Skip to content

More input bindings #1676

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 34 commits into from
Jan 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
672bc1b
Add macOS/Emacs-style home/end bindings to `Input`
davep Jan 26, 2023
5d67c76
Add delete key as a delete binding to `Input`
davep Jan 26, 2023
487b2e2
Add delete-to-end to `Input`
davep Jan 26, 2023
53c168c
Add delete-to-start to `Input`
davep Jan 26, 2023
5e1420d
Favour named keys over Ctrl-combos
davep Jan 26, 2023
00c4981
Reorder the `Input` bindings
davep Jan 26, 2023
a2807f2
Add support for jumping to the next word
davep Jan 26, 2023
fade5db
Add support for jumping to the previous word
davep Jan 26, 2023
d815cce
Simplify an expression
davep Jan 26, 2023
1600d98
Tidy up previous word
davep Jan 26, 2023
44d4bc6
Be more forgiving about what a word is
davep Jan 26, 2023
372d835
Start to improve the naming of binding-oriented actions
davep Jan 26, 2023
2fa0956
Improve the documentation for the movement and editing actions
davep Jan 26, 2023
0675b40
Add support for deleting an Input word leftward
davep Jan 26, 2023
3399fb8
Add support for deleting an Input word rightward
davep Jan 26, 2023
7de4924
If going rightward one word an no more word go to end
davep Jan 26, 2023
f4b29d8
Move the current Input tests into a subdirectory
davep Jan 27, 2023
7ddf4bb
Add some initial Input key/action unit tests
davep Jan 27, 2023
938a3b4
Add a test for going left a word from home
davep Jan 29, 2023
fad87c9
Add a test for going left a word from the end
davep Jan 29, 2023
d5a9942
Add a test for going right a word from the start
davep Jan 29, 2023
112c789
Add a test for going right a word from the end
davep Jan 29, 2023
af4a6b0
Fix a typo/thinko in a test name
davep Jan 29, 2023
5bf0542
Rename a test to be more in line with the others
davep Jan 29, 2023
054c23a
Add a test for using right-word to get to the end of an input
davep Jan 29, 2023
b7203ed
Add a test for using left-word to get home from the end of an input
davep Jan 29, 2023
1230ca3
Rename the key action tests
davep Jan 29, 2023
e199dc2
Start Input unit tests for actions that modify the text
davep Jan 30, 2023
af2189f
Fix a docstring typo
davep Jan 30, 2023
9e23a79
Add more Input unit tests for actions that modify the text
davep Jan 30, 2023
a175224
Help some older Pythons along
davep Jan 30, 2023
9df61ba
Update the CHANGELOG
davep Jan 30, 2023
62244b4
Merge branch 'main' into more-input-bindings
davep Jan 30, 2023
11618e7
Merge branch 'main' into more-input-bindings
willmcgugan Jan 30, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
77 changes: 73 additions & 4 deletions src/textual/widgets/_input.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -291,26 +303,64 @@ def insert_text_at_cursor(self, text: str) -> None:
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]
after = value[delete_position + 1 :]
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
Expand All @@ -327,6 +377,25 @@ def action_delete_left(self) -> None:
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))

Expand Down
148 changes: 148 additions & 0 deletions tests/input/test_input_key_modification_actions.py
Original file line number Diff line number Diff line change
@@ -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]
Loading