Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/textual/widgets/_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions tests/test_key_ordering.py
Original file line number Diff line number Diff line change
@@ -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"