Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions actop/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion tests/test_per_process_power.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 4 additions & 8 deletions tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down