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:
xavierk
2026-09-14 17:05:44 +05:30
parent 79478653fa
commit 197e8ed02d
2 changed files with 393 additions and 3 deletions
+112 -3
View File
@@ -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