mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #1676 from davep/more-input-bindings
More input bindings
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
148
tests/input/test_input_key_modification_actions.py
Normal file
148
tests/input/test_input_key_modification_actions.py
Normal 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]
|
||||
161
tests/input/test_input_key_movement_actions.py
Normal file
161
tests/input/test_input_key_movement_actions.py
Normal 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.
|
||||
Reference in New Issue
Block a user