diff --git a/CHANGELOG.md b/CHANGELOG.md index 67652d343d..5ef51ab349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Fixed + +- Fixed bound keys (backspace, arrows, etc.) executing out of order with typed characters in `Input` and `TextArea` under fast input https://github.com/Textualize/textual/issues/6605 + ## [8.2.8] - 2026-06-30 ### Fixed diff --git a/src/textual/widget.py b/src/textual/widget.py index 63ef5da97a..3d8f35b05d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4708,6 +4708,30 @@ async def _on_key(self, event: events.Key) -> None: async def handle_key(self, event: events.Key) -> bool: return await dispatch_key(self, event) + async def _run_key_bindings(self, event: events.Key) -> bool: + """Run this widget's own bindings for a key event, without waiting for + the event to bubble to the app. + + Widgets that handle printable keys directly in `_on_key` (such as Input + and TextArea) call this so that bound keys are applied in the order they + were typed, rather than after the event has bubbled to the app, which + could reorder them relative to fast typing. + + Args: + event: A key event. + + Returns: + True if a binding was run, otherwise False. + """ + for binding in self._bindings.key_to_bindings.get(event.key, ()): + if not binding.priority and await self.app.run_action( + binding.action, self + ): + event.stop() + event.prevent_default() + return True + return False + async def _on_compose(self, event: events.Compose) -> None: _rich_traceback_omit = True event.prevent_default() diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index bcf2c2994d..6fdcc14fdb 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -752,6 +752,8 @@ async def _on_key(self, event: events.Key) -> None: else: self.replace(event.character, *selection) event.prevent_default() + else: + await self._run_key_bindings(event) def _on_paste(self, event: events.Paste) -> None: if event.text: diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 1ca74675e5..fc6f90df78 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1847,6 +1847,8 @@ async def _on_key(self, event: events.Key) -> None: assert insert is not None start, end = self.selection self._replace_via_keyboard(insert, start, end) + else: + await self._run_key_bindings(event) def _find_columns_to_next_tab_stop(self) -> int: """Get the location of the next tab stop after the cursors position on the current line. diff --git a/tests/test_key_ordering.py b/tests/test_key_ordering.py new file mode 100644 index 0000000000..be00dadfef --- /dev/null +++ b/tests/test_key_ordering.py @@ -0,0 +1,43 @@ +"""Regression tests for https://github.com/Textualize/textual/issues/6605 + +Keys handled by bindings (such as backspace) used to take a detour through +ancestor message queues before their action ran, so under fast input they +could be applied out of order with printable keys, which Input and TextArea +handle directly. +""" + +from textual import events +from textual.app import App, ComposeResult +from textual.widgets import Input, TextArea + + +def type_fast(app: App, *keys: str) -> None: + """Post key events back to back, without waiting for each to settle.""" + for key in keys: + app.post_message(events.Key(key, key if len(key) == 1 else None)) + + +async def test_input_binding_keys_apply_in_order_with_typed_text(): + class KeysApp(App): + def compose(self) -> ComposeResult: + yield Input() + + app = KeysApp() + async with app.run_test() as pilot: + type_fast(app, "b", "a", "backspace", "c") + for _ in range(3): + await pilot.pause() + assert app.query_one(Input).value == "bc" + + +async def test_text_area_binding_keys_apply_in_order_with_typed_text(): + class KeysApp(App): + def compose(self) -> ComposeResult: + yield TextArea() + + app = KeysApp() + async with app.run_test() as pilot: + type_fast(app, "b", "a", "backspace", "c") + for _ in range(3): + await pilot.pause() + assert app.query_one(TextArea).text == "bc"