Make the combined dashboard usable at constrained sizes (issue #81)
- Add terminal size detection with _MIN_WIDTH (80) and _MIN_HEIGHT (24) thresholds - Add constrained CSS layout: single-column grid, hide graph, show text summary - Add #constrained-summary widget with textual history summary - Add on_resize handler and _refresh()-based constrained state detection - Preserve selected-day context across resize transitions - Ensure all regions (headline, health, service, quit) remain usable when constrained - Update DailyBarGraph._is_constrained to accept terminal_width parameter - Add comprehensive tests for constrained layout behavior Covers TPH-9 and cross-cutting TPH regression proof. Closes #81
This commit is contained in:
+112
-3
@@ -158,6 +158,10 @@ _BAR_HEIGHT = 6
|
||||
_BAR_WIDTH = 2
|
||||
_BAR_SPACING = 1
|
||||
|
||||
# Terminal size thresholds for constrained layout (issue #81)
|
||||
_MIN_WIDTH = 80
|
||||
_MIN_HEIGHT = 24
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive daily bar graph widget
|
||||
@@ -269,17 +273,20 @@ class DailyBarGraph(Widget):
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
|
||||
def _is_constrained(self) -> bool:
|
||||
"""Return True when the widget is too narrow for the bar graph.
|
||||
def _is_constrained(self, terminal_width: int = 0) -> bool:
|
||||
"""Return True when the terminal or widget is too narrow for the bar graph.
|
||||
|
||||
At 80 columns with a 3fr:2fr grid split the pane content width is ~46
|
||||
(after round border + padding). Below 32 the bars become unreadable.
|
||||
When terminal_width is provided (issue #81), also check against _MIN_WIDTH.
|
||||
"""
|
||||
try:
|
||||
w = self.region.width
|
||||
if terminal_width > 0 and terminal_width < _MIN_WIDTH:
|
||||
return True
|
||||
return 0 < w < 32
|
||||
except Exception:
|
||||
return False
|
||||
return terminal_width > 0 and terminal_width < _MIN_WIDTH
|
||||
|
||||
def _show_constrained_summary(self) -> None:
|
||||
"""Textual fallback for terminals below 80×24."""
|
||||
@@ -930,6 +937,28 @@ class FenrisTuiApp(App):
|
||||
grid-size: 2 5;
|
||||
grid-rows: 8 5 10 7 3;
|
||||
}
|
||||
#main-grid.constrained {
|
||||
grid-size: 1 4;
|
||||
grid-columns: 1fr;
|
||||
grid-rows: auto auto auto auto;
|
||||
}
|
||||
#main-grid.constrained.paused {
|
||||
grid-size: 1 5;
|
||||
grid-rows: auto auto auto auto auto;
|
||||
}
|
||||
#main-grid.constrained #usage-history {
|
||||
display: none;
|
||||
}
|
||||
#main-grid.constrained #constrained-summary {
|
||||
display: block;
|
||||
}
|
||||
#constrained-summary {
|
||||
display: none;
|
||||
border: round #555555;
|
||||
padding: 0 1;
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
}
|
||||
#dashboard-scroll { height: 1fr; }
|
||||
#headline-band { column-span: 2; }
|
||||
#paused-banner {
|
||||
@@ -987,12 +1016,16 @@ class FenrisTuiApp(App):
|
||||
for theme in THEMES.values():
|
||||
self.register_theme(theme)
|
||||
|
||||
# Track constrained state for resize handling (issue #81)
|
||||
self._is_constrained_mode = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll(id="dashboard-scroll"):
|
||||
with Container(id="main-grid"):
|
||||
yield Static("", id="headline-band", classes="pane")
|
||||
yield Static("", id="paused-banner")
|
||||
yield DailyBarGraph(id="usage-history", classes="pane")
|
||||
yield Static("", id="constrained-summary", classes="pane")
|
||||
yield Static("", id="drive-health", classes="pane")
|
||||
yield Static("", id="service-strip", classes="pane")
|
||||
yield Static("q QUIT TUI", id="quit-rail")
|
||||
@@ -1005,6 +1038,7 @@ class FenrisTuiApp(App):
|
||||
|
||||
self.query_one("#headline-band").border_title = "headline"
|
||||
self.query_one("#usage-history").border_title = "usage history"
|
||||
self.query_one("#constrained-summary").border_title = "usage history"
|
||||
self.query_one("#drive-health").border_title = "drive"
|
||||
self.query_one("#service-strip").border_title = "service + actions"
|
||||
self._refresh_timer = self.set_interval(
|
||||
@@ -1012,6 +1046,55 @@ class FenrisTuiApp(App):
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def on_resize(self) -> None:
|
||||
"""Handle terminal resize to switch between constrained and normal layout (issue #81)."""
|
||||
width, height = self.size
|
||||
was_constrained = self._is_constrained_mode
|
||||
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||
|
||||
main_grid = self.query_one("#main-grid")
|
||||
if self._is_constrained_mode:
|
||||
main_grid.add_class("constrained")
|
||||
else:
|
||||
main_grid.remove_class("constrained")
|
||||
|
||||
# Re-render constrained summary when entering constrained mode
|
||||
if self._is_constrained_mode and not was_constrained:
|
||||
self._update_constrained_summary()
|
||||
# Re-render full layout when leaving constrained mode
|
||||
elif not self._is_constrained_mode and was_constrained:
|
||||
self._refresh()
|
||||
|
||||
def _update_constrained_summary(self) -> None:
|
||||
"""Update the textual summary shown when terminal is too small for graph."""
|
||||
graph = self.query_one("#usage-history")
|
||||
summary = self.query_one("#constrained-summary")
|
||||
|
||||
if not graph._day_data:
|
||||
summary.update("[dim]graph needs ≥80×24[/dim]\nawaiting first sample")
|
||||
return
|
||||
|
||||
# Summarise visible days as text (issue #81 AC2)
|
||||
total_bytes = sum(d.get("total_bytes", 0) for d in graph._day_data)
|
||||
days_with_data = sum(1 for d in graph._day_data if d.get("total_bytes", 0) > 0)
|
||||
n = len(graph._day_data)
|
||||
first = graph._day_data[0].get("local_label", "")
|
||||
last = graph._day_data[-1].get("local_label", "")
|
||||
text = (
|
||||
"[dim]graph needs ≥80×24[/dim]\n"
|
||||
" %d days · %d with writes · %.3f GB total\n"
|
||||
" %s → %s" % (n, days_with_data, total_bytes / 1e9, first, last)
|
||||
)
|
||||
|
||||
# Preserve selected-day context (issue #81 AC2)
|
||||
if 0 <= graph.selected_index < len(graph._day_data):
|
||||
day = graph._day_data[graph.selected_index]
|
||||
text += "\n [bold]%s[/bold] · %.3f GB" % (
|
||||
day.get("local_label", ""), day.get("total_bytes", 0) / 1e9
|
||||
)
|
||||
|
||||
summary.update(text)
|
||||
|
||||
def on_refresh_tick(self) -> None:
|
||||
"""Refresh dashboard and dismiss launch-only authentication guidance."""
|
||||
self._show_auth_notice = False
|
||||
@@ -1047,6 +1130,20 @@ class FenrisTuiApp(App):
|
||||
def _refresh(self) -> None:
|
||||
"""Refresh all four regions from store data."""
|
||||
self._clock_now = datetime.now(timezone.utc)
|
||||
# Also update constrained state on each refresh (issue #81)
|
||||
try:
|
||||
width, height = self.size
|
||||
was_constrained = self._is_constrained_mode
|
||||
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||
main_grid = self.query_one("#main-grid")
|
||||
if self._is_constrained_mode:
|
||||
main_grid.add_class("constrained")
|
||||
else:
|
||||
main_grid.remove_class("constrained")
|
||||
if self._is_constrained_mode and not was_constrained:
|
||||
self._update_constrained_summary()
|
||||
except Exception:
|
||||
pass
|
||||
conn = self._open_store()
|
||||
|
||||
if conn is None:
|
||||
@@ -1070,6 +1167,10 @@ class FenrisTuiApp(App):
|
||||
"Enable monitoring: fenris monitor resume"
|
||||
)
|
||||
self.query_one("#usage-history").set_data([])
|
||||
if self._is_constrained_mode:
|
||||
self.query_one("#constrained-summary").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\nawaiting first sample"
|
||||
)
|
||||
self.query_one("#drive-health").update("")
|
||||
self.query_one("#service-strip").update(
|
||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
||||
@@ -1084,6 +1185,10 @@ class FenrisTuiApp(App):
|
||||
"Check journalctl -u fenris-collect.service"
|
||||
)
|
||||
self.query_one("#usage-history").set_data([])
|
||||
if self._is_constrained_mode:
|
||||
self.query_one("#constrained-summary").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\nobservation store unreadable"
|
||||
)
|
||||
self.query_one("#drive-health").update("")
|
||||
self.query_one("#service-strip").update(
|
||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||
@@ -1133,6 +1238,10 @@ class FenrisTuiApp(App):
|
||||
else:
|
||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
||||
|
||||
# Update constrained summary if in constrained mode (issue #81)
|
||||
if self._is_constrained_mode:
|
||||
self._update_constrained_summary()
|
||||
|
||||
# --- Drive-health pane (§7.2 right, issue #79) ---
|
||||
health = _query_drive_health(conn)
|
||||
# Vendor wear grouped under Drive health with context
|
||||
|
||||
Reference in New Issue
Block a user