From 197e8ed02dafc7a29634735bc212467f1701ed72 Mon Sep 17 00:00:00 2001 From: xavierk Date: Mon, 14 Sep 2026 17:05:44 +0530 Subject: [PATCH] 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 --- src/fenris/tui.py | 115 ++++++++++++++++++- tests/test_tui.py | 281 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+), 3 deletions(-) diff --git a/src/fenris/tui.py b/src/fenris/tui.py index ab8ca25..6ad5800 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -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 diff --git a/tests/test_tui.py b/tests/test_tui.py index 685ef09..72e81e6 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -57,6 +57,8 @@ from fenris.tui import ( _GLYPH_GAP, _GLYPH_ZERO, _GLYPH_PARTIAL, + _MIN_WIDTH, + _MIN_HEIGHT, ) @@ -1095,3 +1097,282 @@ class TestBarGraphTUI: assert _GLYPH_GAP in legend assert _GLYPH_ZERO in legend assert _GLYPH_PARTIAL in legend + + +# --------------------------------------------------------------------------- +# Issue #81: Constrained size layout +# --------------------------------------------------------------------------- + +class TestConstrainedLayout: + """Tests for constrained terminal size behavior (issue #81).""" + + @pytest.mark.asyncio + async def test_constrained_at_80x24(self, tmp_path): + """At 80×24 the dashboard renders normally with all regions visible.""" + conn = init_store(tmp_path / "test.db") + _insert_baseline(conn, tbw_tb=1.0, verified=True) + _insert_segment(conn) + _open_period(conn) + for i in range(14): + d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, d, bw=1024*1024*100) + _insert_sample(conn, "2026-09-30T10:00:00+00:00") + conn.close() + + app = FenrisTuiApp(store_path=tmp_path / "test.db") + async with app.run_test(size=(80, 24)) as pilot: + # All four regions should be visible + assert app.query_one("#headline-band") is not None + assert app.query_one("#usage-history") is not None + assert app.query_one("#drive-health") is not None + assert app.query_one("#service-strip") is not None + # Graph should not be in constrained mode + assert not app._is_constrained_mode + # Constrained summary should be hidden + summary = app.query_one("#constrained-summary") + assert str(summary.styles.display) == "none" + + @pytest.mark.asyncio + async def test_constrained_below_80_width(self, tmp_path): + """Below 80 columns the graph is hidden and textual summary shown.""" + conn = init_store(tmp_path / "test.db") + _insert_baseline(conn, tbw_tb=1.0, verified=True) + _insert_segment(conn) + _open_period(conn) + for i in range(14): + d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, d, bw=1024*1024*100) + _insert_sample(conn, "2026-09-30T10:00:00+00:00") + conn.close() + + app = FenrisTuiApp(store_path=tmp_path / "test.db") + async with app.run_test(size=(79, 24)) as pilot: + # Should be in constrained mode + assert app._is_constrained_mode + # Main grid should have constrained class + main_grid = app.query_one("#main-grid") + assert main_grid.has_class("constrained") + # Constrained summary should be visible + summary = app.query_one("#constrained-summary") + summary_text = str(summary.render()) + assert "graph needs ≥80×24" in summary_text + assert "days" in summary_text + assert "GB total" in summary_text + + @pytest.mark.asyncio + async def test_constrained_below_24_height(self, tmp_path): + """Below 24 rows the graph is hidden and textual summary shown.""" + conn = init_store(tmp_path / "test.db") + _insert_baseline(conn, tbw_tb=1.0, verified=True) + _insert_segment(conn) + _open_period(conn) + for i in range(14): + d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, d, bw=1024*1024*100) + _insert_sample(conn, "2026-09-30T10:00:00+00:00") + conn.close() + + app = FenrisTuiApp(store_path=tmp_path / "test.db") + async with app.run_test(size=(80, 23)) as pilot: + # Should be in constrained mode + assert app._is_constrained_mode + # Constrained summary should be visible + summary = app.query_one("#constrained-summary") + summary_text = str(summary.render()) + assert "graph needs ≥80×24" in summary_text + + @pytest.mark.asyncio + async def test_resize_from_constrained_to_normal(self, tmp_path): + """Resizing from constrained to normal restores graph and coherent state.""" + conn = init_store(tmp_path / "test.db") + _insert_baseline(conn, tbw_tb=1.0, verified=True) + _insert_segment(conn) + _open_period(conn) + for i in range(14): + d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, d, bw=1024*1024*100) + _insert_sample(conn, "2026-09-30T10:00:00+00:00") + conn.close() + + app = FenrisTuiApp(store_path=tmp_path / "test.db") + async with app.run_test(size=(79, 24)) as pilot: + # Start in constrained mode + assert app._is_constrained_mode + graph = app.query_one("#usage-history") + # Select a day while constrained + graph.selected_index = 5 + + # Resize to normal — pilot.resize_terminal changes terminal size + await pilot.resize_terminal(80, 24) + await pilot.pause() + # Trigger refresh so constrained state updates (timer would do this) + app._refresh() + await pilot.pause() + + # Should no longer be constrained + assert not app._is_constrained_mode + # Main grid should not have constrained class + main_grid = app.query_one("#main-grid") + assert not main_grid.has_class("constrained") + # Selection should be preserved + assert graph.selected_index == 5 + # Graph should have data + assert len(graph._day_data) > 0 + + @pytest.mark.asyncio + async def test_resize_from_normal_to_constrained(self, tmp_path): + """Resizing from normal to constrained hides graph and shows summary.""" + conn = init_store(tmp_path / "test.db") + _insert_baseline(conn, tbw_tb=1.0, verified=True) + _insert_segment(conn) + _open_period(conn) + for i in range(14): + d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, d, bw=1024*1024*100) + _insert_sample(conn, "2026-09-30T10:00:00+00:00") + conn.close() + + app = FenrisTuiApp(store_path=tmp_path / "test.db") + async with app.run_test(size=(80, 24)) as pilot: + # Start in normal mode + assert not app._is_constrained_mode + graph = app.query_one("#usage-history") + # Focus and select a day + app.set_focus(graph) + await pilot.pause() + await pilot.press("right") + await pilot.pause() + assert graph.selected_index == 0 + + # Resize to constrained — pilot.resize_terminal changes terminal size + await pilot.resize_terminal(79, 24) + await pilot.pause() + # Trigger refresh so constrained state updates (timer would do this) + app._refresh() + await pilot.pause() + + # Should be in constrained mode + assert app._is_constrained_mode + # Constrained summary should show selected day context + summary = app.query_one("#constrained-summary") + summary_text = str(summary.render()) + assert "graph needs ≥80×24" in summary_text + # Selected day context should survive + assert "2026-09" in summary_text + + @pytest.mark.asyncio + async def test_constrained_summary_empty_store(self, tmp_path): + """Constrained summary shows awaiting message for empty store.""" + app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") + async with app.run_test(size=(79, 24)) as pilot: + assert app._is_constrained_mode + summary = app.query_one("#constrained-summary") + summary_text = str(summary.render()) + assert "graph needs ≥80×24" in summary_text + assert "awaiting" in summary_text.lower() + + @pytest.mark.asyncio + async def test_constrained_all_regions_usable(self, tmp_path): + """All regions remain usable in constrained mode.""" + conn = init_store(tmp_path / "test.db") + _insert_baseline(conn, tbw_tb=1.0, verified=True) + _insert_segment(conn) + _open_period(conn) + for i in range(14): + d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, d, bw=1024*1024*100) + _insert_sample(conn, "2026-09-30T10:00:00+00:00") + conn.close() + + app = FenrisTuiApp(store_path=tmp_path / "test.db") + async with app.run_test(size=(79, 24)) as pilot: + # All regions should exist + assert app.query_one("#headline-band") is not None + assert app.query_one("#drive-health") is not None + assert app.query_one("#service-strip") is not None + assert app.query_one("#quit-rail") is not None + + # Headline should contain projection + headline = str(app.query_one("#headline-band").render()) + assert "remaining" in headline.lower() or "projection" in headline.lower() + + # Service strip should have actions + strip = str(app.query_one("#service-strip").render()) + assert "p pause" in strip + assert "r resume" in strip + assert "q quit" not in strip # Quit is in quit-rail + + # Quit rail should be visible + rail = str(app.query_one("#quit-rail").render()) + assert "QUIT" in rail + + @pytest.mark.asyncio + async def test_constrained_long_reasons_visible(self, tmp_path): + """Long status reasons remain visible in constrained mode.""" + conn = init_store(tmp_path / "test.db") + _insert_segment(conn) + _open_period(conn) + # Insert stale data + stale_ts = (_clock() - timedelta(days=10)).isoformat() + _insert_sample(conn, stale_ts, pu=5) + conn.close() + + app = FenrisTuiApp(store_path=tmp_path / "test.db") + async with app.run_test(size=(79, 24)) as pilot: + strip = str(app.query_one("#service-strip").render()) + # Status should be visible even with long reasons + assert "stale" in strip.lower() or "freshness" in strip.lower() + + @pytest.mark.asyncio + async def test_constrained_keyboard_navigation(self, tmp_path): + """Keyboard focus traversal works in constrained mode.""" + conn = init_store(tmp_path / "test.db") + _insert_baseline(conn, tbw_tb=1.0, verified=True) + _insert_segment(conn) + _open_period(conn) + for i in range(14): + d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d") + _insert_day(conn, d, bw=1024*1024*100) + _insert_sample(conn, "2026-09-30T10:00:00+00:00") + conn.close() + + app = FenrisTuiApp(store_path=tmp_path / "test.db") + async with app.run_test(size=(79, 24)) as pilot: + # Quit should still work + await pilot.press("q") + assert not app.is_running + + @pytest.mark.asyncio + async def test_constrained_theme_and_motion_toggle(self, tmp_path): + """Theme and motion toggles work in constrained mode.""" + app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") + async with app.run_test(size=(79, 24)) as pilot: + assert app._is_constrained_mode + # Toggle theme + await pilot.press("t") + await pilot.pause() + # Toggle motion + await pilot.press("m") + await pilot.pause() + # Should still be constrained + assert app._is_constrained_mode + + def test_min_width_constant(self): + """_MIN_WIDTH is 80 columns.""" + assert _MIN_WIDTH == 80 + + def test_min_height_constant(self): + """_MIN_HEIGHT is 24 rows.""" + assert _MIN_HEIGHT == 24 + + def test_is_constrained_with_terminal_width(self, tmp_path): + """DailyBarGraph._is_constrained checks terminal width.""" + graph = DailyBarGraph() + # Widget-level check (no terminal width) + assert graph._is_constrained() is False + # Terminal width below minimum + assert graph._is_constrained(terminal_width=79) is True + # Terminal width at minimum + assert graph._is_constrained(terminal_width=80) is False + # Terminal width above minimum + assert graph._is_constrained(terminal_width=120) is False