From 9ac43758eb880a5f8bb2edf5a25c36def0bda90c Mon Sep 17 00:00:00 2001 From: Bin Le Date: Sun, 23 Aug 2026 13:14:31 -0400 Subject: [PATCH] tui: stop the hidden filter input from swallowing every letter key Textual's default screen AUTO_FOCUS ("*") focuses the first focusable widget, and `display = False` does not make a widget unfocusable (`visible` tracks the `visibility` style, not `display`). The hidden #filter-input therefore won the focus on mount and consumed every printable key: pressing `l` typed an `l` into an invisible regex box instead of cycling the layout preset. `g`, `c`, `p`, `s`, `t` and `q` were dead the same way -- a user could not even quit. Set AUTO_FOCUS = None and assign focus explicitly via a new _focus_default_widget(): the process table when it is shown (its cursor wants the arrow keys), else the non-focusable dashboard. Call it on mount, when `p` toggles the table, and when the filter closes. DataTable claims only navigation keys, so the letter bindings stay live while it holds focus. The TUI tests had been papering over this with `app.set_focus(None)`, blaming the headless harness for what was a real production bug; those workarounds are removed so the tests now fail if focus regresses. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 17 +++++++++++++++++ actop/tui/app.py | 26 ++++++++++++++++++++++++-- tests/test_per_process_power.py | 1 - tests/test_tui_app.py | 12 ++++-------- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 538fbfa..d75d4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ This project follows a Keep a Changelog-style format and uses version tags for r ## [Unreleased] +### Fixed + +- **Every single-letter keybinding was dead on startup (`l`, `g`, `c`, `p`, `s`, + `t`, `q`).** The hidden `#filter-input` was auto-focused on mount and silently + swallowed the keys: pressing `l` typed an `l` into an invisible regex box + instead of cycling the layout preset. Textual's default screen `AUTO_FOCUS` + (`"*"`) focuses the first focusable widget, and `display = False` does *not* + make a widget unfocusable — `visible` tracks the `visibility` style, not + `display` — so the invisible input was a legitimate focus candidate and won. + `ActopApp` now sets `AUTO_FOCUS = None` and assigns focus explicitly + (`_focus_default_widget()`: the process table when shown, else the + non-focusable dashboard), so the letter bindings are live from the first frame + and the process table gets the arrow keys when it is visible. + The TUI tests had been papering over this with `app.set_focus(None)`, blaming + the headless harness for what was a real production bug; those workarounds are + removed so the tests now fail if focus regresses. + ## [1.9.10] - 2026-08-19 ### Added diff --git a/actop/tui/app.py b/actop/tui/app.py index 27e53ed..2865c9d 100644 --- a/actop/tui/app.py +++ b/actop/tui/app.py @@ -281,6 +281,9 @@ class ActopApp(App): ("c", "toggle_cores", "Cores"), ("t", "cycle_theme", "Theme"), ] + # No auto-focus: see the on_mount comment. Focus is assigned explicitly by + # `_focus_default_widget()` so a hidden Input can never claim the keyboard. + AUTO_FOCUS = None BINDINGS = [ *_LETTER_BINDINGS, Binding("space", "toggle_pause", "Pause"), @@ -353,11 +356,19 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self) -> None: + # Textual's default screen AUTO_FOCUS ("*") focuses the first focusable + # widget, and `display = False` does NOT make a widget unfocusable + # (`visible` tracks the `visibility` style, not `display`) — so the + # hidden #filter-input won the focus and silently ate every printable + # key. `l`, `g`, `c`, `p`, `s`, `t` all went dead: the keystrokes were + # typed into an invisible regex box instead of firing their bindings. + # AUTO_FOCUS is cleared at class level; focus is placed explicitly here. self.query_one("#main-section").display = False self.query_one("#status-line").display = False # hidden behind the splash self.query_one("#process-table", DataTable).display = self._show_processes self._refresh_process_table() # initialises columns without advancing sort self._splash_timer = self.set_interval(0.1, self._tick_splash) + self._focus_default_widget() self.poll_metrics() @work(thread=True, exclusive=True) @@ -480,16 +491,27 @@ def action_toggle_processes(self) -> None: self._processes_pending = True table.display = self._show_processes self._refresh_process_table() + # Follow the table with the focus: a revealed table needs the arrow keys + # for its cursor, and a hidden one must not keep them (nor keep focus on + # a `display = False` widget). Letter bindings stay live either way — + # DataTable claims only navigation keys, never printable ones. + self._focus_default_widget() # Re-evaluate check_action so the footer shows/hides `/ Filter` at once. self.refresh_bindings() - def _close_filter_input(self, inp) -> None: - inp.display = False + def _focus_default_widget(self) -> None: + """Park focus on a widget that does not consume printable keys, so the + single-letter bindings stay live. The process table when it is shown + (its cursor wants the arrow keys), else the dashboard.""" if self._show_processes: self.set_focus(self.query_one("#process-table", DataTable)) else: self.set_focus(self.query_one("#hardware-dash", HardwareDashboard)) + def _close_filter_input(self, inp) -> None: + inp.display = False + self._focus_default_widget() + def action_toggle_filter(self) -> None: if not self._show_processes: return # filter applies only to the process table; no-op while hidden diff --git a/tests/test_per_process_power.py b/tests/test_per_process_power.py index c95d83f..532d633 100644 --- a/tests/test_per_process_power.py +++ b/tests/test_per_process_power.py @@ -345,7 +345,6 @@ def rows(): break await asyncio.sleep(0.25) - app.set_focus(None) # harness focuses the hidden filter Input await pilot.press("p") await pilot.pause() pending = rows() diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 386287e..24367c1 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -52,10 +52,10 @@ async def _run(): from actop.tui.widgets import HardwareDashboard dash = app.query_one("#hardware-dash", HardwareDashboard) - # The headless harness auto-focuses the (hidden) filter Input, which - # would swallow the single-letter key; clear focus so `l` routes to - # the app binding the way it does when the filter is inactive. - app.set_focus(None) + # No focus fixup: `l` must reach the app binding on a freshly + # mounted app. This test used to call `app.set_focus(None)` first, + # blaming the headless harness — but the hidden #filter-input really + # was auto-focused and really did swallow the key in production. before = dash.layout_preset await pilot.press("l") await pilot.pause() @@ -85,7 +85,6 @@ async def _run(): from actop.tui.widgets import HardwareDashboard dash = app.query_one("#hardware-dash", HardwareDashboard) - app.set_focus(None) # as in the lowercase test: unfocus the filter state = {} for key, read in ( ("L", lambda: dash.layout_preset), @@ -111,7 +110,6 @@ async def _run(): app = ActopApp(build_parser().parse_args([])) async with app.run_test(size=(160, 50)) as pilot: await pilot.pause() - app.set_focus(None) await pilot.press("Q") await pilot.pause() return app.is_running @@ -126,7 +124,6 @@ async def _run(): app = ActopApp(build_parser().parse_args(["--theme", "nord"])) async with app.run_test() as pilot: await pilot.pause() - app.set_focus(None) assert app.theme == "nord" await pilot.press("t") await pilot.pause() @@ -162,7 +159,6 @@ async def _run(): app = ActopApp(build_parser().parse_args([])) async with app.run_test() as pilot: await pilot.pause() - app.set_focus(None) from textual.widgets import DataTable table = app.query_one("#process-table", DataTable)