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:
+198
-116
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user