From 6c517e4ecbd33316bbc21b6ca8866e50bafbf915 Mon Sep 17 00:00:00 2001 From: xavierk Date: Mon, 5 Oct 2026 20:42:04 +0530 Subject: [PATCH] feat(tui): unify keyboard and mouse navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare every dashboard key as a Binding on the widget that owns it, and generate the footer, activity tool chips and help screen from those bindings so they cannot drift. The footer collapses to "? help · q quit" under 80x24. Add Home/End and PgUp/PgDn (one week) navigation, mouse-wheel selection on the graphs, click-to-focus on every panel, and clickable footer chips. Tab and Shift+Tab now visit only the dashboard panels. --- CHANGELOG.md | 4 + README.md | 12 +- src/fenris/tui.py | 314 +++++++++++++++--------- tests/test_live_graph.py | 2 +- tests/test_tui.py | 10 +- tests/test_tui_navigation.py | 456 +++++++++++++++++++++++++++++++++++ 6 files changed, 669 insertions(+), 129 deletions(-) create mode 100644 tests/test_tui_navigation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a37724..75050da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,13 @@ backfill releases from before this changelog. - Show the projection stage and an evidence ladder of Supported conditions, each met or unmet with its reason, so confidence visibly strengthens with each collection run. - Show the sustained-regime write rate when no endurance baseline applies. - Enter a rated TBW from the dashboard with `b`, saved as an unverified override, so the lifespan appears without the CLI. +- Jump to the first or last point with Home and End on every graph, and a week at a time with PgUp and PgDn in the Day and History views. +- Move through graph points with the mouse wheel, focus any panel by clicking it, and click the footer and activity tool chips to run their actions. ### Changed +- Generate the footer and help screen from the dashboard's key bindings so they always agree, and collapse the footer to "? help · q quit" under 80x24. +- Make Tab and Shift+Tab visit only the dashboard panels. - Show every dashboard time in local time with its timezone, keep a secondary UTC line when a point is inspected, and report the monitoring period start in local time from `fenris-monitor enable`. - Treat the complete-local-day requirement as a contributing fact rather than a gate, and stop the TUI and CLI from deciding when a projection can be shown. diff --git a/README.md b/README.md index 1c7e1aa..d1fa1b2 100644 --- a/README.md +++ b/README.md @@ -271,16 +271,20 @@ shows incomplete or unavailable dates without treating them as zero. Older UTC-only summaries cannot establish exact local-day totals. - `v` cycles Live / Day / History; the tabs are also clickable. -- `←` / `→` inspect points; `w` switches read/write volume in every view. +- `←` / `→` inspect points, `Home` / `End` jump to the first or last point, and + `PgUp` / `PgDn` jump a week in Day and History (the live window is three + hours); the mouse wheel moves the selection too. `w` + switches read/write volume in every view. - `[` / `]` browse dates, `g` enters a date, and `t` returns to today/live. -- `Tab` / `Shift+Tab` move focus; `z` expands the focused panel, and `z` or - `Esc` restores it. Monitoring status and controls remain visible. +- `Tab` / `Shift+Tab` move focus between panels (clicking a panel focuses it); + `z` expands the focused panel, and `z` or `Esc` restores it. Monitoring status and controls remain visible. - `s` cycles Chalktone, Amber, Nord, and High Contrast; saved theme preferences survive upgrades. `m` toggles reduced motion. On smaller terminals, textual summaries and scrollable panels keep evidence accessible. Pause, resume, collect, disclosures, help, and quit remain available -in the fixed control row. +in the fixed control row, which collapses to `? help · q quit` under 80×24. `?` +lists every key; the footer and help are generated from the same bindings. Run `fenris` as your normal user to open the TUI dashboard. The dashboard does not need `sudo`. Use `sudo` for package installation and system configuration; diff --git a/src/fenris/tui.py b/src/fenris/tui.py index 7e06c22..61ce51a 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -3,7 +3,8 @@ Presentation follows docs/spec/glint-dashboard-design.md; shared status, projection, evidence, and privileged-action contracts remain unchanged. -Bindings: p (pause, asks), r (resume), c (collect now), d (disclosures), q (quit). +Every key is declared once as a Binding; the footer, tool chips and help screen +are generated from those bindings (issue #110). Privileged actions route through fenris-monitor as terminal-attached subprocesses (LC-6, LC-8). The TUI never samples in-process. @@ -86,10 +87,9 @@ from .timefmt import local_zone_name # --------------------------------------------------------------------------- _RESUME_HINT = "r Resume — enable monitoring and future boots" -_ACTION_LEGEND = ( - "p Pause · " + _RESUME_HINT + "\n" - "b Rated TBW · c Collect now · t Today · [ ] Day · g Date · m Motion · d Disclosures · ? Help" -) +_COLLAPSED_FOOTER = "? help · q quit" +# Navigation actions shown as chips above the graphs instead of in the footer. +_TOOL_ACTIONS = ("prev_day", "next_day", "go_to_date", "today", "toggle_measure") def _format_remaining(seconds: float) -> str: """Format remaining lifespan as human-readable string.""" @@ -151,6 +151,20 @@ class HistoryGraph(Widget): can_focus = True can_focus_children = False + BINDINGS = [ + Binding("left", "move(-1)", "Previous point", show=False), + Binding("right", "move(1)", "Next point", show=False), + Binding("home", "edge(-1)", "First point", show=False), + Binding("end", "edge(1)", "Last point", show=False), + Binding("pageup", "week(-1)", "Back one week", show=False), + Binding("pagedown", "week(1)", "Forward one week", show=False), + Binding("enter", "drill", "Show hours", show=False), + Binding("escape", "back", "Back to days", show=False), + Binding("backspace", "back", "Back to days", show=False), + *(Binding(str(n + 1), "set_range(%d)" % n, "History range", show=False) + for n in range(len(_RANGE_OPTIONS))), + ] + class DaySelected(Message): def __init__(self, day: str) -> None: super().__init__() @@ -545,45 +559,82 @@ class HistoryGraph(Widget): # -- Event handling -- - def on_key(self, event: Any) -> None: + # -- Navigation (bindings; the footer and help screen read these) -- + + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + if action == "drill": + return self.view_mode == "daily" and self.selected_index >= 0 + if action == "set_range": + return self.view_mode == "daily" + if action == "back": + # A zoomed panel restores first; the app's Escape binding handles it. + return self.view_mode == "hourly" and not getattr(self.app, "_zoomed_panel", None) + return True + + def _point_index(self) -> int: + return self.selected_index if self.view_mode == "daily" else self._hourly_selected + + def _point_count(self) -> int: + return len(self._day_data if self.view_mode == "daily" else self._hour_data) + + def _select_point(self, index: int) -> None: + count = self._point_count() + if not count: + return + index = max(0, min(count - 1, index)) if self.view_mode == "daily": - self._handle_daily_key(event) - else: - self._handle_hourly_key(event) - - def _handle_daily_key(self, event: Any) -> None: - if event.key == "left": - if self.selected_index < 0: - self.selected_index = len(self._day_data) - 1 - else: - self.selected_index = max(0, self.selected_index - 1) + self.selected_index = index self._refresh() - event.stop() - elif event.key == "right": - if self.selected_index < 0: - self.selected_index = 0 - else: - self.selected_index = min( - len(self._day_data) - 1, self.selected_index + 1 - ) - self._refresh() - event.stop() - elif event.key == "enter" and self.selected_index >= 0: - self._enter_drill() - event.stop() - elif event.key in ("1", "2", "3", "4"): - self.range_days = _RANGE_OPTIONS[int(event.key) - 1] - selected_day = self._day_data[self.selected_index].get("day") if self._day_data else None - self._trim_to_range() - self.selected_index = next( - (i for i, item in enumerate(self._day_data) if item.get("day") == selected_day), - len(self._day_data) - 1, - ) - self._refresh() - event.stop() - - if event.key in ("left", "right", "1", "2", "3", "4"): self._notify_selection() + else: + self._hourly_selected = index + self._select_history_hour() + self._refresh_hourly() + + def action_move(self, offset: int) -> None: + current = self._point_index() + if current < 0: + self._select_point(self._point_count() - 1 if offset < 0 else 0) + else: + self._select_point(current + offset) + + def action_edge(self, direction: int) -> None: + self._select_point(0 if direction < 0 else self._point_count() - 1) + + def action_week(self, direction: int) -> None: + if self.view_mode == "hourly": + self.app._browse_day(7 * direction) + return + current = self._point_index() + self._select_point((current if current >= 0 else self._point_count() - 1) + 7 * direction) + + def action_drill(self) -> None: + self._enter_drill() + + def action_back(self) -> None: + self._exit_drill() + self.app._set_activity_view("history") + + def action_set_range(self, index: int) -> None: + self.range_days = _RANGE_OPTIONS[index] + selected_day = self._day_data[self.selected_index].get("day") if self._day_data else None + self._trim_to_range() + self.selected_index = next( + (i for i, item in enumerate(self._day_data) if item.get("day") == selected_day), + len(self._day_data) - 1, + ) + self._refresh() + self._notify_selection() + + def on_mouse_scroll_up(self, event: Any) -> None: + event.stop() + event.prevent_default() + self.action_move(-1) + + def on_mouse_scroll_down(self, event: Any) -> None: + event.stop() + event.prevent_default() + self.action_move(1) def _notify_selection(self) -> None: if 0 <= self.selected_index < len(self._day_data): @@ -642,39 +693,6 @@ class HistoryGraph(Widget): self._hourly_selected = -1 self._refresh() - def _handle_hourly_key(self, event: Any) -> None: - if event.key == "escape" and getattr(self.app, "_zoomed_panel", None): - return - if event.key in ("escape", "backspace"): - self._exit_drill() - if hasattr(self.app, "_set_activity_view"): - self.app._set_activity_view("history") - event.stop() - elif event.key == "left": - if not self._hour_data: - event.stop() - return - if self._hourly_selected < 0: - self._hourly_selected = len(self._hour_data) - 1 - else: - self._hourly_selected = max(0, self._hourly_selected - 1) - self._select_history_hour() - self._refresh_hourly() - event.stop() - elif event.key == "right": - if not self._hour_data: - event.stop() - return - if self._hourly_selected < 0: - self._hourly_selected = 0 - else: - self._hourly_selected = min( - len(self._hour_data) - 1, self._hourly_selected + 1 - ) - self._select_history_hour() - self._refresh_hourly() - event.stop() - def _select_history_hour(self) -> None: selected_hour = self._selected_history_hour() if selected_hour is not None: @@ -770,6 +788,13 @@ class LiveActivityGraph(Widget): can_focus = True can_focus_children = False + BINDINGS = [ + Binding("left", "move(-1)", "Previous point", show=False), + Binding("right", "move(1)", "Next point", show=False), + Binding("home", "edge(-1)", "First point", show=False), + Binding("end", "edge(1)", "Last point", show=False), + ] + DEFAULT_CSS = """ LiveActivityGraph { height: 100%; @@ -910,14 +935,27 @@ class LiveActivityGraph(Widget): return "[bold yellow]Inspected interval expired; following live[/bold yellow]\n" return "" - def on_key(self, event: Any) -> None: - offset = -1 if event.key == "left" else 1 if event.key == "right" else 0 - if offset: - intervals = self._interval_ids() - self.selection.move_live(intervals, offset) - self._selected_index = self.selection.selected_live_index(intervals) - self._refresh() - event.stop() + def _move_live(self, offset: int) -> None: + intervals = self._interval_ids() + self.selection.move_live(intervals, offset) + self._selected_index = self.selection.selected_live_index(intervals) + self._refresh() + + def action_move(self, offset: int) -> None: + self._move_live(offset) + + def action_edge(self, direction: int) -> None: + self._move_live(direction * len(self._data)) + + def on_mouse_scroll_up(self, event: Any) -> None: + event.stop() + event.prevent_default() + self._move_live(-1) + + def on_mouse_scroll_down(self, event: Any) -> None: + event.stop() + event.prevent_default() + self._move_live(1) def on_click(self, event: Any) -> None: render = self.query_one("#live-render") @@ -983,6 +1021,23 @@ class DisclosuresScreen(ModalScreen[None]): self.dismiss() +def _literal(text: str) -> str: + """Keep key names such as "[" from being read as markup.""" + return text.replace("[", "\\[") + + +def binding_rows(app: App) -> Dict[str, str]: + """Every dashboard binding, grouped by description, keys joined for display.""" + rows: Dict[str, List[str]] = {} + for source in (FenrisTuiApp, LiveActivityGraph, HistoryGraph): + for binding in source.BINDINGS: + keys = rows.setdefault(binding.description, []) + display = app.get_key_display(binding) + if display not in keys: + keys.append(display) + return {description: " / ".join(keys) for description, keys in rows.items()} + + class HelpScreen(ModalScreen[None]): """Persistent keyboard and privilege guidance.""" @@ -1007,14 +1062,11 @@ class HelpScreen(ModalScreen[None]): " sudo fenris sample\n" "Then reopen the dashboard with fenris.\n\n" "[bold]Keyboard controls[/bold]\n" - + _ACTION_LEGEND + "\n" - "q Quit the dashboard — monitoring continues\n" - "Tab / Shift+Tab Focus panels · ← → Select · 1-4 History range\n" - "v Live / Day / History · z Zoom panel · Esc Restore\n" - "[ Previous day · ] Next day · g Go to date\n" - "Enter Show hours · Esc Return to days\n" - "w Toggle read/write volume on every graph\n" - "s Cycle Chalktone / Amber / Nord / High Contrast\n" + + "\n".join( + _literal(keys.ljust(16) + description) + for description, keys in binding_rows(self.app).items() + ) + + "\n\n" "Dotted lines join adjacent measured volumes; gaps are never filled.\n" "Values are bytes per interval, hour, or day, not transfer speed.\n\n" "[dim]↑ ↓ Scroll · Esc Close[/dim]", @@ -1220,23 +1272,26 @@ class FenrisTuiApp(App): """ BINDINGS = [ - Binding("p", "pause", "Pause", show=False), - Binding("r", "resume", "Resume", show=False), - Binding("b", "baseline", "Rated TBW", show=False), - Binding("c", "collect", "Collect now", show=False), - Binding("d", "disclose", "Disclosures", show=False), - Binding("t", "today", "Today", show=False), - Binding("left_square_bracket", "prev_day", "Previous day", show=False), + Binding("p", "pause", "Pause"), + Binding("r", "resume", "Resume"), + Binding("c", "collect", "Collect"), + Binding("b", "baseline", "Rated TBW"), + Binding("d", "disclose", "Disclosures"), + Binding("z", "toggle_zoom", "Zoom"), + Binding("escape", "exit_zoom", "Restore zoom", show=False), + Binding("v", "next_view", "View"), + Binding("s", "cycle_theme", "Theme"), + Binding("m", "toggle_motion", "Motion"), + Binding("tab", "focus_next", "Focus", key_display="Tab"), + Binding("shift+tab", "focus_previous", "Previous panel", show=False, + key_display="Shift+Tab"), + Binding("?", "help", "Help"), + Binding("q", "quit", "Quit TUI"), + Binding("left_square_bracket", "prev_day", "Prev day", show=False), Binding("right_square_bracket", "next_day", "Next day", show=False), Binding("g", "go_to_date", "Go to date", show=False), - Binding("m", "toggle_motion", "Motion", show=False), - Binding("w", "toggle_measure", "W/R", show=False), - Binding("?", "help", "Help", show=False), - Binding("q", "quit", "Quit", show=False), - Binding("z", "toggle_zoom", "Zoom", show=False), - Binding("escape", "exit_zoom", "Restore", show=False), - Binding("v", "next_view", "View", show=False), - Binding("s", "cycle_theme", "Theme", show=False), + Binding("t", "today", "Today", show=False), + Binding("w", "toggle_measure", "Toggle read/write", show=False), ] def __init__( @@ -1303,10 +1358,7 @@ class FenrisTuiApp(App): yield Tabs(Tab("Live", id="view-live"), Tab("Day", id="view-day"), Tab("History", id="view-history"), id="activity-tabs") yield Static("", id="local-day") - yield Static( - "[@click=app.prev_day]← Day[/] [@click=app.next_day]Day →[/] " - "[@click=app.go_to_date]g Date[/] [@click=app.today]t Today[/] " - "[@click=app.toggle_measure]w W/R[/]", id="activity-tools") + yield Static("", id="activity-tools") yield LiveActivityGraph( id="live-activity", selection=self._activity_selection, ) @@ -1317,13 +1369,7 @@ class FenrisTuiApp(App): yield Static("", id="drive-health") with VerticalScroll(id="status-area"): yield Static("", id="service-strip") - yield Static( - "[@click=app.pause]p Pause[/] · [@click=app.resume]r Resume[/] · " - "[@click=app.collect]c Collect[/] · [@click=app.disclose]d Disclosures[/] · " - "[@click=app.quit]q Quit TUI[/]\n" - "Tab Focus · [@click=app.toggle_zoom]z Zoom[/] · [@click=app.next_view]v View[/] · " - "[@click=app.cycle_theme]s Theme[/] · [@click=app.toggle_motion]m Motion[/] · " - "[@click=app.help]? Help[/]", id="action-rail") + yield Static("", id="action-rail") def on_mount(self) -> None: self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-") @@ -1331,11 +1377,46 @@ class FenrisTuiApp(App): self.query_one("#activity-panel").border_title = "Drive activity" self.query_one("#drive-panel").border_title = "Drive" self.query_one("#live-activity").border_title = "Live activity" + # Tab visits panels only: scroll hosts and the view tabs stay out of the order. + for selector in ("#dashboard-scroll", "#status-area", "#activity-tabs"): + self.query_one(selector).can_focus = False + self._render_tools() self._refresh_timer = self.set_interval(self.refresh_interval_s, self.on_refresh_tick) self._refresh() self._apply_activity_view() self.query_one("#live-activity").focus(scroll_visible=not self._is_constrained_mode) + def _chip(self, binding: Binding) -> str: + label = "%s %s" % (self.get_key_display(binding), binding.description) + return "[@click=app.%s]%s[/]" % (binding.action, _literal(label)) + + def _render_tools(self) -> None: + chips = {b.action: self._chip(b) for b in self.BINDINGS if b.action in _TOOL_ACTIONS} + self.query_one("#activity-tools").update(" ".join(chips[a] for a in _TOOL_ACTIONS)) + + def _render_footer(self) -> None: + """Footer chips come from the visible bindings; small terminals collapse it.""" + if self._is_constrained_mode: + by_action = {b.action: b for b in self.BINDINGS} + text = " · ".join( + "[@click=app.%s]%s %s[/]" % ( + action, _literal(self.get_key_display(by_action[action])), word) + for action, word in (("help", "help"), ("quit", "quit"))) + else: + text = " · ".join(self._chip(b) for b in self.BINDINGS if b.show) + self.query_one("#action-rail").update(text) + + def on_click(self, event: Any) -> None: + """Clicking anywhere in a panel focuses it (the graph, for the activity panel).""" + pane = next((w for w in event.widget.ancestors_with_self + if w.has_class("dashboard-pane")), None) if event.widget else None + focused = self.focused + if pane is None or (focused is not None and pane in focused.ancestors_with_self): + return + if pane.id == "activity-panel": + pane = self.query_one("#live-activity" if self._activity_view == "live" else "#usage-history") + pane.focus(scroll_visible=not self._is_constrained_mode) + def _apply_activity_view(self) -> None: self.query_one("#live-activity").display = self._activity_view == "live" self.query_one("#usage-history").display = self._activity_view != "live" @@ -1423,6 +1504,7 @@ class FenrisTuiApp(App): width, height = self.size was_constrained = self._is_constrained_mode self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT + self._render_footer() main_grid = self.query_one("#main-grid") if was_constrained != self._is_constrained_mode: first, second = ("activity-panel", "endurance-panel") if self._is_constrained_mode else ( diff --git a/tests/test_live_graph.py b/tests/test_live_graph.py index 83b055e..f043562 100644 --- a/tests/test_live_graph.py +++ b/tests/test_live_graph.py @@ -281,7 +281,7 @@ class TestLiveActivityGraph: await pilot.pause() header = str(graph.query_one("#live-header").render()) assert "1 intervals" in header - assert "w W/R" in str(app.query_one("#activity-tools").render()) + assert "w Toggle read/write" in str(app.query_one("#activity-tools").render()) # --------------------------------------------------------------------------- diff --git a/tests/test_tui.py b/tests/test_tui.py index d70606e..c23ddb3 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1357,15 +1357,9 @@ class TestConstrainedLayout: headline = str(app.query_one("#headline-band").render()) assert "remaining" in headline.lower() or "projection" in headline.lower() - # Service strip should have actions - strip = str(app.query_one("#action-rail").render()) - assert "p Pause" in strip - assert "r Resume" in strip - assert "q Quit TUI" in strip - - # Quit rail should be visible + # Under 80x24 the footer collapses to help and quit (issue #110) rail = str(app.query_one("#action-rail").render()) - assert "Quit" in rail + assert rail == "? help · q quit" @pytest.mark.asyncio async def test_constrained_long_reasons_visible(self, tmp_path): diff --git a/tests/test_tui_navigation.py b/tests/test_tui_navigation.py new file mode 100644 index 0000000..392bad9 --- /dev/null +++ b/tests/test_tui_navigation.py @@ -0,0 +1,456 @@ +"""Unified keyboard and mouse navigation (issue #110). + +Every action has one declaration: a Binding. The footer, the help screen and +the clickable chips are all generated from those bindings, and the keyboard, +mouse and wheel paths reach the same actions. Pilot drives each path. +""" +import re +from datetime import date, timedelta +from unittest.mock import patch + +import pytest +from test_dashboard_design import dashboard # noqa: F401 (pytest fixture) +from textual import events +from textual.geometry import Region + +from fenris.tui import ( + ConfirmPause, + DatePickerScreen, + BaselineScreen, + DisclosuresScreen, + FenrisTuiApp, + HelpScreen, + HistoryGraph, + LiveActivityGraph, +) + +BINDING_SOURCES = (FenrisTuiApp, LiveActivityGraph, HistoryGraph) + + +# -- helpers --------------------------------------------------------------- + +async def _settle(app, pilot): + app.on_refresh_tick() + await pilot.pause() + + +def _chip(app, label): + """Offset of a footer or tool chip label inside its Static.""" + for selector in ("#action-rail", "#activity-tools"): + widget = app.query_one(selector) + region = Region(0, 0, widget.size.width, widget.size.height) + for y, strip in enumerate(widget.render_lines(region)): + x = strip.text.find(label) + if x >= 0: + return selector, (x + 1, y) + raise AssertionError("no chip %r in footer" % label) + + +async def _click_chip(app, pilot, label): + selector, offset = _chip(app, label) + await pilot.click(selector, offset=offset) + await pilot.pause() + + +def _scroll(widget, direction): + event = (events.MouseScrollUp if direction < 0 else events.MouseScrollDown)( + widget, 3, 1, 0, direction, 0, False, False, False, + ) + widget.post_message(event) + + +def _help_rows(app): + text = str(app.screen.query_one("#help-text").render()) + section = text.split("Keyboard controls\n", 1)[1].split("\n\n", 1)[0] + rows = {} + for line in section.splitlines(): + keys, description = re.match(r"^(\S.*?)\s{2,}(\S.*)$", line).groups() + rows[description] = set(keys.split(" / ")) + return rows + + +def _binding_rows(app): + rows = {} + for source in BINDING_SOURCES: + for binding in source.BINDINGS: + rows.setdefault(binding.description, set()).add(app.get_key_display(binding)) + return rows + + +# -- one source of truth --------------------------------------------------- + +def test_graphs_declare_bindings_instead_of_key_handlers(): + for graph in (HistoryGraph, LiveActivityGraph): + assert "on_key" not in vars(graph) + assert graph.BINDINGS + + +@pytest.mark.asyncio +async def test_help_content_equals_binding_set(dashboard): # noqa: F811 + async with dashboard.run_test(size=(100, 36)) as pilot: + await pilot.press("?") + assert isinstance(dashboard.screen, HelpScreen) + assert _help_rows(dashboard) == _binding_rows(dashboard) + assert "The dashboard does not need sudo" in str( + dashboard.screen.query_one("#help-text").render()) + + +@pytest.mark.asyncio +async def test_footer_chips_are_generated_from_visible_bindings(dashboard): # noqa: F811 + async with dashboard.run_test(size=(120, 36)) as pilot: + await pilot.pause() + footer = str(dashboard.query_one("#action-rail").render()) + shown = [b for b in FenrisTuiApp.BINDINGS if b.show] + assert shown + for binding in shown: + assert "%s %s" % (dashboard.get_key_display(binding), binding.description) in footer + for binding in FenrisTuiApp.BINDINGS: + if not binding.show: + label = "%s %s" % (dashboard.get_key_display(binding), binding.description) + assert label not in footer + + +@pytest.mark.asyncio +@pytest.mark.parametrize("size", [(79, 24), (80, 23), (60, 18)]) +async def test_footer_collapses_under_80x24(dashboard, size): # noqa: F811 + async with dashboard.run_test(size=size) as pilot: + await pilot.pause() + assert str(dashboard.query_one("#action-rail").render()) == "? help · q quit" + await _click_chip(dashboard, pilot, "? help") + assert isinstance(dashboard.screen, HelpScreen) + await pilot.press("escape") + await _click_chip(dashboard, pilot, "q quit") + assert not dashboard.is_running + + +@pytest.mark.asyncio +async def test_footer_is_full_at_80x24(dashboard): # noqa: F811 + async with dashboard.run_test(size=(80, 24)) as pilot: + await pilot.pause() + footer = str(dashboard.query_one("#action-rail").render()) + assert "p Pause" in footer and "q Quit TUI" in footer + + +# -- keyboard: live graph -------------------------------------------------- + +@pytest.mark.asyncio +async def test_live_home_end_and_arrows(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + await _settle(app, pilot) + graph = app.query_one("#live-activity") + last = len(graph._data) - 1 + assert last > 3 + await pilot.press("home") + assert graph._selected_index == 0 + await pilot.press("right") + assert graph._selected_index == 1 + await pilot.press("end") + assert graph._selected_index == last + await pilot.press("left") + assert graph._selected_index == last - 1 + + +# -- keyboard: history graph ----------------------------------------------- + +async def _open_history(app, pilot): + await pilot.click("#view-history") + await _settle(app, pilot) + graph = app.query_one("#usage-history") + assert app.focused is graph + return graph + + +@pytest.mark.asyncio +async def test_history_home_end_and_week_jumps(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + graph = await _open_history(app, pilot) + last = len(graph._day_data) - 1 + assert last >= 13 + await pilot.press("home") + assert graph.selected_index == 0 + await pilot.press("end") + assert graph.selected_index == last + assert app._browse_date == graph._day_data[last]["day"] + await pilot.press("pageup") + assert graph.selected_index == last - 7 + assert app._browse_date == graph._day_data[last - 7]["day"] + await pilot.press("pagedown") + assert graph.selected_index == last + await pilot.press("pagedown") + assert graph.selected_index == last + await pilot.press("home", "pageup") + assert graph.selected_index == 0 + + +@pytest.mark.asyncio +async def test_history_range_keys_and_drill_bindings(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + graph = await _open_history(app, pilot) + await pilot.press("1") + assert graph.range_days == 7 + await pilot.press("3") + assert graph.range_days == 30 + await pilot.press("left", "enter") + assert app._activity_view == "day" and graph.view_mode == "hourly" + await pilot.press("1") + assert graph.range_days == 30 # range keys only apply to the day graph + await pilot.press("escape") + assert graph.view_mode == "daily" and app._activity_view == "history" + await pilot.press("enter") + await pilot.press("backspace") + assert graph.view_mode == "daily" + + +@pytest.mark.asyncio +async def test_hour_graph_home_end_arrows_and_week_jump(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + graph = await _open_history(app, pilot) + await pilot.press("enter") + day = app._browse_date + await pilot.press("home") + assert graph._hourly_selected == 0 + await pilot.press("right") + assert graph._hourly_selected == 1 + await pilot.press("end") + assert graph._hourly_selected == len(graph._hour_data) - 1 + await pilot.press("left") + assert graph._hourly_selected == len(graph._hour_data) - 2 + await pilot.press("pageup") + assert app._activity_view == "day" + assert app._browse_date == (date.fromisoformat(day) - timedelta(days=7)).isoformat() + + +@pytest.mark.asyncio +async def test_escape_in_zoomed_hour_view_restores_zoom_first(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + graph = await _open_history(app, pilot) + await pilot.press("enter", "z") + assert app._zoomed_panel == "activity-panel" + await pilot.press("escape") + assert app._zoomed_panel is None and graph.view_mode == "hourly" + await pilot.press("escape") + assert graph.view_mode == "daily" + + +# -- keyboard: focus ring -------------------------------------------------- + +@pytest.mark.asyncio +async def test_tab_cycles_panels_with_visible_focus_ring(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + order = [app.focused.id] + for _ in range(3): + await pilot.press("tab") + order.append(app.focused.id) + assert order == ["live-activity", "drive-panel", "endurance-panel", "live-activity"] + await pilot.press("shift+tab") + assert app.focused.id == "endurance-panel" + await pilot.press("shift+tab") + assert app.focused.id == "drive-panel" + + def ring(pane_id): + return app.query_one("#" + pane_id).styles.border_top[1] + + assert ring("drive-panel") != ring("endurance-panel") + await pilot.press("tab") + assert app.focused.id == "endurance-panel" + assert ring("endurance-panel") != ring("drive-panel") + await pilot.press("tab") + assert ring("activity-panel") != ring("drive-panel") + + +# -- mouse ----------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_click_focuses_each_panel(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.click("#drive-health") + assert app.focused.id == "drive-panel" + await pilot.click("#headline-band") + assert app.focused.id == "endurance-panel" + await pilot.click("#local-day") + assert app.focused.id == "live-activity" + await pilot.click("#view-history") + await pilot.click("#drive-health") + await pilot.click("#local-day") + assert app.focused.id == "usage-history" + + +@pytest.mark.asyncio +async def test_view_tabs_are_clickable(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + for view in ("history", "day", "live"): + await pilot.click("#view-" + view) + assert app._activity_view == view + assert app.query_one("#activity-tabs").active == "view-" + view + + +@pytest.mark.asyncio +async def test_action_chips_fire_their_bindings(dashboard): # noqa: F811 + app = dashboard + calls = [] + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + with patch.object(app, "_run_helper", side_effect=lambda *a: calls.append(a) or True): + await _click_chip(app, pilot, "p Pause") + assert isinstance(app.screen, ConfirmPause) + await pilot.press("n") + await _click_chip(app, pilot, "r Resume") + assert calls == [("enable", ["--now"])] + await _click_chip(app, pilot, "c Collect") + assert calls[-1] == ("collect",) + await _click_chip(app, pilot, "b Rated TBW") + assert isinstance(app.screen, BaselineScreen) + await pilot.press("escape") + await _click_chip(app, pilot, "s Theme") + assert app.theme == "fenris-amber" + await _click_chip(app, pilot, "d Disclosures") + assert isinstance(app.screen, DisclosuresScreen) + await pilot.press("escape") + await _click_chip(app, pilot, "z Zoom") + assert app._zoomed_panel is not None + await _click_chip(app, pilot, "z Zoom") + assert app._zoomed_panel is None + await _click_chip(app, pilot, "v View") + assert app._activity_view == "day" + await _click_chip(app, pilot, "m Motion") + assert app._reduced_motion is True + + +@pytest.mark.asyncio +async def test_activity_tool_chips_are_generated_from_bindings(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + tools = str(app.query_one("#activity-tools").render()) + for action in ("prev_day", "next_day", "go_to_date", "today", "toggle_measure"): + binding = next(b for b in FenrisTuiApp.BINDINGS if b.action == action) + assert "%s %s" % (app.get_key_display(binding), binding.description) in tools + await _click_chip(app, pilot, "w Toggle read/write") + assert app.query_one("#live-activity").measure == "read" + await _click_chip(app, pilot, "g Go to date") + assert isinstance(app.screen, DatePickerScreen) + await pilot.press("escape") + await _click_chip(app, pilot, "Prev day") + assert app._activity_view == "day" + await _click_chip(app, pilot, "t Today") + assert app._activity_view == "live" + + +@pytest.mark.asyncio +async def test_wheel_moves_live_selection(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + await _settle(app, pilot) + graph = app.query_one("#live-activity") + last = len(graph._data) - 1 + _scroll(graph, -1) + await pilot.pause() + assert graph._selected_index == last - 1 + _scroll(graph, -1) + await pilot.pause() + assert graph._selected_index == last - 2 + _scroll(graph, 1) + await pilot.pause() + assert graph._selected_index == last - 1 + + +@pytest.mark.asyncio +async def test_wheel_moves_history_day_and_hour_selection(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + graph = await _open_history(app, pilot) + last = len(graph._day_data) - 1 + _scroll(graph, -1) + await pilot.pause() + assert graph.selected_index == last - 1 + _scroll(graph, 1) + await pilot.pause() + assert graph.selected_index == last + await pilot.press("enter", "end") + end = graph._hourly_selected + _scroll(graph, -1) + await pilot.pause() + assert graph._hourly_selected == end - 1 + + +@pytest.mark.asyncio +async def test_click_to_inspect_stays(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + await pilot.pause() + graph = app.query_one("#live-activity") + await pilot.click("#live-render", offset=(graph._point_columns[0], 1)) + assert graph._selected_index == 0 + + +# -- modal keys ------------------------------------------------------------ + +@pytest.mark.asyncio +async def test_confirm_pause_y_n_and_escape(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + with patch.object(app, "_run_helper") as helper: + await pilot.press("p", "n") + assert not isinstance(app.screen, ConfirmPause) + await pilot.press("p", "escape") + assert not isinstance(app.screen, ConfirmPause) + helper.assert_not_called() + await pilot.press("p", "y") + assert not isinstance(app.screen, ConfirmPause) + helper.assert_called_once_with("disable", ["--now"]) + + +@pytest.mark.asyncio +async def test_date_picker_keys(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + await _settle(app, pilot) + await pilot.press("g", "escape") + assert not isinstance(app.screen, DatePickerScreen) + await pilot.press("g", "enter") + assert isinstance(app.screen, DatePickerScreen) + assert "Please enter a date" in str(app.screen.query_one("#date-error").render()) + await pilot.press(*"2026-13-45", "enter") + assert "Invalid date format" in str(app.screen.query_one("#date-error").render()) + app.screen.query_one("#date-input").value = "" + await pilot.press(*"2999-01-01", "enter") + assert "future" in str(app.screen.query_one("#date-error").render()) + app.screen.query_one("#date-input").value = "" + await pilot.press(*"2026-09-17", "enter") + assert not isinstance(app.screen, DatePickerScreen) + assert app._browse_date == "2026-09-17" + assert app._activity_view == "day" + + +@pytest.mark.asyncio +async def test_disclosures_and_help_toggle_keys(dashboard): # noqa: F811 + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("d") + assert isinstance(app.screen, DisclosuresScreen) + await pilot.press("d") + assert not isinstance(app.screen, DisclosuresScreen) + await pilot.press("d", "escape") + assert not isinstance(app.screen, DisclosuresScreen) + await pilot.press("?") + assert isinstance(app.screen, HelpScreen) + await pilot.press("?") + assert not isinstance(app.screen, HelpScreen) + await pilot.press("?", "escape") + assert not isinstance(app.screen, HelpScreen) + + +@pytest.mark.asyncio +async def test_single_dashboard_has_no_page_navigation_bindings(dashboard): # noqa: F811 + keys = {b.key for b in FenrisTuiApp.BINDINGS} + assert not keys & {"left", "right", "1", "2", "3", "4", "home", "end", "pageup", "pagedown"}