Merge pull request #1676 from davep/more-input-bindings

More input bindings
This commit is contained in:
Dave Pearson
2023-01-30 15:20:09 +00:00
committed by GitHub
5 changed files with 383 additions and 4 deletions

View File

@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added the coroutines `Animator.wait_until_complete` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658
- Added the method `Animator.is_being_animated` that checks if an attribute of an object is being animated or is scheduled for animation
- Added more keyboard actions and related bindings to `Input` https://github.com/Textualize/textual/pull/1676
- Added App.scroll_sensitivity_x and App.scroll_sensitivity_y to adjust how many lines the scroll wheel moves the scroll position https://github.com/Textualize/textual/issues/928
- Added Shift+scroll wheel and ctrl+scroll wheel to scroll horizontally

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import re
from rich.cells import cell_len, get_character_cell_size
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
from rich.highlighter import Highlighter
@@ -81,12 +83,22 @@ class Input(Widget, can_focus=True):
BINDINGS = [
Binding("left", "cursor_left", "cursor left", show=False),
Binding("ctrl+left", "cursor_left_word", "cursor left word", show=False),
Binding("right", "cursor_right", "cursor right", show=False),
Binding("backspace", "delete_left", "delete left", show=False),
Binding("home", "home", "home", show=False),
Binding("end", "end", "end", show=False),
Binding("ctrl+d", "delete_right", "delete right", show=False),
Binding("ctrl+right", "cursor_right_word", "cursor right word", show=False),
Binding("home,ctrl+a", "home", "home", show=False),
Binding("end,ctrl+e", "end", "end", show=False),
Binding("enter", "submit", "submit", show=False),
Binding("backspace", "delete_left", "delete left", show=False),
Binding(
"ctrl+w", "delete_left_word", "delete left to start of word", show=False
),
Binding("ctrl+u", "delete_left_all", "delete all to the left", show=False),
Binding("delete,ctrl+d", "delete_right", "delete right", show=False),
Binding(
"ctrl+f", "delete_right_word", "delete right to start of word", show=False
),
Binding("ctrl+k", "delete_right_all", "delete all to the right", show=False),
]
COMPONENT_CLASSES = {"input--cursor", "input--placeholder"}
@@ -291,18 +303,42 @@ class Input(Widget, can_focus=True):
self.cursor_position += len(text)
def action_cursor_left(self) -> None:
"""Move the cursor one position to the left."""
self.cursor_position -= 1
def action_cursor_right(self) -> None:
"""Move the cursor one position to the right."""
self.cursor_position += 1
def action_home(self) -> None:
"""Move the cursor to the start of the input."""
self.cursor_position = 0
def action_end(self) -> None:
"""Move the cursor to the end of the input."""
self.cursor_position = len(self.value)
_WORD_START = re.compile(r"(?<=\W)\w")
def action_cursor_left_word(self) -> None:
"""Move the cursor left to the start of a word."""
try:
*_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position])
except ValueError:
self.cursor_position = 0
else:
self.cursor_position = hit.start()
def action_cursor_right_word(self) -> None:
"""Move the cursor right to the start of a word."""
hit = re.search(self._WORD_START, self.value[self.cursor_position :])
if hit is None:
self.cursor_position = len(self.value)
else:
self.cursor_position += hit.start()
def action_delete_right(self) -> None:
"""Delete one character at the current cursor position."""
value = self.value
delete_position = self.cursor_position
before = value[:delete_position]
@@ -310,7 +346,21 @@ class Input(Widget, can_focus=True):
self.value = f"{before}{after}"
self.cursor_position = delete_position
def action_delete_right_word(self) -> None:
"""Delete the current character and all rightward to the start of the next word."""
after = self.value[self.cursor_position :]
hit = re.search(self._WORD_START, after)
if hit is None:
self.value = self.value[: self.cursor_position]
else:
self.value = f"{self.value[: self.cursor_position]}{after[hit.end()-1 :]}"
def action_delete_right_all(self) -> None:
"""Delete the current character and all characters to the right of the cursor position."""
self.value = self.value[: self.cursor_position]
def action_delete_left(self) -> None:
"""Delete one character to the left of the current cursor position."""
if self.cursor_position <= 0:
# Cursor at the start, so nothing to delete
return
@@ -327,6 +377,25 @@ class Input(Widget, can_focus=True):
self.value = f"{before}{after}"
self.cursor_position = delete_position
def action_delete_left_word(self) -> None:
"""Delete leftward of the cursor position to the start of a word."""
if self.cursor_position <= 0:
return
after = self.value[self.cursor_position :]
try:
*_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position])
except ValueError:
self.cursor_position = 0
else:
self.cursor_position = hit.start()
self.value = f"{self.value[: self.cursor_position]}{after}"
def action_delete_left_all(self) -> None:
"""Delete all characters to the left of the cursor position."""
if self.cursor_position > 0:
self.value = self.value[self.cursor_position :]
self.cursor_position = 0
async def action_submit(self) -> None:
await self.emit(self.Submitted(self, self.value))

