Release v0.7.0 #113
@@ -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 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.
|
- 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.
|
- 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
|
### 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`.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -271,16 +271,20 @@ shows incomplete or unavailable dates without treating them as zero. Older
|
|||||||
UTC-only summaries cannot establish exact local-day totals.
|
UTC-only summaries cannot establish exact local-day totals.
|
||||||
|
|
||||||
- `v` cycles Live / Day / History; the tabs are also clickable.
|
- `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.
|
- `[` / `]` 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
|
- `Tab` / `Shift+Tab` move focus between panels (clicking a panel focuses it);
|
||||||
`Esc` restores it. Monitoring status and controls remain visible.
|
`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
|
- `s` cycles Chalktone, Amber, Nord, and High Contrast; saved theme preferences
|
||||||
survive upgrades. `m` toggles reduced motion.
|
survive upgrades. `m` toggles reduced motion.
|
||||||
|
|
||||||
On smaller terminals, textual summaries and scrollable panels keep evidence
|
On smaller terminals, textual summaries and scrollable panels keep evidence
|
||||||
accessible. Pause, resume, collect, disclosures, help, and quit remain available
|
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
|
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;
|
not need `sudo`. Use `sudo` for package installation and system configuration;
|
||||||
|
|||||||
+198
-116
@@ -3,7 +3,8 @@
|
|||||||
Presentation follows docs/spec/glint-dashboard-design.md; shared status,
|
Presentation follows docs/spec/glint-dashboard-design.md; shared status,
|
||||||
projection, evidence, and privileged-action contracts remain unchanged.
|
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
|
Privileged actions route through fenris-monitor as terminal-attached subprocesses
|
||||||
(LC-6, LC-8). The TUI never samples in-process.
|
(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"
|
_RESUME_HINT = "r Resume — enable monitoring and future boots"
|
||||||
_ACTION_LEGEND = (
|
_COLLAPSED_FOOTER = "? help · q quit"
|
||||||
"p Pause · " + _RESUME_HINT + "\n"
|
# Navigation actions shown as chips above the graphs instead of in the footer.
|
||||||
"b Rated TBW · c Collect now · t Today · [ ] Day · g Date · m Motion · d Disclosures · ? Help"
|
_TOOL_ACTIONS = ("prev_day", "next_day", "go_to_date", "today", "toggle_measure")
|
||||||
)
|
|
||||||
|
|
||||||
def _format_remaining(seconds: float) -> str:
|
def _format_remaining(seconds: float) -> str:
|
||||||
"""Format remaining lifespan as human-readable string."""
|
"""Format remaining lifespan as human-readable string."""
|
||||||
@@ -151,6 +151,20 @@ class HistoryGraph(Widget):
|
|||||||
can_focus = True
|
can_focus = True
|
||||||
can_focus_children = False
|
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):
|
class DaySelected(Message):
|
||||||
def __init__(self, day: str) -> None:
|
def __init__(self, day: str) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -545,45 +559,82 @@ class HistoryGraph(Widget):
|
|||||||
|
|
||||||
# -- Event handling --
|
# -- 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":
|
if self.view_mode == "daily":
|
||||||
self._handle_daily_key(event)
|
self.selected_index = index
|
||||||
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._refresh()
|
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()
|
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:
|
def _notify_selection(self) -> None:
|
||||||
if 0 <= self.selected_index < len(self._day_data):
|
if 0 <= self.selected_index < len(self._day_data):
|
||||||
@@ -642,39 +693,6 @@ class HistoryGraph(Widget):
|
|||||||
self._hourly_selected = -1
|
self._hourly_selected = -1
|
||||||
self._refresh()
|
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:
|
def _select_history_hour(self) -> None:
|
||||||
selected_hour = self._selected_history_hour()
|
selected_hour = self._selected_history_hour()
|
||||||
if selected_hour is not None:
|
if selected_hour is not None:
|
||||||
@@ -770,6 +788,13 @@ class LiveActivityGraph(Widget):
|
|||||||
can_focus = True
|
can_focus = True
|
||||||
can_focus_children = False
|
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 = """
|
DEFAULT_CSS = """
|
||||||
LiveActivityGraph {
|
LiveActivityGraph {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -910,14 +935,27 @@ class LiveActivityGraph(Widget):
|
|||||||
return "[bold yellow]Inspected interval expired; following live[/bold yellow]\n"
|
return "[bold yellow]Inspected interval expired; following live[/bold yellow]\n"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def on_key(self, event: Any) -> None:
|
def _move_live(self, offset: int) -> None:
|
||||||
offset = -1 if event.key == "left" else 1 if event.key == "right" else 0
|
intervals = self._interval_ids()
|
||||||
if offset:
|
self.selection.move_live(intervals, offset)
|
||||||
intervals = self._interval_ids()
|
self._selected_index = self.selection.selected_live_index(intervals)
|
||||||
self.selection.move_live(intervals, offset)
|
self._refresh()
|
||||||
self._selected_index = self.selection.selected_live_index(intervals)
|
|
||||||
self._refresh()
|
def action_move(self, offset: int) -> None:
|
||||||
event.stop()
|
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:
|
def on_click(self, event: Any) -> None:
|
||||||
render = self.query_one("#live-render")
|
render = self.query_one("#live-render")
|
||||||
@@ -983,6 +1021,23 @@ class DisclosuresScreen(ModalScreen[None]):
|
|||||||
self.dismiss()
|
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]):
|
class HelpScreen(ModalScreen[None]):
|
||||||
"""Persistent keyboard and privilege guidance."""
|
"""Persistent keyboard and privilege guidance."""
|
||||||
|
|
||||||
@@ -1007,14 +1062,11 @@ class HelpScreen(ModalScreen[None]):
|
|||||||
" sudo fenris sample\n"
|
" sudo fenris sample\n"
|
||||||
"Then reopen the dashboard with fenris.\n\n"
|
"Then reopen the dashboard with fenris.\n\n"
|
||||||
"[bold]Keyboard controls[/bold]\n"
|
"[bold]Keyboard controls[/bold]\n"
|
||||||
+ _ACTION_LEGEND + "\n"
|
+ "\n".join(
|
||||||
"q Quit the dashboard — monitoring continues\n"
|
_literal(keys.ljust(16) + description)
|
||||||
"Tab / Shift+Tab Focus panels · ← → Select · 1-4 History range\n"
|
for description, keys in binding_rows(self.app).items()
|
||||||
"v Live / Day / History · z Zoom panel · Esc Restore\n"
|
)
|
||||||
"[ Previous day · ] Next day · g Go to date\n"
|
+ "\n\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"
|
|
||||||
"Dotted lines join adjacent measured volumes; gaps are never filled.\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"
|
"Values are bytes per interval, hour, or day, not transfer speed.\n\n"
|
||||||
"[dim]↑ ↓ Scroll · Esc Close[/dim]",
|
"[dim]↑ ↓ Scroll · Esc Close[/dim]",
|
||||||
@@ -1220,23 +1272,26 @@ class FenrisTuiApp(App):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("p", "pause", "Pause", show=False),
|
Binding("p", "pause", "Pause"),
|
||||||
Binding("r", "resume", "Resume", show=False),
|
Binding("r", "resume", "Resume"),
|
||||||
Binding("b", "baseline", "Rated TBW", show=False),
|
Binding("c", "collect", "Collect"),
|
||||||
Binding("c", "collect", "Collect now", show=False),
|
Binding("b", "baseline", "Rated TBW"),
|
||||||
Binding("d", "disclose", "Disclosures", show=False),
|
Binding("d", "disclose", "Disclosures"),
|
||||||
Binding("t", "today", "Today", show=False),
|
Binding("z", "toggle_zoom", "Zoom"),
|
||||||
Binding("left_square_bracket", "prev_day", "Previous day", show=False),
|
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("right_square_bracket", "next_day", "Next day", show=False),
|
||||||
Binding("g", "go_to_date", "Go to date", show=False),
|
Binding("g", "go_to_date", "Go to date", show=False),
|
||||||
Binding("m", "toggle_motion", "Motion", show=False),
|
Binding("t", "today", "Today", show=False),
|
||||||
Binding("w", "toggle_measure", "W/R", show=False),
|
Binding("w", "toggle_measure", "Toggle read/write", 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),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -1303,10 +1358,7 @@ class FenrisTuiApp(App):
|
|||||||
yield Tabs(Tab("Live", id="view-live"), Tab("Day", id="view-day"),
|
yield Tabs(Tab("Live", id="view-live"), Tab("Day", id="view-day"),
|
||||||
Tab("History", id="view-history"), id="activity-tabs")
|
Tab("History", id="view-history"), id="activity-tabs")
|
||||||
yield Static("", id="local-day")
|
yield Static("", id="local-day")
|
||||||
yield Static(
|
yield Static("", id="activity-tools")
|
||||||
"[@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 LiveActivityGraph(
|
yield LiveActivityGraph(
|
||||||
id="live-activity", selection=self._activity_selection,
|
id="live-activity", selection=self._activity_selection,
|
||||||
)
|
)
|
||||||
@@ -1317,13 +1369,7 @@ class FenrisTuiApp(App):
|
|||||||
yield Static("", id="drive-health")
|
yield Static("", id="drive-health")
|
||||||
with VerticalScroll(id="status-area"):
|
with VerticalScroll(id="status-area"):
|
||||||
yield Static("", id="service-strip")
|
yield Static("", id="service-strip")
|
||||||
yield Static(
|
yield Static("", id="action-rail")
|
||||||
"[@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")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-")
|
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("#activity-panel").border_title = "Drive activity"
|
||||||
self.query_one("#drive-panel").border_title = "Drive"
|
self.query_one("#drive-panel").border_title = "Drive"
|
||||||
self.query_one("#live-activity").border_title = "Live activity"
|
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_timer = self.set_interval(self.refresh_interval_s, self.on_refresh_tick)
|
||||||
self._refresh()
|
self._refresh()
|
||||||
self._apply_activity_view()
|
self._apply_activity_view()
|
||||||
self.query_one("#live-activity").focus(scroll_visible=not self._is_constrained_mode)
|
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:
|
def _apply_activity_view(self) -> None:
|
||||||
self.query_one("#live-activity").display = self._activity_view == "live"
|
self.query_one("#live-activity").display = self._activity_view == "live"
|
||||||
self.query_one("#usage-history").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
|
width, height = self.size
|
||||||
was_constrained = self._is_constrained_mode
|
was_constrained = self._is_constrained_mode
|
||||||
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||||
|
self._render_footer()
|
||||||
main_grid = self.query_one("#main-grid")
|
main_grid = self.query_one("#main-grid")
|
||||||
if was_constrained != self._is_constrained_mode:
|
if was_constrained != self._is_constrained_mode:
|
||||||
first, second = ("activity-panel", "endurance-panel") if self._is_constrained_mode else (
|
first, second = ("activity-panel", "endurance-panel") if self._is_constrained_mode else (
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ class TestLiveActivityGraph:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
header = str(graph.query_one("#live-header").render())
|
header = str(graph.query_one("#live-header").render())
|
||||||
assert "1 intervals" in header
|
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
@@ -1357,15 +1357,9 @@ class TestConstrainedLayout:
|
|||||||
headline = str(app.query_one("#headline-band").render())
|
headline = str(app.query_one("#headline-band").render())
|
||||||
assert "remaining" in headline.lower() or "projection" in headline.lower()
|
assert "remaining" in headline.lower() or "projection" in headline.lower()
|
||||||
|
|
||||||
# Service strip should have actions
|
# Under 80x24 the footer collapses to help and quit (issue #110)
|
||||||
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
|
|
||||||
rail = str(app.query_one("#action-rail").render())
|
rail = str(app.query_one("#action-rail").render())
|
||||||
assert "Quit" in rail
|
assert rail == "? help · q quit"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_constrained_long_reasons_visible(self, tmp_path):
|
async def test_constrained_long_reasons_visible(self, tmp_path):
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user