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)