View File

@@ -0,0 +1,148 @@
"""Unit tests for Input widget value modification actions."""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import Input
TEST_INPUTS: dict[str | None, str] = {
"empty": "",
"multi-no-punctuation": "Curse your sudden but inevitable betrayal",
"multi-punctuation": "We have done the impossible, and that makes us mighty.",
"multi-and-hyphenated": "Long as she does it quiet-like",
}
class InputTester(App[None]):
"""Input widget testing app."""
def compose(self) -> ComposeResult:
for input_id, value in TEST_INPUTS.items():
yield Input(value, id=input_id)
async def test_delete_left_from_home() -> None:
"""Deleting left from home should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_delete_left()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id]
async def test_delete_left_from_end() -> None:
"""Deleting left from end should remove the last character (if there is one)."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_left()
assert input.cursor_position == len(input.value)
assert input.value == TEST_INPUTS[input.id][:-1]
async def test_delete_left_word_from_home() -> None:
"""Deleting word left from home should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_delete_left_word()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id]
async def test_delete_left_word_from_end() -> None:
"""Deleting word left from end should remove the expected text."""
async with InputTester().run_test() as pilot:
expected: dict[str | None, str] = {
"empty": "",
"multi-no-punctuation": "Curse your sudden but inevitable ",
"multi-punctuation": "We have done the impossible, and that makes us ",
"multi-and-hyphenated": "Long as she does it quiet-",
}
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_left_word()
assert input.cursor_position == len(input.value)
assert input.value == expected[input.id]
async def test_delete_left_all_from_home() -> None:
"""Deleting all left from home should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_delete_left_all()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id]
async def test_delete_left_all_from_end() -> None:
"""Deleting all left from end should empty the input value."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_left_all()
assert input.cursor_position == 0
assert input.value == ""
async def test_delete_right_from_home() -> None:
"""Deleting right from home should delete one character (if there is any to delete)."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_delete_right()
assert input.cursor_position == 0
assert input.value == TEST_INPUTS[input.id][1:]
async def test_delete_right_from_end() -> None:
"""Deleting right from end should not change the input's value."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_right()
assert input.cursor_position == len(input.value)
assert input.value == TEST_INPUTS[input.id]
async def test_delete_right_word_from_home() -> None:
"""Deleting word right from home should delete one word (if there is one)."""
async with InputTester().run_test() as pilot:
expected: dict[str | None, str] = {
"empty": "",
"multi-no-punctuation": "your sudden but inevitable betrayal",
"multi-punctuation": "have done the impossible, and that makes us mighty.",
"multi-and-hyphenated": "as she does it quiet-like",
}
for input in pilot.app.query(Input):
input.action_delete_right_word()
assert input.cursor_position == 0
assert input.value == expected[input.id]
async def test_delete_right_word_from_end() -> None:
"""Deleting word right from end should not change the input's value."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_right_word()
assert input.cursor_position == len(input.value)
assert input.value == TEST_INPUTS[input.id]
async def test_delete_right_all_from_home() -> None:
"""Deleting all right home should remove everything in the input."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_delete_right_all()
assert input.cursor_position == 0
assert input.value == ""
async def test_delete_right_all_from_end() -> None:
"""Deleting all right from end should not change the input's value."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_delete_right_all()
assert input.cursor_position == len(input.value)
assert input.value == TEST_INPUTS[input.id]

