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
+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 (