feat(tui): unify keyboard and mouse navigation

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.
This commit is contained in:
xavierk
2026-10-05 20:42:04 +05:30
parent 79ebb10dea
commit 6c517e4ecb
6 changed files with 669 additions and 129 deletions
+4
View File
@@ -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.
+8 -4
View File
@@ -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;
+198 -116
View File
@@ -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 (
+1 -1
View File
@@ -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())
# ---------------------------------------------------------------------------
+2 -8
View File
@@ -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):
+456
View File
@@ -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"}