View File

@@ -0,0 +1,161 @@
"""Unit tests for Input widget position movement actions."""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import Input
class InputTester(App[None]):
"""Input widget testing app."""
def compose(self) -> ComposeResult:
for value, input_id in (
("", "empty"),
("Shiny", "single-word"),
("Curse your sudden but inevitable betrayal", "multi-no-punctuation"),
(
"We have done the impossible, and that makes us mighty.",
"multi-punctuation",
),
("Long as she does it quiet-like", "multi-and-hyphenated"),
):
yield Input(value, id=input_id)
async def test_input_home() -> None:
"""Going home should always land at position zero."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_home()
assert input.cursor_position == 0
async def test_input_end() -> None:
"""Going end should always land at the last position."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
assert input.cursor_position == len(input.value)
async def test_input_right_from_home() -> None:
"""Going right should always land at the next position, if there is one."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_cursor_right()
assert input.cursor_position == (1 if input.value else 0)
async def test_input_right_from_end() -> None:
"""Going right should always stay put if doing so from the end."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_cursor_right()
assert input.cursor_position == len(input.value)
async def test_input_left_from_home() -> None:
"""Going left from home should stay put."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_cursor_left()
assert input.cursor_position == 0
async def test_input_left_from_end() -> None:
"""Going left from the end should go back one place, where possible."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_cursor_left()
assert input.cursor_position == (len(input.value) - 1 if input.value else 0)
async def test_input_left_word_from_home() -> None:
"""Going left one word from the start should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_cursor_left_word()
assert input.cursor_position == 0
async def test_input_left_word_from_end() -> None:
"""Going left one word from the end should land correctly.."""
async with InputTester().run_test() as pilot:
expected_at: dict[str | None, int] = {
"empty": 0,
"single-word": 0,
"multi-no-punctuation": 33,
"multi-punctuation": 47,
"multi-and-hyphenated": 26,
}
for input in pilot.app.query(Input):
input.action_end()
input.action_cursor_left_word()
assert input.cursor_position == expected_at[input.id]
async def test_input_right_word_from_home() -> None:
"""Going right one word from the start should land correctly.."""
async with InputTester().run_test() as pilot:
expected_at: dict[str | None, int] = {
"empty": 0,
"single-word": 5,
"multi-no-punctuation": 6,
"multi-punctuation": 3,
"multi-and-hyphenated": 5,
}
for input in pilot.app.query(Input):
input.action_cursor_right_word()
assert input.cursor_position == expected_at[input.id]
async def test_input_right_word_from_end() -> None:
"""Going right one word from the end should do nothing."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.action_cursor_right_word()
assert input.cursor_position == len(input.value)
async def test_input_right_word_to_the_end() -> None:
"""Using right-word to get to the end should hop the correct number of times."""
async with InputTester().run_test() as pilot:
expected_hops: dict[str | None, int] = {
"empty": 0,
"single-word": 1,
"multi-no-punctuation": 6,
"multi-punctuation": 10,
"multi-and-hyphenated": 7,
}
for input in pilot.app.query(Input):
hops = 0
while input.cursor_position < len(input.value):
input.action_cursor_right_word()
hops += 1
assert hops == expected_hops[input.id]
async def test_input_left_word_from_the_end() -> None:
"""Using left-word to get home from the end should hop the correct number of times."""
async with InputTester().run_test() as pilot:
expected_hops: dict[str | None, int] = {
"empty": 0,
"single-word": 1,
"multi-no-punctuation": 6,
"multi-punctuation": 10,
"multi-and-hyphenated": 7,
}
for input in pilot.app.query(Input):
input.action_end()
hops = 0
while input.cursor_position:
input.action_cursor_left_word()
hops += 1
assert hops == expected_hops[input.id]
# TODO: more tests.