diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d1cfd..cb22cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ backfill releases from before this changelog. ## [Unreleased] +## [0.5.0] - 2026-09-19 + +### Changed + +- Make drive activity the focus of a Chalktone dashboard with Live, Day, and History tabs, panel zoom, and fixed monitoring controls. +- Replace block bars with labelled dotted volume plots that fit the terminal and preserve gaps, partial evidence, and point inspection. + +### Fixed + +- Preserve historical selections and store-fault messages through graph refresh and resize. +- Show hourly drill-down results without overwriting them with a loading placeholder. +- Keep known unallocated daily volume visible across coverage gaps, and distinguish missing hourly evidence from zero on small terminals. + ## [0.4.0] - 2026-09-18 ### Added diff --git a/README.md b/README.md index c83465b..af6019a 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,29 @@ is retried at the next interval; it never fabricates missing observations. ## Reading the dashboard +![Chalktone dashboard with a dotted activity plot](assets/dashboard-chalktone.png) + +Preview uses synthetic observations, not measurements from a real drive. + +The Chalktone dashboard opens with a large **Live** activity plot. **Day** shows +hourly evidence for a selected date, labelled UTC, and **History** shows daily +evidence. Local-day totals keep their recorded timezone. Dotted traces show +measured read/write volumes, not transfer speed; missing evidence breaks the +trace. `?` marks a gap, `~` a partial total, and `u` unallocated daily volume. +Use the selected-point readout for exact values and evidence state. + +- `v` cycles Live / Day / History; the tabs are also clickable. +- `←` / `→` inspect points; `w` switches read/write volume in every view. +- `[` / `]` 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 + `Esc` restores it. Monitoring status and controls remain visible. +- `s` cycles Chalktone, Amber, Nord, and High Contrast; saved theme preferences + survive upgrades. `m` toggles reduced motion. + +On smaller terminals, textual summaries and scrollable panels keep evidence +accessible. Pause, resume, collect, disclosures, help, and quit remain available +in the fixed control row. + 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; pause, resume, and collect-now actions normally authenticate through polkit. diff --git a/assets/dashboard-chalktone.png b/assets/dashboard-chalktone.png new file mode 100644 index 0000000..1f2f58c Binary files /dev/null and b/assets/dashboard-chalktone.png differ diff --git a/docs/research/glint-dashboard-adoption.md b/docs/research/glint-dashboard-adoption.md new file mode 100644 index 0000000..c846aa3 --- /dev/null +++ b/docs/research/glint-dashboard-adoption.md @@ -0,0 +1,67 @@ +# Glint dashboard design adoption + +Research date: 2026-09-19. Status: feasibility findings with interview decisions in progress. This note does not authorize implementation or change Fenris's accepted behavior. + +## Finding + +Fenris can adopt a Glint-inspired terminal dashboard through an independently authored redesign of its existing Textual presentation layer. Glint is a Rust terminal application, not an HTML/CSS dashboard or a drop-in Textual component library. Its most relevant ideas are a compact pane grid, consistent title rows and metadata, obvious focus, restrained colors, and enlarging a focused pane. A wholesale Glint integration would introduce a different UI runtime, unrelated application infrastructure, and a licensing decision without being necessary to achieve this visual direction. + +The user has selected **Chalktone / screenshot 3**, **a large activity chart as the primary panel**, and **panel styling plus keyboard focus and zoom**. The activity panel has **Live / Day / History tabs**; enlarging a panel retains a **fixed monitoring status, freshness, and control strip**. A general dashboard builder is outside the selected scope. The developing product specification is [Glint dashboard design](../spec/glint-dashboard-design.md). + +## Primary-source snapshot + +Inspected Glint's default branch at commit [`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`](https://github.com/ntrospect0/glint/tree/c1d73d3e8ead2f4069630b2a237306af8f6e69c8), committed 2026-07-19. A shallow reference checkout was created outside Fenris at `/tmp/fenris-glint-reference`. + +- [README](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) contains three dashboard screenshots, a setup screenshot, and a live-capture link. The first two dashboard screenshots use `tokyonight`; the third uses `chalktone`. They show example compositions, not one mandatory layout. +- [Screenshot 1](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo1.png), [screenshot 2](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo2.png), [screenshot 3](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png), and [setup screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-setup.png) are versioned in the repository. This source investigation does not substitute for visual inspection of those images. +- [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) declares Rust edition 2021, package version 0.5.0, Ratatui 0.28, Crossterm 0.28, and a `glint` binary. It also brings Tokio, HTTP clients, configuration/watch infrastructure, and optional widget dependencies. +- [GitHub repository API](https://api.github.com/repos/ntrospect0/glint), checked on the research date, reports an unarchived repository created 2026-05-27. [GitHub releases API](https://api.github.com/repos/ntrospect0/glint/releases) returned no releases. The [changelog](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/CHANGELOG.md) labels 0.5.0 and 0.4.0 unreleased, and README installation is from source. This is evidence of a young project and its distribution state, not proof that it is abandoned. +- [CI configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/.github/workflows/ci.yml) runs Cargo tests and Clippy on Ubuntu; Clippy warnings do not fail CI. Source review alone does not establish that current CI passes. No Glint build or test run was performed for this assessment. + +## What can transfer + +| Glint pattern and source | Fit for Fenris | Scope implication | +| --- | --- | --- | +| Pane grid with row/column spans: [layout model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/layout.rs), [default configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/config.toml). | Fenris already uses a Textual grid and rounded bordered panes. A considered re-layout can use that existing ownership. | Decide which Fenris facts deserve persistent space. A fixed layout is much smaller than Glint's user-composable dashboard system. | +| Title integrated into the border, right-aligned metadata, focus treatment, shortcut indication: [UI title helpers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/mod.rs). | Useful for clearly named activity, history, drive, and monitoring panes. Date/range/freshness can become concise pane metadata where legible. | Independently implement the visible behavior in Fenris; do not translate or copy these GPL helpers. Ensure essential evidence is not merely truncated away. | +| Semantic theme roles separating border, title, metadata, and text: [theme model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/theme/mod.rs), [bundled schemes](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/colorschemes.toml). | Fenris already owns themes in `src/fenris/themes.py`. Extend that role system for a chosen visual direction while keeping semantic status colors and text. | Select the preferred screenshot/palette before making the default. High Contrast and reduced-motion preferences already exist and must remain usable. | +| Tab/click focus, keyboard shortcuts, and focused-pane enlargement: [README controls](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md), [app focus/zoom ownership](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/app.rs). | Particularly useful for graphs when a small terminal limits detail. | Zoom is new interaction behavior, not just styling. Selection/date/focus must survive entry, exit, resize, and refresh; essential fault/pause state needs a visibility decision. | +| Multiple widgets stacked in one cell, cycled with `.`/`,`: [stack implementation](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/stack.rs). | A possible way to expose secondary details without permanent screen cost. | Hiding monitoring state, confidence, or evidence behind inactive tabs would weaken Fenris's current contract. Choose deliberately; not assumed needed. | +| Responsive views exposing more detail at larger sizes: [view tiers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/view_tier.rs), [widget author guide](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md). | Useful principle, but Glint's breakpoints describe its content and are not Fenris requirements. | Preserve Fenris's constrained-terminal facts and controls, then design larger views. Do not simply reuse Glint's threshold values. | +| Compact bottom status bar: [status bar](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/status_bar.rs). | Supports a quieter action/focus rail. | Fenris has real monitoring continuity, freshness, collection outcome, authentication, and quit semantics. Its footer cannot be reduced to Glint's version/clock/theme bar. | + +Glint's calendar, stocks, news, email, weather, notes, galleries, external credentials, and profile/setup machinery are outside the stated Fenris redesign. Its widget SDK adds widgets inside the Rust application via a trait and registry; it is not an existing Python integration seam ([widget SDK](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md), [widget interface](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/mod.rs)). + +## Fenris ownership and constraints + +The present application is Python with Textual, pinned to Textual 8.2.8 in [`requirements.txt`](../../requirements.txt). [`FenrisTuiApp`](../../src/fenris/tui.py) already owns the grid, header, bordered panes, compact layout, help, action bindings, and theme registration; `DailyBarGraph` and `LiveActivityGraph` already own graph selection/rendering. [`themes.py`](../../src/fenris/themes.py) owns Amber, Nord, High Contrast, and status color precedence. These are the natural extension points; a new backend or service is unnecessary for a visual redesign. + +The design must preserve these existing responsibilities and evidence semantics: + +- The collector owns privileged drive acquisition and observation-store writes; the TUI is an unprivileged reader and dispatches administrative actions through the sanctioned helper. A visual redesign does not imply a second sampler or store writer. Sources: [`CONTEXT.md`](../../CONTEXT.md), [redesign spec](../spec/fenris-redesign.md), [`tui.py`](../../src/fenris/tui.py). +- Usage-adjusted theoretical lifespan remains a write-endurance projection, accompanied by projection confidence and supporting facts. It must not become a physical failure countdown. Unknown, unavailable, warming, stale, and store-fault states require meaningful presentation. Sources: [`CONTEXT.md`](../../CONTEXT.md), [projection ADR](../adr/0002-projection-model-sustained-regime.md), [live activity specification](../spec/live-drive-activity.md). +- Gaps must not become zeros, and unavailable measurements must not become decorative smooth curves. Local-day labels, read/write units, incomplete evidence, selected dates, and timezone context matter. Sources: [local-day history ADR](../adr/0010-local-day-activity-history.md), [live activity specification](../spec/live-drive-activity.md). +- Pause is a deliberate disable; quitting only leaves the TUI. Monitoring continuity, last collection outcome, and freshness must remain understandable. Sources: [service lifecycle ADR](../adr/0003-service-lifecycle-and-sanctioned-toggle.md), [dashboard clarity specification](../spec/dashboard-clarity.md), [`status_composition.py`](../../src/fenris/status_composition.py). +- The current source switches to constrained presentation below 80 columns or 24 rows; the accepted live-activity specification requires daily totals, dates, evidence labels, confidence, and controls to remain accessible on narrow terminals. A new design must be reviewed against real target dimensions. Sources: [`tui.py`](../../src/fenris/tui.py), [live activity specification](../spec/live-drive-activity.md). + +The fixed Panes information architecture is normative in the older [redesign specification, section 7](../spec/fenris-redesign.md), and [dashboard clarity](../spec/dashboard-clarity.md) says it preserves that architecture and prescribes a separate quit rail. A significantly different pane hierarchy or footer should explicitly amend those presentation decisions when the user selects the new design, rather than silently treating old contracts as irrelevant. The underlying evidence and action semantics can be retained. + +## License boundary + +Glint declares **GPL-3.0-or-later** in [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) and source SPDX headers. Its [README license section](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) says distributed modified versions must remain GPL-licensed with copyright notices, and its [LICENSE](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/LICENSE) specifies the distribution obligations. Fenris deliberately uses MIT across source and packages under [ADR 0009](../adr/0009-mit-license.md). + +Copying or translating Glint implementation into an integrated distributed Fenris would therefore raise GPL compliance and licensing choices that conflict with keeping the combined implementation solely MIT. It is not an adoption route to take implicitly. The recommended route is independently authored Fenris UI code using common layout/interaction ideas observed in Glint, without copying Glint code or assets. If direct implementation reuse becomes a requirement, resolve licensing or separate permission first; this research does not establish that permission. + +## Interview state + +The selected direction is an independently implemented Chalktone-inspired dashboard with a fixed Fenris layout, an activity-first hierarchy, keyboard focus and enlargement, one activity panel with Live / Day / History tabs, and a persistent monitoring status/freshness/control strip during enlargement. Existing 80×24 support and constrained reflow are retained rather than reopened as a new minimum-size decision. The [developing design specification](../spec/glint-dashboard-design.md) owns the precise requirements and any remaining decisions; this research note is supporting evidence. + +No product code, glossary, ADR, or existing acceptance criteria were changed by this investigation. Keyboard focus/zoom feasibility does not establish that exact interaction behavior has been implemented or runtime-validated in Fenris. + +## Validation scope + +Evidence comes from the pinned Glint source, its checked-in documentation/screenshots, GitHub's first-party API, and Fenris's current source and accepted documents. The main design investigation visually inspected screenshots 1 and 3 and separately verified Textual grid documentation. + +The main investigation also reviewed synthetic renders of current Fenris at 140×44 and 80×24, after refresh cleared the launch authentication banner, using installed Textual 8.2.7 (the repository lock is 8.2.8). The captures are `/tmp/fenris-current-capture-3xdv46jm/fenris-current-140x44.png` and `/tmp/fenris-current-capture-3xdv46jm/fenris-current-80x24.png`. They showed a large blank vertical area; at 80×24 the drive, monitoring status, and actions fell below the initial viewport. This supports rearranging the panels and keeping the status/control strip visible, but is not a diagnosis of the deployed drive or proof across every state. + +No Glint runtime was executed, and no Glint asset was copied into the product. No accessibility audit was conducted. This research establishes architectural feasibility and records decisions; it does not claim pixel fidelity, a working integration, an upstream test result, or a measured performance result. diff --git a/docs/spec/dashboard-clarity.md b/docs/spec/dashboard-clarity.md index 8d617fd..ef55c94 100644 --- a/docs/spec/dashboard-clarity.md +++ b/docs/spec/dashboard-clarity.md @@ -1,5 +1,11 @@ # Fenris dashboard clarity specification +**Presentation amendment (2026-09-19):** The accepted +[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes the +preserved Panes arrangement and standalone heavy quit rail. Identity, continuity, +paused-state explanations, and the distinction between quitting and pausing +remain required; quit now has an explicit entry in the fixed controls. + **Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map. **Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*. @@ -119,4 +125,4 @@ This resolves the map's README-wording fog: the wording is decided here; the act ## 9. Out of scope -Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff. \ No newline at end of file +Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff. diff --git a/docs/spec/fenris-redesign.md b/docs/spec/fenris-redesign.md index a3beb4f..c5e6763 100644 --- a/docs/spec/fenris-redesign.md +++ b/docs/spec/fenris-redesign.md @@ -362,6 +362,12 @@ Never "82 % confidence" or "95 % accurate". ## 7. Panes TUI +**Presentation amendment (2026-09-19):** The accepted +[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes this +section's panel arrangement and graph appearance with an activity-first layout, +dotted volume plots, Live / Day / History tabs, and focused-panel zoom. Shared +evidence, projection, authentication, and monitoring-action contracts still apply. + **Decisions:** [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3) (Variant A adopted), [Evaluate Python TUI frameworks](https://git.bongbetic.com/xavierk/Fenris/issues/6) (Textual). **ADRs:** [0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §§8, 10; [0004](../adr/0004-install-upgrade-removal-lifecycle.md) §10. **Criteria:** TUI-1–TUI-4, CI-1, CI-2, CI-4. The [prototype](https://git.bongbetic.com/xavierk/Fenris/src/branch/prototype/tui-information-architecture/prototype/tui-ia) is visual reference only; this section is normative. ### 7.1 Framework and floor diff --git a/docs/spec/glint-dashboard-design.md b/docs/spec/glint-dashboard-design.md new file mode 100644 index 0000000..672d9ef --- /dev/null +++ b/docs/spec/glint-dashboard-design.md @@ -0,0 +1,148 @@ +# Glint-inspired Fenris dashboard + +Status: accepted on 2026-09-19. The user confirmed the complete design and +additionally requested Glint-style plotted graphs in place of block bars. + +## Purpose and reference + +Adopt the visual approach of [Glint](https://github.com/ntrospect0/glint) for +Fenris's terminal dashboard. The selected visual reference is the third README +screenshot, using Chalktone, at upstream commit +`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`: +[reference screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png). + +The source and architectural assessment is recorded in +[the research note](../research/glint-dashboard-adoption.md). + +## Confirmed user choices + +- Adopt panel styling, keyboard focus, and panel zoom. A freely configurable + dashboard builder is outside the selected scope. +- Give the live read/write activity chart the largest opening-screen area, + with endurance outlook and monitoring state visible alongside it. +- Use the muted Chalktone appearance from Glint's third screenshot as the + visual direction. +- Put Live / Day / History views inside one activity panel, keeping both + read/write totals and the selected date visible. +- Expand a focused panel within the dashboard while retaining a fixed strip + for monitoring state, observation freshness, and essential controls. +- Replace block bars with thin dotted time-series plots resembling Glint's + chart. Label volume and time axes, retain exact selected-point readouts, + and leave explicit breaks for missing or incompatible evidence. Joining + adjacent measured points is visual guidance, not additional observations. + +## Implementation recommendation + +Implement the selected visual and interaction patterns independently in +Fenris's existing Python/Textual presentation layer. Retain the collector, +observation store, shared status/projection contracts, and authenticated control +path. Glint is a Rust/Ratatui application under GPL-3.0-or-later; copying or +porting its implementation into Fenris would require a separate licensing +decision under [ADR 0009](../adr/0009-mit-license.md). Referencing general panel +and navigation patterns does not require adopting its application architecture. + +Use thin borders, compact titles, an explicit focused-panel indicator, and +restrained cream/earth-tone accents. Preserve readable contrast and semantic +state labels; the reference's dim secondary text is not a readability target. +Use Fenris content and identity, without Glint's unrelated clock, weather, +finance, email, or gallery features. + +## Existing behavior to preserve + +- Activity is measured read/write volume, with both totals accessible and + writes selected initially. A visual stock-chart reference must not turn + interval volume into speed or imply continuity through unknown evidence. +- Preserve the latest-three-hour opening view, date selection, local-day and + timezone labels, available history precision, and historical selection across + refresh under [live drive activity](live-drive-activity.md). +- Preserve the usage-adjusted theoretical lifespan and categorical projection + confidence with contributing facts. Navigation and graph selection cannot + change the projection evidence window or endurance accounting. +- Preserve explicit zero, gap, incomplete, unallocated, unavailable, paused, + stale, collection-failure, and store-fault states. +- Keep boot enablement, runtime activity, collection outcome, and observation + freshness distinct. Quit leaves background monitoring running; pause remains + a deliberate disable through the existing control path. +- Preserve keyboard and mouse access, constrained-terminal text/reflow, + high-contrast availability, reduced motion, help, and disclosures. + +## Concrete layout and interaction proposal + +- A compact identity header above one dashboard workspace. +- A narrow supporting column for the endurance outlook and drive facts; + a wide activity panel receives the remaining workspace. On the normal + dashboard, confidence and monitoring state remain visible beside activity. +- Live starts with the latest three hours; Day exposes the selected local + day's available detail; History exposes the retained longer-term evidence. + Reuse existing measurements and ranges rather than introducing a new data + model. Show selected date/timezone, read/write totals, measurement, and + evidence state wherever applicable. +- Use visible, clickable tabs; Tab/Shift+Tab and mouse clicks move focus. + Use `z` to toggle focused-panel zoom and Escape to restore the dashboard + when an input/dialog is not consuming Escape. Preserve selection, date, + range, measurement, and focus across zoom and refresh. +- Keep the bottom status/control area outside the expanding workspace. It + may wrap when required: compactness cannot merge the four service facts or + hide a fault/pause state. Keep quit distinct from pause in wording and + behavior without spending three full rows on a separate heavy quit box. +- Use existing date-navigation and read/write shortcuts, with visible help + updated for tabs, focus, and zoom. Text entry must consume its own keys. +- Use Chalktone-inspired styling as the new default while retaining existing + selectable themes, High Contrast, and reduced motion. Preserve an explicitly + saved theme preference during upgrade. +- Design for 80×24 and larger, with existing text/reflow behavior below that + size. Essential facts and actions must remain accessible; the large-screen + reference does not require squeezing its entire density into small terminals. + +These defaults implement the confirmed choices. Date entry and inspection +retain existing evidence precision: hourly/daily UTC evidence is labelled UTC, +while local-day totals keep their recorded timezone. This visual redesign does +not manufacture finer or local-hour precision from coarse UTC evidence. + +## Smallest sufficient implementation proof + +Render normal and zoomed views at a representative large terminal and 80×24, +plus a constrained terminal. Check focus/tab/zoom/date/read-write interactions +and selection persistence through refresh. Exercise paused, stale, store-fault, +missing-baseline, and incomplete-evidence displays using existing synthetic +stores and headless TUI patterns. Verify quit never invokes monitoring control +and existing action tests still cover the sanctioned helper. UI fixtures are +not evidence of the user's deployed drive state. + +## Specification reconciliation + +This accepted redesign supersedes conflicting presentation requirements in +[the original Panes specification](fenris-redesign.md#7-panes-tui) and +[dashboard clarity](dashboard-clarity.md): the full-width headline becomes a +supporting endurance panel, the history/live plots share a tabbed activity +panel, and a compact fixed control row replaces the heavy standalone quit +rail. The block-bar requirement is superseded by dotted volume plots. Their +behavioral requirements, including clear quit-versus-pause semantics, remain. + +The live-drive-activity specification excludes unrelated redesign from that +earlier task. This is a separate design request, not permission to undo its +accepted data, date-navigation, evidence, or forecast behavior. Existing design +documents and source may differ in implementation status; this assessment is +not proof that every earlier acceptance criterion has shipped. + +## Documentation scope + +No new domain term has been resolved: panel, focus, zoom, and theme are general +interface concepts and do not belong in the domain glossary. No new ADR is +needed for a reversible presentation change that retains the existing stack, +license, data model, and privilege boundary. Record any later durable +architectural trade-off separately if one emerges. + +## Implementation validation + +Implemented in Fenris's existing presentation layer with Textual 8.2.8. +The full suite passed 801 tests; 43 packaging/signing checks were skipped for +missing package artifacts or signing tools. After the final incomplete-evidence +fixes, all 81 focused dashboard, history, and plot tests passed. New renderer +and regression files pass configured Ruff checks; affected production files +pass correctness lint and the diff passes whitespace checks. Existing broader +lint warnings were not part of this redesign. + +Rendered normal and zoomed dashboards at 140×44, plus normal 80×24 and +constrained 70×20 views. The [saved preview](../../assets/dashboard-chalktone.png) +uses synthetic observations. No installation or release was performed. diff --git a/pyproject.toml b/pyproject.toml index 0aaaa86..7d50257 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fenris" -version = "0.4.0" +version = "0.5.0" description = "NVMe wear monitor with persistent TUI" requires-python = ">=3.10" license = {file = "LICENSE"} diff --git a/src/fenris/__init__.py b/src/fenris/__init__.py index 607ce04..50ad9af 100644 --- a/src/fenris/__init__.py +++ b/src/fenris/__init__.py @@ -1,2 +1,2 @@ """Fenris: NVMe wear monitor with persistent TUI.""" -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/fenris/activity_plot.py b/src/fenris/activity_plot.py new file mode 100644 index 0000000..f65d443 --- /dev/null +++ b/src/fenris/activity_plot.py @@ -0,0 +1,104 @@ +"""A terminal volume plot shared by Fenris's activity views. + +Braille provides two by four dots per terminal cell. Only adjacent, complete +measurements are connected; gaps and partial evidence never imply continuity. +The returned columns also place mouse inspection on the plotted time axis. +""" +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from rich.text import Text + + +@dataclass(frozen=True) +class VolumePoint: + time: float + volume: int | None + label: str + state: str = "measured" + + +def volume_plot( + points: Sequence[VolumePoint], width: int, height: int, + selected: int, colors: Mapping[str, str], +) -> tuple[Text, list[int], str]: + """Render bounded axes and a dotted trace, without resampling evidence.""" + width, height = max(12, width), max(4, height) + axis_width = 8 + columns, rows = width - axis_width, height - 2 + pixel_width, pixel_height = columns * 2, rows * 4 + maximum = max((p.volume or 0 for p in points), default=0) + scale, unit = (1e12, "TB") if maximum >= 1e12 else ( + (1e9, "GB") if maximum >= 1e9 else + (1e6, "MB") if maximum >= 1e6 else + (1e3, "KB") if maximum >= 1e3 else (1, "B") + ) + ceiling = maximum or scale + start = points[0].time if points else 0 + span = (points[-1].time - start) if len(points) > 1 else 0 + xs = [round((p.time - start) / span * (pixel_width - 1)) if span else 0 + for p in points] + cells = [[0] * columns for _ in range(rows)] + # Unicode braille dot numbering, indexed by y within cell and then x. + bits = ((1, 8), (2, 16), (4, 32), (64, 128)) + + def dot(x: int, y: int) -> None: + cells[y // 4][x // 2] |= bits[y % 4][x % 2] + + previous = None + markers = {} + for index, (point, x) in enumerate(zip(points, xs)): + if point.volume is None: + previous = None + if point.state != "future": + markers[x // 2] = "?" + continue + y = round((1 - point.volume / ceiling) * (pixel_height - 1)) + y = min(pixel_height - 1, max(0, y)) + if previous is not None and point.state == "measured": + px, py = previous + steps = max(abs(x - px), abs(y - py), 1) + for step in range(steps + 1): + dot(round(px + (x - px) * step / steps), + round(py + (y - py) * step / steps)) + dot(x, y) + previous = (x, y) if point.state == "measured" else None + if point.state != "measured": + markers[x // 2] = "~" if point.state == "partial" else "u" + elif point.volume == 0: + markers.setdefault(x // 2, "·") + + selected_column = xs[selected] // 2 if 0 <= selected < len(xs) else -1 + result = Text(no_wrap=True, overflow="crop") + ticks = {0, rows // 2, rows - 1} + for row, values in enumerate(cells): + value = ceiling * (rows - 1 - row) / max(1, rows - 1) / scale + label = f"{value:6.2f}"[-6:] if row in ticks else " " + result.append(label + " │", style=colors["muted"]) + for col, value in enumerate(values): + char = chr(0x2800 + value) if value else " " + style = colors["allocated"] + if col == selected_column: + style = "bold " + colors["selection"] + if not value: + char = "┊" + style = colors["muted"] + result.append(char, style=style) + result.append("\n") + result.append(" └", style=colors["muted"]) + for col in range(columns): + result.append("▼" if col == selected_column else markers.get(col, "─"), + style=colors["selection"] if col == selected_column else colors["muted"]) + result.append("\n" + " " * axis_width) + labels = [" "] * columns + end_of_label = -1 + for index in sorted({0, len(points) // 2, len(points) - 1}): + if index < 0 or not points: + continue + label = points[index].label[:columns] + column = max(0, min(columns - len(label), xs[index] // 2 - len(label) // 2)) + if column > end_of_label: + labels[column:column + len(label)] = label + end_of_label = column + len(label) + result.append("".join(labels), style=colors["muted"]) + return result, [axis_width + x // 2 for x in xs], unit diff --git a/src/fenris/preferences.py b/src/fenris/preferences.py index 9411579..cc65f29 100644 --- a/src/fenris/preferences.py +++ b/src/fenris/preferences.py @@ -19,8 +19,8 @@ from typing import Any, Dict PREFERENCE_FILE_NAME = "preferences.json" -VALID_THEMES = {"amber", "nord", "high_contrast"} -DEFAULT_THEME = "amber" +VALID_THEMES = {"chalktone", "amber", "nord", "high_contrast"} +DEFAULT_THEME = "chalktone" DEFAULT_REDUCED_MOTION = False @@ -46,7 +46,7 @@ def load_preferences() -> Dict[str, Any]: reduced_motion: bool If the file is missing, corrupt, unreadable, or contains invalid - values, returns safe defaults (Amber theme, normal motion). + values, returns safe defaults (Chalktone theme, normal motion). """ path = get_preference_path() diff --git a/src/fenris/themes.py b/src/fenris/themes.py index ca2bb28..0480712 100644 --- a/src/fenris/themes.py +++ b/src/fenris/themes.py @@ -1,6 +1,6 @@ """Fenris theme presets (issue #80). -Three accessible colour presets: Amber (default), Nord, and High Contrast. +Chalktone-inspired default plus Amber, Nord, and High Contrast. Themes style chrome, borders, accents, muted text, and graph roles; status semantic colours/glyphs/text always win. @@ -126,7 +126,22 @@ _HIGH_CONTRAST = Theme( # Theme registry # --------------------------------------------------------------------------- +_CHALKTONE = Theme( + name="fenris-chalktone", + primary="#abc4b3", secondary="#c9b69a", accent="#dfc49a", + warning="#e9bc79", error="#e58d89", success="#acd29c", + foreground="#e0d8c5", background="#202426", surface="#202426", + panel="#252a2c", boost="#333b3d", dark=True, + variables={ + "graph-allocated": "#abc4b3", "graph-unallocated": "#dfc49a", + "graph-gap": "#e58d89", "graph-zero": "#a7b1a9", + "graph-partial": "#e9bc79", "graph-selection": "#f3dbb0", + "border-default": "#606d6a", "muted-text": "#a7b1a9", + }, +) + THEMES = { + "chalktone": _CHALKTONE, "amber": _AMBER, "nord": _NORD, "high_contrast": _HIGH_CONTRAST, @@ -138,16 +153,16 @@ THEME_NAMES = set(THEMES.keys()) def get_theme(name: str) -> Theme: """Return a registered theme by preset name. - Unknown names fall back to Amber. + Unknown names fall back to Chalktone. """ - return THEMES.get(name, _AMBER) + return THEMES.get(name, _CHALKTONE) def get_graph_colors(theme_name: str) -> Dict[str, str]: """Return the graph colour roles for a theme preset. Returns a dict with keys: allocated, unallocated, gap, zero, partial, - selection. Falls back to Amber for unknown names. + selection and muted text. Falls back to Chalktone for unknown names. """ theme = get_theme(theme_name) variables = theme.variables or {} @@ -158,4 +173,5 @@ def get_graph_colors(theme_name: str) -> Dict[str, str]: "zero": variables.get("graph-zero", "#665533"), "partial": variables.get("graph-partial", "#aa8822"), "selection": variables.get("graph-selection", "#ffd54f"), + "muted": variables.get("muted-text", "#a7b1a9"), } diff --git a/src/fenris/tui.py b/src/fenris/tui.py index af3e883..1f59daa 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -1,10 +1,7 @@ -"""Panes TUI: keyboard-first Textual app (spec §7, TUI-1, TUI-4). +"""Fenris terminal dashboard with tabbed volume plots and focused-panel zoom. -One dense screen, four normative regions: - 1. Headline band (full width, top) - 2. Usage-history pane (left, wider) - 3. Drive-health + settings pane (right, narrower) - 4. Service strip (full width, bottom) +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). Privileged actions route through fenris-monitor as terminal-attached subprocesses @@ -25,8 +22,11 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal, VerticalScroll from textual.screen import ModalScreen +from textual.message import Message from textual.widget import Widget -from textual.widgets import Input, Static +from textual.widgets import Input, Static, Tab, Tabs + +from .activity_plot import VolumePoint, volume_plot from .projection import ( ConfidenceState, @@ -53,7 +53,7 @@ from .status_composition import ( from .control import MONITOR_HELPER, MonitorError, run_monitor from .derive import _parse_ts from .preferences import load_preferences, save_preferences -from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors +from .themes import THEMES, get_graph_colors # --------------------------------------------------------------------------- @@ -105,21 +105,11 @@ def _can_render_wolf(width: int) -> bool: # --------------------------------------------------------------------------- -# Bar graph glyphs and constants +# History ranges # --------------------------------------------------------------------------- -_GLYPH_ALLOCATED = "\u2588" # \u2588 full block -_GLYPH_UNALLOCATED = "\u2592" # \u2592 medium shade -_GLYPH_GAP = "\u2591" # \u2591 light shade -_GLYPH_ZERO = "\u00b7" # \u00b7 middle dot -_GLYPH_PARTIAL = "\u2504" # \u2504 partial-period cap -_GLYPH_SELECTED = "\u25bc" # \u25bc selection marker - _RANGE_OPTIONS = (7, 14, 30, 90) _RANGE_DEFAULT = 14 -_BAR_HEIGHT = 6 -_BAR_WIDTH = 2 -_BAR_SPACING = 1 # Terminal size thresholds for constrained layout (issue #81) _MIN_WIDTH = 80 @@ -127,28 +117,28 @@ _MIN_HEIGHT = 24 # --------------------------------------------------------------------------- -# Interactive daily bar graph widget +# Interactive history plot widget # --------------------------------------------------------------------------- -class DailyBarGraph(Widget): - """Interactive daily writes bar graph with hourly drill-down. - - Renders writes-only daily bars using block glyphs, supports keyboard - and mouse navigation, range switching (7/14/30/90 days), day selection, - and hourly drill-down. No plotting dependency (issue #75 AC5). - """ +class HistoryGraph(Widget): + """Historical read/write volume plots with day and hour inspection.""" can_focus = True can_focus_children = False - CSS = """ - DailyBarGraph { + class DaySelected(Message): + def __init__(self, day: str) -> None: + super().__init__() + self.day = day + + DEFAULT_CSS = """ + HistoryGraph { height: 100%; width: 100%; layout: vertical; } #bar-range { - height: 1; + height: auto; width: 100%; } #bar-render { @@ -157,11 +147,12 @@ class DailyBarGraph(Widget): overflow: hidden; } #bar-legend { - height: 1; + height: auto; width: 100%; } #bar-readout { - height: 2; + height: auto; + min-height: 2; width: 100%; } """ @@ -178,8 +169,10 @@ class DailyBarGraph(Widget): self._max_bytes: int = 0 self._hourly_selected: int = -1 self._drill_unallocated_bytes: int = 0 - self._visible_start: int = 0 self._on_drill: Optional[Callable[[str], None]] = None + self.measure = "written" + self._point_columns: list[int] = [] + self._empty_message = "Awaiting first sample" def compose(self) -> ComposeResult: yield Static("", id="bar-range") @@ -192,8 +185,10 @@ class DailyBarGraph(Widget): day_data: List[Dict[str, Any]], on_drill: Any = None, selected_date: Optional[str] = None, + empty_message: str = "Awaiting first sample", ) -> None: """Update graph with day data. on_drill(day) called on drill entry.""" + self._empty_message = empty_message selected_day = selected_date if selected_day is None and 0 <= self.selected_index < len(self._day_data): selected_day = self._day_data[self.selected_index].get("day") @@ -251,26 +246,27 @@ class DailyBarGraph(Widget): return self._render_range() self._render_bars() - self._render_legend() self._render_readout() def _show_empty(self) -> None: self.query_one("#bar-range").update( "[dim]Usage history · %d days · UTC[/dim]" % self.range_days ) - self.query_one("#bar-render").update("[dim]Awaiting first sample[/dim]") + self.query_one("#bar-render").update( + ("Graph needs ≥80×24\n" if self._is_constrained() else "") + self._empty_message) self.query_one("#bar-legend").update("") self.query_one("#bar-readout").update("") def _is_constrained(self, terminal_width: int = 0) -> bool: - """Return True when the terminal or widget is too narrow for the bar graph. + """Return True when the terminal or widget is too narrow for the plot. - 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. + Below 32 columns the plot axes and time labels become unreadable. When terminal_width is provided (issue #81), also check against _MIN_WIDTH. """ try: w = self.region.width + if self.is_mounted and (self.app.size.width < _MIN_WIDTH or self.app.size.height < _MIN_HEIGHT): + return True if terminal_width > 0 and terminal_width < _MIN_WIDTH: return True return 0 < w < 32 @@ -300,19 +296,7 @@ class DailyBarGraph(Widget): ) self.query_one("#bar-legend").update("") - # Preserve selection readout - if 0 <= self.selected_index < len(self._day_data): - day = self._day_data[self.selected_index] - self.query_one("#bar-readout").update( - "[bold]%s[/bold] \u00b7 W %.3f GB \u00b7 R %.3f GB" - % ( - day.get("local_label", ""), - day.get("total_written", day.get("total_bytes", 0)) / 1e9, - day.get("total_read", 0) / 1e9, - ) - ) - else: - self.query_one("#bar-readout").update("[dim]No selectable day[/dim]") + self._render_readout() def _show_constrained_hourly_summary(self) -> None: """Textual fallback for hourly view when terminal is too small.""" @@ -330,18 +314,7 @@ class DailyBarGraph(Widget): ) self.query_one("#bar-legend").update("") - if 0 <= self._hourly_selected < len(self._hour_data): - h = self._hour_data[self._hourly_selected] - self.query_one("#bar-readout").update( - "[bold]%s[/bold] \u00b7 W %.3f GB \u00b7 R %.3f GB" - % ( - h.get("local_label", ""), - h.get("bytes_written", 0) / 1e9, - h.get("bytes_read", 0) / 1e9, - ) - ) - else: - self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select hour[/dim]") + self._render_hourly_readout() def on_resize(self) -> None: """Re-render when terminal size changes.""" @@ -358,116 +331,39 @@ class DailyBarGraph(Widget): parts.append("[reverse bold]\\[%s][/reverse bold]" % label) else: parts.append("[%s]" % label) - label = "Usage history · %d days · UTC %s" % ( + label = "%d days · UTC %s" % ( self.range_days, " ".join(parts), ) if self.view_mode == "hourly": - label = "Usage history · Hourly · UTC · [bold]%s[/bold] · Esc Back" % ( + label = "Hourly · UTC · [bold]%s[/bold] · Backspace History" % ( self.drill_day or "", ) self.query_one("#bar-range").update(label) - def _visible_items(self, items: List[Dict[str, Any]], selected: int) -> tuple[int, List[Dict[str, Any]]]: - """Return the readable viewport containing the selected item.""" - width = getattr(getattr(self, "region", None), "width", 0) or 52 - capacity = max(1, (width - 12) // (_BAR_WIDTH + _BAR_SPACING)) - capacity = min(capacity, len(items)) - if capacity >= len(items): - return 0, items - selected = max(0, selected) - start = min(max(0, selected - capacity + 1), len(items) - capacity) - return start, items[start:start + capacity] - - @staticmethod - def _unit_scale(max_bytes: int) -> tuple[float, str]: - if max_bytes >= 1_000_000_000_000: - return 1e12, "TB" - if max_bytes >= 1_000_000_000: - return 1e9, "GB" - return 1e6, "MB" - def _render_bars(self) -> None: - start, visible = self._visible_items(self._day_data, self.selected_index) - self._visible_start = start - if not visible: - self.query_one("#bar-render").update("") - return - - max_bytes = self._max_bytes or 1 - render_height = self.query_one("#bar-render").region.height - bar_h = max(1, min(_BAR_HEIGHT, render_height - 2)) - n = len(visible) - - scale, unit = _unit_scale(max_bytes) - lines: List[str] = [ - "Writes (%s) · %s → %s UTC" % ( - unit, visible[0].get("day", ""), visible[-1].get("day", ""), + points = [] + for index, day in enumerate(self._day_data): + volume = day.get("total_read", 0) if self.measure == "read" else day.get("total_bytes", 0) + unallocated = day.get("unallocated_read" if self.measure == "read" else "unallocated_bytes", 0) + state = "unallocated" if unallocated else "gap" if day.get("is_gap") else ( + "partial" if day.get("is_partial") else "measured" ) - ] - for row in range(bar_h, 0, -1): - if row == bar_h: - axis = "%7.2f " % (max_bytes / scale) - elif row == (bar_h + 1) // 2 and row != 1: - axis = "%7.2f " % (max_bytes / scale / 2) - elif row == 1: - axis = "%7s " % "0" - else: - axis = " " - line = axis - threshold = (row / bar_h) * max_bytes - for i, day in enumerate(visible): - allocated = day.get("allocated_bytes", 0) - total = day.get("total_bytes", 0) - is_zero = day.get("is_zero", False) - is_gap = day.get("is_gap", False) - if is_zero and row == 1: - glyph = _GLYPH_ZERO - elif is_gap: - glyph = _GLYPH_GAP if row <= 2 else " " - elif total == 0: - glyph = " " - elif day.get("is_partial") and row == max( - 1, (total * bar_h + max_bytes - 1) // max_bytes, - ): - glyph = _GLYPH_PARTIAL - elif threshold <= allocated: - glyph = _GLYPH_ALLOCATED - elif threshold <= total: - glyph = _GLYPH_UNALLOCATED - else: - glyph = " " + points.append(VolumePoint(index, None if state == "gap" else volume, + day.get("day", "")[5:], state)) + self._plot(points, self.selected_index) - line += glyph * _BAR_WIDTH - if i < n - 1: - line += " " * _BAR_SPACING - - lines.append(line) - - # Date labels - label_line = "Day UTC " - for i, day in enumerate(visible): - label = day.get("local_label", day.get("day", ""))[-2:] - if start + i == self.selected_index: - label = _GLYPH_SELECTED + label[-1:] - label_line += label - if i < n - 1: - label_line += " " * _BAR_SPACING - lines.append(label_line) - - self.query_one("#bar-render").update("\n".join(lines)) - - def _render_legend(self) -> None: - legend = ( - "%s Alloc %s Unalloc %s Gap %s Zero %s Partial" - % ( - _GLYPH_ALLOCATED, - _GLYPH_UNALLOCATED, - _GLYPH_GAP, - _GLYPH_ZERO, - _GLYPH_PARTIAL, - ) + def _plot(self, points: list[VolumePoint], selected: int) -> None: + target = self.query_one("#bar-render") + colors = get_graph_colors(getattr(self.app, "_current_theme_name", "chalktone")) + chart, self._point_columns, unit = volume_plot( + points, target.content_size.width or 40, target.content_size.height or 8, + selected, colors, + ) + target.update(chart) + self.query_one("#bar-legend").update( + ("Reads" if self.measure == "read" else "Writes") + + " (%s) · ? Gap · · Zero · ~ Partial · u Unalloc" % unit ) - self.query_one("#bar-legend").update(legend) def _render_readout(self) -> None: if self.selected_index < 0 or self.selected_index >= len(self._day_data): @@ -475,6 +371,17 @@ class DailyBarGraph(Widget): return day = self._day_data[self.selected_index] + if day.get("is_gap"): + # Known daily evidence survives missing hourly coverage; absent + # evidence must not be presented as a measured zero. + volumes = [] + for label, key in (("W", "unallocated_bytes"), ("R", "unallocated_read")): + volume = day.get(key, 0) + volumes.append("%s %.3f GB unallocated" % (label, volume / 1e9) + if volume else "%s unavailable" % label) + self.query_one("#bar-readout").update( + "%s UTC · gap\n%s" % (day.get("day", ""), " · ".join(volumes))) + return total_written = day.get("total_written", day.get("total_bytes", 0)) total_read = day.get("total_read", 0) allocated_w = day.get("allocated_bytes", 0) @@ -484,9 +391,7 @@ class DailyBarGraph(Widget): coverage = day.get("coverage", 0) hours = day.get("evidenced_hours", 0) - state = " · partial" if day.get("is_partial") else ( - " · gap" if day.get("is_gap") else "" - ) + state = " · partial" if day.get("is_partial") else "" parts = [ "[bold]%s UTC[/bold] \u00b7 W %.3f GB \u00b7 R %.3f GB \u00b7 %d hours \u00b7 %.0f%% coverage%s" % ( @@ -518,6 +423,8 @@ class DailyBarGraph(Widget): def _refresh_hourly(self) -> None: if not self._hour_data: self.query_one("#bar-render").update("[dim]No hourly data[/dim]") + self.query_one("#bar-legend").update("") + self.query_one("#bar-readout").update("") return self._render_range() @@ -526,79 +433,27 @@ class DailyBarGraph(Widget): self._show_constrained_hourly_summary() return - max_bytes = max( - (h.get("bytes_written", 0) for h in self._hour_data), default=0 - ) or 1 - start, visible = self._visible_items(self._hour_data, self._hourly_selected) - self._visible_start = start - render_height = self.query_one("#bar-render").region.height - bar_h = max(1, min(_BAR_HEIGHT, render_height - 2)) - n = len(visible) - scale, unit = _unit_scale(max_bytes) - - lines: List[str] = [ - "Writes (%s) · %s:00 → %s:00 UTC" % ( - unit, - visible[0].get("local_label", ""), - visible[-1].get("local_label", ""), + points = [] + for index, hour in enumerate(self._hour_data): + state = "future" if hour.get("is_future") else ( + "gap" if hour.get("is_gap") else "partial" if hour.get("is_partial") else "measured" ) - ] - for row in range(bar_h, 0, -1): - if row == bar_h: - axis = "%7.2f " % (max_bytes / scale) - elif row == (bar_h + 1) // 2 and row != 1: - axis = "%7.2f " % (max_bytes / scale / 2) - elif row == 1: - axis = "%7s " % "0" - else: - axis = " " - line = axis - threshold = (row / bar_h) * max_bytes - for i, hour in enumerate(visible): - bw = hour.get("bytes_written", 0) - if hour.get("is_future"): - glyph = " " - elif hour.get("is_gap"): - glyph = _GLYPH_GAP if row <= 2 else " " - elif hour.get("is_zero") and row == 1: - glyph = _GLYPH_ZERO - elif bw == 0: - glyph = " " - elif hour.get("is_partial") and row == max( - 1, (bw * bar_h + max_bytes - 1) // max_bytes, - ): - glyph = _GLYPH_PARTIAL - elif threshold <= bw: - glyph = _GLYPH_ALLOCATED - else: - glyph = " " - line += glyph * _BAR_WIDTH - if i < n - 1: - line += " " * _BAR_SPACING - lines.append(line) + volume = hour.get("bytes_read" if self.measure == "read" else "bytes_written", 0) + points.append(VolumePoint(index, None if state in ("future", "gap") else volume, + hour.get("local_label", "") + ":00", state)) + self._plot(points, self._hourly_selected) - label_line = "Hour UTC" - for i, hour in enumerate(visible): - label = hour.get("local_label", hour.get("hour", ""))[-2:] - if start + i == self._hourly_selected: - label = _GLYPH_SELECTED + label[-1:] - label_line += " " + label - if i < n - 1: - label_line += " " * max(0, _BAR_SPACING - 1) - lines.append(label_line) + self._render_hourly_readout() - self.query_one("#bar-render").update("\n".join(lines)) - self.query_one("#bar-legend").update( - "%s Writes · %s Gap · %s Zero · %s Partial" - % (_GLYPH_ALLOCATED, _GLYPH_GAP, _GLYPH_ZERO, _GLYPH_PARTIAL) - ) - - # Hourly readout + def _render_hourly_readout(self) -> None: if 0 <= self._hourly_selected < len(self._hour_data): h = self._hour_data[self._hourly_selected] - state = " · partial" if h.get("is_partial") else ( - " · gap" if h.get("is_gap") else "" - ) + if h.get("is_gap") or h.get("is_future"): + self.query_one("#bar-readout").update( + "%s:00 UTC · %s\nW unavailable · R unavailable" % ( + h.get("local_label", ""), "future" if h.get("is_future") else "gap")) + return + state = " · partial" if h.get("is_partial") else "" note = "" if self._drill_unallocated_bytes: note = "\n%.3f GB could not be assigned to an hour" % ( @@ -657,6 +512,13 @@ class DailyBarGraph(Widget): self._refresh() event.stop() + if event.key in ("left", "right", "1", "2", "3", "4"): + self._notify_selection() + + def _notify_selection(self) -> None: + if 0 <= self.selected_index < len(self._day_data): + self.post_message(self.DaySelected(self._day_data[self.selected_index]["day"])) + def _enter_drill(self) -> None: if self.selected_index < 0 or self.selected_index >= len(self._day_data): return @@ -665,12 +527,9 @@ class DailyBarGraph(Widget): self._drill_unallocated_bytes = day.get("unallocated_bytes", 0) self.view_mode = "hourly" self._hourly_selected = -1 + self._render_range() if self._on_drill: self._on_drill(self.drill_day) - self._render_range() - self.query_one("#bar-render").update("[dim]loading...[/dim]") - self.query_one("#bar-legend").update("") - self.query_one("#bar-readout").update("") def _exit_drill(self) -> None: self.view_mode = "daily" @@ -680,8 +539,12 @@ class DailyBarGraph(Widget): 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 self._hourly_selected < 0: @@ -718,10 +581,7 @@ class DailyBarGraph(Widget): old_day = self._day_data[self.selected_index].get("day") # Count actual data days (not gaps) after the target - n_data_after = sum( - 1 for d in self._all_day_data[idx:] - if d.get("sample_count", 0) > 0 or d.get("total_bytes", 0) > 0 - ) + n_data_after = len(self._all_day_data) - idx if n_data_after > self.range_days: self.range_days = max(n_data_after, min(_RANGE_OPTIONS)) @@ -738,17 +598,20 @@ class DailyBarGraph(Widget): def on_click(self, event: Any) -> None: render = self.query_one("#bar-render") - offset_x = event.x - render.region.x - bar_total = _BAR_WIDTH + _BAR_SPACING - idx = max(0, offset_x - 8) // bar_total + if not self._point_columns or not render.region.contains(event.screen_x, event.screen_y): + return + self.focus() + offset_x = event.screen_x - render.content_region.x + idx = min(range(len(self._point_columns)), key=lambda i: abs(self._point_columns[i] - offset_x)) if self.view_mode == "daily": - global_idx = self._visible_start + idx + global_idx = idx if 0 <= global_idx < len(self._day_data): self.selected_index = global_idx self._refresh() + self._notify_selection() else: - global_idx = self._visible_start + idx + global_idx = idx if 0 <= global_idx < len(self._hour_data): self._hourly_selected = global_idx self._refresh_hourly() @@ -762,7 +625,7 @@ def _query_daily_graph_data( conn: sqlite3.Connection, window_end: Optional[datetime] = None, ) -> List[Dict[str, Any]]: - """Query day aggregates for the bar graph. + """Query day aggregates for the history plot. Returns one dict per day with total/allocated/unallocated bytes, coverage, evidence hours, and classification flags. @@ -1022,8 +885,7 @@ def _query_live_graph_data( class LiveActivityGraph(Widget): """Rolling last-three-hours interval volume graph with read/write toggle. - Renders interval written (or read) volumes as vertical bars using block - glyphs. Supports keyboard and mouse navigation, a read/write toggle + Renders interval written (or read) volumes as a dotted time-series plot. Supports keyboard and mouse navigation, a read/write toggle (w key), and selected-point inspection showing timestamps, timezone, measured volume, and evidence state. Reads only committed observation-store evidence. @@ -1032,14 +894,14 @@ class LiveActivityGraph(Widget): can_focus = True can_focus_children = False - CSS = """ + DEFAULT_CSS = """ LiveActivityGraph { height: 100%; width: 100%; layout: vertical; } #live-header { - height: 1; + height: auto; width: 100%; } #live-render { @@ -1048,11 +910,12 @@ class LiveActivityGraph(Widget): overflow: hidden; } #live-legend { - height: 1; + height: auto; width: 100%; } #live-readout { - height: 2; + height: auto; + min-height: 2; width: 100%; } """ @@ -1062,6 +925,8 @@ class LiveActivityGraph(Widget): self._data: List[Dict[str, Any]] = [] self._selected_index: int = -1 self._measure: str = "written" # "written" or "read" + self._point_columns: list[int] = [] + self._empty_message = "Awaiting intervals" def compose(self) -> ComposeResult: yield Static("", id="live-header") @@ -1078,8 +943,9 @@ class LiveActivityGraph(Widget): self._measure = "read" if self._measure == "written" else "written" self._refresh() - def set_data(self, data: List[Dict[str, Any]]) -> None: + def set_data(self, data: List[Dict[str, Any]], empty_message: str = "Awaiting intervals") -> None: """Update graph with live interval data.""" + self._empty_message = empty_message prev_selected_end = None if 0 <= self._selected_index < len(self._data): prev_selected_end = self._data[self._selected_index].get("end_ts") @@ -1102,87 +968,43 @@ class LiveActivityGraph(Widget): def _refresh(self) -> None: self._render_header() if not self._data: - self.query_one("#live-render").update("[dim]Awaiting intervals[/dim]") + self.query_one("#live-render").update(Text(self._empty_message)) self.query_one("#live-legend").update("") self.query_one("#live-readout").update("") return self._render_bars() - self._render_legend() self._render_readout() def _render_header(self) -> None: label = "Writes" if self._measure == "written" else "Reads" - toggle_hint = "w Toggle W/R" n = len(self._data) self.query_one("#live-header").update( - "Live %s · last %dh · %d intervals · %s" - % (label.lower(), LIVE_WINDOW_H, n, toggle_hint) + "%s · last %dh · %d intervals" + % (label, LIVE_WINDOW_H, n) ) def _render_bars(self) -> None: - max_bytes = max( - (self._current_bytes(d) for d in self._data), default=0 - ) or 1 - - render_height = self.query_one("#live-render").region.height - bar_h = max(1, min(_BAR_HEIGHT, render_height - 2)) - n = len(self._data) - scale, unit = _unit_scale(max_bytes) - - measure_label = "Writes" if self._measure == "written" else "Reads" - lines: List[str] = [ - "%s (%s) · %s → %s" % ( - measure_label, unit, - self._data[0].get("start_label", ""), - self._data[-1].get("end_label", ""), - ) - ] - for row in range(bar_h, 0, -1): - if row == bar_h: - axis = "%7.2f " % (max_bytes / scale) - elif row == (bar_h + 1) // 2 and row != 1: - axis = "%7.2f " % (max_bytes / scale / 2) - elif row == 1: - axis = "%7s " % "0" - else: - axis = " " - line = axis - threshold = (row / bar_h) * max_bytes - for i, item in enumerate(self._data): - bw = self._current_bytes(item) - if item.get("is_gap") or item.get("is_segment_boundary"): - glyph = _GLYPH_GAP if row <= 2 else " " - elif bw == 0 and item.get("is_zero"): - glyph = _GLYPH_ZERO if row == 1 else " " - elif bw == 0: - glyph = " " - elif threshold <= bw: - glyph = _GLYPH_ALLOCATED - else: - glyph = " " - line += glyph * _BAR_WIDTH - if i < n - 1: - line += " " * _BAR_SPACING - lines.append(line) - - # Time labels - label_line = "Time " - for i, item in enumerate(self._data): - label = item.get("start_label", "")[-2:] - if i == self._selected_index: - label = _GLYPH_SELECTED + label[-1:] - label_line += label - if i < n - 1: - label_line += " " * _BAR_SPACING - lines.append(label_line) - - self.query_one("#live-render").update("\n".join(lines)) - - def _render_legend(self) -> None: + target = self.query_one("#live-render") + points = [VolumePoint( + _parse_ts(item["end_ts"]).timestamp(), + None if item.get("is_gap") or item.get("is_segment_boundary") else self._current_bytes(item), + item.get("end_label", ""), + "gap" if item.get("is_gap") or item.get("is_segment_boundary") else "measured", + ) for item in self._data] + if self.app.size.width < _MIN_WIDTH or self.app.size.height < _MIN_HEIGHT: + self._point_columns = [] + self.query_one("#live-legend").update("") + target.update("Graph needs ≥80×24\n%d measured intervals · ← → Inspect\n%s → %s UTC" % ( + len(self._data), self._data[0].get("start_label", ""), self._data[-1].get("end_label", ""))) + return + chart, self._point_columns, unit = volume_plot( + points, target.content_size.width or 40, target.content_size.height or 8, + self._selected_index, get_graph_colors(getattr(self.app, "_current_theme_name", "chalktone")), + ) + target.update(chart) self.query_one("#live-legend").update( - "%s Volume %s Gap %s Zero" % ( - _GLYPH_ALLOCATED, _GLYPH_GAP, _GLYPH_ZERO, - ) + ("Reads" if self.measure == "read" else "Writes") + + " (%s / interval) · UTC · ? Gap · · Zero" % unit ) def _render_readout(self) -> None: @@ -1191,6 +1013,11 @@ class LiveActivityGraph(Widget): return item = self._data[self._selected_index] + if item.get("is_segment_boundary"): + self.query_one("#live-readout").update( + "%s → %s UTC · segment boundary\nW unavailable · R unavailable" % ( + item.get("start_label", ""), item.get("end_label", ""))) + return bw = item.get("bytes_written", 0) br = item.get("bytes_read", 0) measure_val = bw if self._measure == "written" else br @@ -1201,7 +1028,7 @@ class LiveActivityGraph(Widget): state_parts.append("gap") if item.get("is_segment_boundary"): state_parts.append("segment boundary") - if item.get("is_zero"): + if item.get("is_zero") and not item.get("is_gap") and not item.get("is_segment_boundary"): state_parts.append("measured zero") state = " · " + ", ".join(state_parts) if state_parts else "" @@ -1234,15 +1061,14 @@ class LiveActivityGraph(Widget): ) self._refresh() event.stop() - elif event.key == "w": - self.toggle_measure() - event.stop() def on_click(self, event: Any) -> None: render = self.query_one("#live-render") - offset_x = event.x - render.region.x - bar_total = _BAR_WIDTH + _BAR_SPACING - idx = max(0, offset_x - 8) // bar_total + if not self._point_columns or not render.region.contains(event.screen_x, event.screen_y): + return + self.focus() + offset_x = event.screen_x - render.content_region.x + idx = min(range(len(self._point_columns)), key=lambda i: abs(self._point_columns[i] - offset_x)) if 0 <= idx < len(self._data): self._selected_index = idx self._refresh() @@ -1369,11 +1195,14 @@ class HelpScreen(ModalScreen[None]): "[bold]Keyboard controls[/bold]\n" + _ACTION_LEGEND + "\n" "q Quit the dashboard — monitoring continues\n" - "Tab Focus the graph · ← → Select · 1-4 Change range\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 W/R on live graph\n" - "Theme changed via preferences file\n\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" + "Values are bytes per interval, hour, or day, not transfer speed.\n\n" "[dim]↑ ↓ Scroll · Esc Close[/dim]", id="help-text", ) @@ -1455,94 +1284,56 @@ class FenrisTuiApp(App): SUB_TITLE = "" CSS = """ + Screen { background: $background; color: $text; } + #identity { height: 1; padding: 0 1; color: $accent; } + #auth-notice { height: auto; padding: 0 1; color: $text-muted; } + #dashboard-scroll { height: 1fr; } #main-grid { layout: grid; - grid-size: 2 5; - grid-columns: 3fr 2fr; - grid-rows: auto 10 8 auto 3; - height: auto; - } - #main-grid.local-day { - grid-size: 2 6; - grid-rows: auto 10 8 auto auto 3; - } - #main-grid.paused { - grid-size: 2 6; - grid-rows: auto auto 10 8 auto 3; - } - #main-grid.paused.local-day { - grid-size: 2 7; - grid-rows: auto auto 10 8 auto auto 3; - } - #main-grid.constrained { - grid-size: 1 5; - grid-columns: 1fr; - grid-rows: auto auto auto auto auto; - } - #main-grid.constrained.local-day { - grid-size: 1 6; - grid-rows: auto auto auto auto auto auto; - } - #main-grid.constrained.paused { - grid-size: 1 6; - grid-rows: auto auto auto auto auto auto; - } - #main-grid.constrained.paused.local-day { - grid-size: 1 7; - grid-rows: auto auto auto auto auto auto auto; - } - #main-grid.constrained #usage-history { - display: none; - } - #main-grid.constrained #constrained-summary { - display: block; - } - #main-grid.constrained #live-activity { - display: none; - } - #constrained-summary { - display: none; - border: round #555555; - padding: 0 1; - height: auto; - min-height: 3; - } - #live-activity { - column-span: 2; - border: round #555555; - padding: 0 1; - height: auto; - min-height: 5; - } - #local-day { - column-span: 2; - display: none; - border: round #555555; - padding: 0 1; - height: auto; - min-height: 3; - } - #dashboard-scroll { height: 1fr; } - #headline-band { column-span: 2; height: auto; min-height: 8; } - #paused-banner { - column-span: 2; - display: none; - background: $error 20%; - color: $text; - height: auto; - min-height: 5; - } - #service-strip { column-span: 2; height: auto; } - #quit-rail { - column-span: 2; - border: heavy $accent; - content-align: center middle; + grid-size: 2 2; + grid-columns: 1fr 2fr; + grid-rows: 3fr 2fr; + grid-gutter: 0 1; height: 100%; } - .pane { border: round #555555; padding: 0 1; height: 100%; } - #confirm-text { padding: 1 2; } - #disc-text { padding: 1 2; } - #help-text { padding: 1 2; } + .dashboard-pane { + border: round $primary 35%; + border-title-color: $text; + padding: 0 1; + scrollbar-size: 1 1; + } + .dashboard-pane:focus, .dashboard-pane:focus-within { + border: round $accent; + border-title-color: $accent; + } + #activity-panel { row-span: 2; height: 100%; } + #headline-band, #drive-health { height: auto; } + #activity-tabs { height: 1; margin: 0; } + #activity-tabs Underline { display: none; } + #activity-tabs Tab { padding: 0 1; height: 1; } + #activity-tools { height: 1; color: $accent; } + #usage-history, #live-activity { height: 1fr; min-height: 6; } + #bar-render, #live-render { min-height: 4; } + #local-day { height: auto; min-height: 2; color: $text; } + #paused-banner { display: none; height: auto; background: $warning 15%; padding: 0 1; } + #status-area { height: auto; max-height: 35%; scrollbar-size: 1 1; } + #service-strip { height: auto; padding: 0 1; border-top: solid $primary 35%; } + #action-rail { height: auto; padding: 0 1; color: $accent; } + #main-grid.zoomed { grid-size: 1 1; grid-columns: 1fr; grid-rows: 1fr; } + #main-grid.zoomed .dashboard-pane { display: none; } + #main-grid.zoomed .dashboard-pane.zoom-target { display: block; row-span: 1; } + #main-grid.constrained { layout: vertical; height: auto; } + #main-grid.constrained .dashboard-pane { height: auto; } + #main-grid.constrained #activity-panel { height: auto; } + #main-grid.constrained #live-activity, #main-grid.constrained #usage-history { + height: auto; min-height: 0; + } + #main-grid.constrained #live-render, #main-grid.constrained #bar-render { + height: auto; min-height: 0; + } + #main-grid.constrained #endurance-panel { max-height: 16; } + #main-grid.constrained.zoomed .zoom-target { height: 24; } + #confirm-text, #disc-text, #help-text { padding: 1 2; } """ BINDINGS = [ @@ -1558,6 +1349,10 @@ class FenrisTuiApp(App): 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), ] def __init__( @@ -1590,109 +1385,145 @@ class FenrisTuiApp(App): # Track the date the user is browsing (None = live/today view) self._browse_date: Optional[str] = None + self._activity_view = "live" + self._zoomed_panel: Optional[str] = None def compose(self) -> ComposeResult: + yield Static(_IDENTITY_FULL, id="identity") + yield Static("Open with fenris (no sudo). Actions authenticate via polkit. ? Help", id="auth-notice") + yield Static("", id="paused-banner") 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 LiveActivityGraph(id="live-activity") - yield Static("", id="drive-health", classes="pane") - yield Static("", id="local-day", classes="pane") - yield Static("", id="service-strip", classes="pane") - yield Static("q Quit TUI", id="quit-rail") + with VerticalScroll(id="endurance-panel", classes="dashboard-pane"): + yield Static("", id="headline-band") + with Container(id="activity-panel", classes="dashboard-pane"): + 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 LiveActivityGraph(id="live-activity") + yield HistoryGraph(id="usage-history") + with VerticalScroll(id="drive-panel", classes="dashboard-pane"): + 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") def on_mount(self) -> None: - """Set border titles, apply theme, and render initial state.""" - # Apply loaded theme (issue #80) - # Preference uses underscores (high_contrast); Textual theme uses dashes (fenris-high-contrast) self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-") - - 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("#endurance-panel").border_title = "Endurance outlook" + 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" - self.query_one("#local-day").border_title = "Local day" - self.query_one("#service-strip").border_title = "Monitoring and actions" - 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._apply_activity_view() + self.query_one("#live-activity").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" + self.query_one("#activity-tabs", Tabs).active = "view-" + self._activity_view + + def _set_activity_view(self, view: str) -> None: + self._activity_view = view + graph = self.query_one("#usage-history") + if view == "live": + self._browse_date = None + elif view == "day" and graph._day_data and graph.view_mode != "hourly": + if self._browse_date is not None: + graph.drill_day = self._browse_date + graph.view_mode = "hourly" + self._on_graph_drill(self._browse_date) + else: + graph._enter_drill() + elif view == "history" and graph.view_mode == "hourly": + graph._exit_drill() + self._apply_activity_view() + self._refresh() + self.query_one("#live-activity" if view == "live" else "#usage-history").focus( + scroll_visible=not self._is_constrained_mode) + + def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + view = event.tab.id.removeprefix("view-") + if event.tabs.active == event.tab.id and view != self._activity_view: + self._set_activity_view(view) + + def on_history_graph_day_selected(self, event: HistoryGraph.DaySelected) -> None: + if self._activity_view != "history": + return + self._browse_date = event.day + with read_status(self.store_path, self._clock_now, query_services=False) as (conn, _): + if conn is not None: + self._render_local_day(conn) + + def action_next_view(self) -> None: + views = ("live", "day", "history") + self._set_activity_view(views[(views.index(self._activity_view) + 1) % len(views)]) + + def action_toggle_zoom(self) -> None: + if self._zoomed_panel: + self.action_exit_zoom() + return + focused = self.focused + panel = next((widget for widget in [focused, *focused.ancestors] + if widget.has_class("dashboard-pane")), None) if focused else None + panel = panel or self.query_one("#activity-panel") + self._zoomed_panel = panel.id + panel.add_class("zoom-target") + self.query_one("#main-grid").add_class("zoomed") + panel.border_subtitle = "z / Esc Restore" + + def action_exit_zoom(self) -> None: + if not self._zoomed_panel: + return + panel = self.query_one("#" + self._zoomed_panel) + panel.remove_class("zoom-target") + panel.border_subtitle = "" + self.query_one("#main-grid").remove_class("zoomed") + self._zoomed_panel = None + + def action_cycle_theme(self) -> None: + names = list(THEMES) + self._current_theme_name = names[(names.index(self._current_theme_name) + 1) % len(names)] + self.theme = "fenris-" + self._current_theme_name.replace("_", "-") + save_preferences(theme=self._current_theme_name, reduced_motion=self._reduced_motion) self._refresh() def on_resize(self) -> None: """Re-render with the new terminal size after Textual applies it.""" self.call_after_refresh(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_written = sum(d.get("total_written", d.get("total_bytes", 0)) for d in graph._day_data) - total_read = sum(d.get("total_read", 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 or d.get("total_read", 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 activity · W %.3f GB R %.3f GB\n" - " %s → %s" % (n, days_with_data, total_written / 1e9, total_read / 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] \u00b7 W %.3f GB \u00b7 R %.3f GB" % ( - day.get("local_label", ""), - day.get("total_written", day.get("total_bytes", 0)) / 1e9, - day.get("total_read", 0) / 1e9, - ) - - summary.update(text) - def on_refresh_tick(self) -> None: """Refresh dashboard and dismiss launch-only authentication guidance.""" self._show_auth_notice = False self._refresh() - def _headline_prefix(self) -> str: - """Render identity and any launch-only guidance above drive state.""" - # Use wolf glyph if terminal can render it (issue #79) - width = getattr(self, '_size', (80, 24))[0] if hasattr(self, '_size') else 80 - if _can_render_wolf(width): - identity = _IDENTITY_FULL - else: - identity = _IDENTITY_FALLBACK - lines = ["[bold]%s[/bold]" % identity] - if self._show_auth_notice: - lines.append("[dim]Open with fenris (no sudo). Actions authenticate via polkit. ? Help[/dim]") - return "\n".join(lines) - def _render_headline(self, body: str = "") -> None: - """Render full-width identity, guidance, and current drive state.""" - text = self._headline_prefix() - if body: - text += "\n\n" + body - self.query_one("#headline-band").update(text) + self.query_one("#identity").update(_IDENTITY_FULL if _can_render_wolf(self.size.width) else _IDENTITY_FALLBACK) + self.query_one("#auth-notice").display = self._show_auth_notice + self.query_one("#headline-band").update(body) def _refresh(self) -> None: """Refresh all four regions from store data.""" self._clock_now = datetime.now(timezone.utc) 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 was_constrained != self._is_constrained_mode: + first, second = ("activity-panel", "endurance-panel") if self._is_constrained_mode else ( + "endurance-panel", "activity-panel") + main_grid.move_child(self.query_one("#" + first), before=self.query_one("#" + second)) if self._is_constrained_mode: main_grid.add_class("constrained") else: @@ -1704,9 +1535,13 @@ class FenrisTuiApp(App): self._render_empty_or_fault(comp) else: self._render_all_regions(conn, comp) - self.query_one("#service-strip").update( - "%s\n%s" % (render_status_tui(comp), _ACTION_LEGEND) - ) + status_lines = render_status_tui(comp).splitlines() + if comp.paused_lines: + status_lines = status_lines[:-len(comp.paused_lines)] + lines = [line for line in status_lines if line] + if len(lines) > 1: + lines[:2] = [lines[0] + " · " + lines[1]] + self.query_one("#service-strip").update("\n".join(lines)) self._render_paused_banner(comp) def _render_empty_or_fault(self, comp: StatusComposition) -> None: @@ -1732,13 +1567,9 @@ class FenrisTuiApp(App): self.query_one("#drive-health").update("") graph = self.query_one("#usage-history") - graph.set_data([]) - if comp.store_fault or comp.newer_schema: - self.query_one("#bar-render").update(message) - if self._is_constrained_mode: - self.query_one("#constrained-summary").update( - "[dim]Graph needs ≥80×24[/dim]\n" + message - ) + graph.set_data([], empty_message=message) + self.query_one("#live-activity").set_data( + [], empty_message=message if comp.store_fault or comp.newer_schema else "Awaiting intervals") # Hide local-day widget when no data local_day_widget = self.query_one("#local-day") local_day_widget.styles.display = "none" @@ -1764,11 +1595,14 @@ class FenrisTuiApp(App): headline = self._format_headline(proj) confidence = self._format_confidence(proj) scenario = self._format_scenario(proj) - self._render_headline(headline + "\n" + confidence + "\n" + scenario) + summary, _, context = headline.partition("\n") + confidence_title, _, facts = confidence.partition("\n") + self._render_headline(summary + "\n" + confidence_title + "\n\n" + + context + "\n" + facts + "\n" + scenario) except Exception: self._render_headline("[bold]No projection available[/bold]") - # --- Usage-history pane (§7.2 left): interactive bar graph --- + # --- Activity panel: live and historical volume plots --- graph = self.query_one("#usage-history") day_data = _query_daily_graph_data(conn, self._clock_now) @@ -1801,10 +1635,6 @@ class FenrisTuiApp(App): selected_date=self._browse_date, ) - # 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 @@ -1889,7 +1719,9 @@ class FenrisTuiApp(App): main_grid = self.query_one("#main-grid") if local is None: - widget.styles.display = "none" + date = self._browse_date or self._clock_now.astimezone().date().isoformat() + widget.update("%s · local-day evidence unavailable\nW unavailable · R unavailable" % date) + widget.display = True main_grid.remove_class("local-day") return @@ -1902,7 +1734,7 @@ class FenrisTuiApp(App): tz_display = "%s %s" % (local["tz_name"], local["tz_offset"]) text = ( - "[bold]%s[/bold] %s%s\n" + "[bold]%s[/bold] · %s%s\n" " W %.3f GB · R %.3f GB · %.0f%% coverage" % ( local["local_date"], @@ -1918,6 +1750,9 @@ class FenrisTuiApp(App): def _on_graph_drill(self, day: str) -> None: """Load hourly data when the graph enters drill-down mode.""" graph = self.query_one("#usage-history") + self._activity_view = "day" + self._browse_date = day + self._apply_activity_view() with read_status( self.store_path, self._clock_now, query_services=False, ) as (conn, _): @@ -1925,6 +1760,8 @@ class FenrisTuiApp(App): _query_hourly_graph_data(conn, day, self._clock_now) if conn is not None else [] ) + if conn is not None: + self._render_local_day(conn) def _format_headline(self, proj: ProjectionResult) -> str: """Format the lifespan headline (spec §6.11).""" @@ -1940,8 +1777,8 @@ class FenrisTuiApp(App): if proj.regime_days: regime = " · sustained regime: %d days" % proj.regime_days return ( - "[bold]Usage-adjusted theoretical lifespan: [white]%s remaining[/white][/bold]" - "\n If current habits continue%s" % (remaining, regime) + "[bold]%s remaining[/bold]" + "\nUsage-adjusted theoretical lifespan\nIf current habits continue%s" % (remaining, regime) ) def _format_confidence(self, proj: ProjectionResult) -> str: @@ -1981,14 +1818,19 @@ class FenrisTuiApp(App): # --- Actions --- def action_toggle_measure(self) -> None: - """Toggle between written and read volumes on the live graph.""" + """Toggle between written and read volumes in all activity views.""" live = self.query_one("#live-activity") live.toggle_measure() + graph = self.query_one("#usage-history") + graph.measure = live.measure self._refresh() def action_today(self) -> None: """Return to today's live view from any historical browsing.""" self._browse_date = None + self._activity_view = "live" + self._apply_activity_view() + self.query_one("#live-activity").focus(scroll_visible=not self._is_constrained_mode) graph = self.query_one("#usage-history") if graph.view_mode == "hourly": graph._exit_drill() @@ -2015,7 +1857,7 @@ class FenrisTuiApp(App): self._browse_date = target_str if graph.view_mode == "hourly": graph._exit_drill() - self._refresh() + self._set_activity_view("day") def action_prev_day(self) -> None: """Select the previous (older) day.""" @@ -2034,7 +1876,7 @@ class FenrisTuiApp(App): graph._exit_drill() self._browse_date = date_str graph.navigate_to_date(date_str) - self._refresh() + self._set_activity_view("day") def action_go_to_date(self) -> None: """Open the date entry screen.""" diff --git a/tests/test_activity_plot.py b/tests/test_activity_plot.py new file mode 100644 index 0000000..673cb8b --- /dev/null +++ b/tests/test_activity_plot.py @@ -0,0 +1,52 @@ +"""Visible geometry and evidence boundaries of the terminal volume plot.""" +from fenris.activity_plot import VolumePoint, volume_plot +from fenris.themes import get_graph_colors + + +def plot(points, selected=-1, width=48, height=10): + return volume_plot(points, width, height, selected, get_graph_colors("chalktone")) + + +def test_trace_fits_viewport_and_uses_time_not_sample_index(): + points = [VolumePoint(t, v, str(t)) for t, v in ((0, 0), (3, 50), (30, 100))] + text, columns, unit = plot(points) + assert unit == "B" + assert len(text.plain.splitlines()) == 10 + assert all(len(line) <= 48 for line in text.plain.splitlines()) + assert columns[1] - columns[0] < (columns[2] - columns[0]) / 5 + assert any(0x2801 <= ord(c) <= 0x28ff for c in text.plain) + assert not any(c in text.plain for c in "█▒░") + assert "100.00" in text.plain and "0.00" in text.plain + + +def test_gap_breaks_trace_while_measured_zero_stays_on_axis(): + text, columns, _ = plot([ + VolumePoint(0, 0, "00:00"), VolumePoint(1, None, "00:03", "gap"), + VolumePoint(2, 100, "00:06"), + ]) + rows = text.plain.splitlines() + assert "?" in rows[-2] + # No invented intermediate dots on either side of the missing measurement. + for row in rows[:-2]: + assert all(c == " " for c in row[columns[0] + 1:columns[-1]]) + assert 0x2801 <= ord(rows[-3][columns[0]]) <= 0x28ff + + +def test_partial_and_unallocated_totals_are_isolated_and_labelled(): + text, columns, unit = plot([ + VolumePoint(0, 1_000_000, "01", "partial"), + VolumePoint(1, 2_000_000, "02", "unallocated"), + VolumePoint(2, 3_000_000, "03"), + ]) + assert unit == "MB" + assert "~" in text.plain and "u" in text.plain + for row in text.plain.splitlines()[:-2]: + assert all(c == " " for c in row[columns[0] + 1:columns[1]]) + + +def test_many_intervals_and_selection_fit_small_plot_without_losing_points(): + points = [VolumePoint(i, i % 9 * 1_000_000, str(i)) for i in range(180)] + text, columns, _ = plot(points, selected=100, width=32, height=6) + assert len(columns) == len(points) + assert text.plain.splitlines()[-2][columns[100]] == "▼" + assert all(len(row) == 32 for row in text.plain.splitlines()) diff --git a/tests/test_dashboard_design.py b/tests/test_dashboard_design.py new file mode 100644 index 0000000..36029cb --- /dev/null +++ b/tests/test_dashboard_design.py @@ -0,0 +1,176 @@ +"""User-facing navigation, zoom, and plotted volume in the redesigned dashboard.""" +from datetime import datetime, timedelta, timezone +from xml.etree import ElementTree + +import pytest +from test_tui import ( + _insert_baseline, + _insert_day, + _insert_local_day, + _insert_segment, + _open_period, +) + +from fenris.store import init_store +from fenris.tui import FenrisTuiApp + +NOW = datetime(2026, 9, 19, 12, tzinfo=timezone.utc) + + +@pytest.fixture +def dashboard(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "prefs")) + + class Clock(datetime): + @classmethod + def now(cls, tz=None): + return NOW + + monkeypatch.setattr("fenris.tui.datetime", Clock) + monkeypatch.setattr("fenris.status.query_service_state", lambda: { + "boot_enabled": True, "timer_active": True, "last_collect_ok": True, + "last_collect_age_s": 0, "last_collect_reason": None, + }) + conn = init_store(tmp_path / "test.db") + _insert_segment(conn) + _insert_baseline(conn) + _open_period(conn) + for offset in range(18): + date = (NOW - timedelta(days=offset)).date().isoformat() + _insert_day(conn, date, bw=(offset + 1) * 1_000_000_000) + _insert_local_day(conn, date, bw=(offset + 1) * 1_000_000_000, br=2_000_000_000) + for index in range(61): + conn.execute( + "INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id) VALUES (?, ?, ?, ?, ?)", + ((NOW - timedelta(minutes=(60 - index) * 3)).isoformat(), + "/dev/test", index * 1_000_000, index * 2_000_000, 1), + ) + conn.execute( + "INSERT INTO hour_observations (hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) " + "VALUES (?, ?, ?, ?, ?)", ("2026-09-18T12:00:00+00:00", 4_000_000, 8_000_000, 1, 20), + ) + conn.commit() + conn.close() + return FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999) + + +def visible(app): + return " ".join("".join(ElementTree.fromstring(app.export_screenshot()).itertext()).split()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("size", [(140, 44), (80, 24)]) +async def test_live_plot_inspection_zoom_refresh_and_resize(dashboard, size): + app = dashboard + async with app.run_test(size=size) as pilot: + app.on_refresh_tick() + await pilot.pause() + assert app.theme == "fenris-chalktone" + graph = app.query_one("#live-activity") + assert app.focused is graph + await pilot.press("left", "w") + selected = str(app.query_one("#live-readout").render()) + assert "11:54" in selected and "W 0.001 GB" in selected and "R 0.002 GB" in selected + assert "Reads" in str(app.query_one("#live-legend").render()) + await pilot.press("z") + app.on_refresh_tick() + await pilot.pause() + assert str(app.query_one("#live-readout").render()) == selected + assert app.query_one("#activity-panel").region.width == size[0] + for fact in ("Freshness:", "Last collect:", "Boot:", "Timer:", "q Quit TUI"): + assert fact in visible(app) + await pilot.resize_terminal(100, 30) + await pilot.press("escape") + assert str(app.query_one("#live-readout").render()) == selected + assert app._zoomed_panel is None + assert app.query_one("#action-rail").region.bottom <= 30 + + +@pytest.mark.asyncio +async def test_tabs_date_entry_and_hourly_inspection_keep_context(dashboard): + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + await pilot.click("#view-history") + await pilot.press("left", "enter") + assert app.query_one("#activity-tabs").active == "view-day" + assert "2026-09-18" in str(app.query_one("#local-day").render()) + # Hour 23 is selected initially; move to the known hour 12. + await pilot.press(*(["left"] * 11)) + readout = str(app.query_one("#bar-readout").render()) + assert "12:00 UTC" in readout and "W 0.004 GB" in readout + await pilot.press("z", "w") + app.on_refresh_tick() + await pilot.pause() + assert str(app.query_one("#bar-readout").render()) == readout + await pilot.press("g") + await pilot.press(*list("2026-09-17")) + await pilot.press("escape") + assert str(app.query_one("#bar-readout").render()) == readout + assert app._zoomed_panel == "activity-panel" + await pilot.press("escape", "t") + assert app.query_one("#activity-tabs").active == "view-live" + + +@pytest.mark.asyncio +async def test_mouse_inspection_matches_time_axis(dashboard): + app = dashboard + async with app.run_test(size=(100, 36)) as pilot: + await pilot.pause() + graph = app.query_one("#live-activity") + x = graph._point_columns[0] + await pilot.click("#live-render", offset=(x, 1)) + text = str(app.query_one("#live-readout").render()) + assert "09:00 → 09:03 UTC" in text + + +@pytest.mark.asyncio +async def test_theme_control_persists_choice_and_quit_never_pauses(dashboard, monkeypatch): + calls = [] + monkeypatch.setattr(dashboard, "_run_helper", lambda *args: calls.append(args)) + async with dashboard.run_test(size=(80, 24)) as pilot: + await pilot.press("s") + assert dashboard.theme == "fenris-amber" + await pilot.press("q") + assert calls == [] + from fenris.preferences import load_preferences + assert load_preferences()["theme"] == "amber" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("size", [(100, 36), (70, 20)]) +async def test_unallocated_volume_survives_day_with_missing_coverage(dashboard, size): + async with dashboard.run_test(size=size) as pilot: + await pilot.click("#view-history") + graph = dashboard.query_one("#usage-history") + graph.set_data([{ + "day": "2026-09-18", "total_bytes": 2_000_000_000, + "unallocated_bytes": 2_000_000_000, "is_gap": True, + "is_partial": True, + }]) + readout = str(dashboard.query_one("#bar-readout").render()) + assert "W 2.000 GB unallocated" in readout + assert "R unavailable" in readout and "gap" in readout + if size[0] >= 80: + plot = str(dashboard.query_one("#bar-render").render()) + assert any(0x2801 <= ord(c) <= 0x28ff for c in plot) + graph.measure = "read" + graph._refresh() + plot = str(dashboard.query_one("#bar-render").render()) + assert not any(0x2801 <= ord(c) <= 0x28ff for c in plot) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", ["gap", "future"]) +async def test_small_terminal_hour_readout_does_not_invent_zero(dashboard, state): + async with dashboard.run_test(size=(70, 20)) as pilot: + await pilot.click("#view-day") + graph = dashboard.query_one("#usage-history") + graph.set_hour_data([{ + "hour": "2026-09-18T12:00:00+00:00", "local_label": "12", + "is_gap": state == "gap", "is_future": state == "future", + }]) + graph._hourly_selected = 0 + graph._refresh_hourly() + readout = str(dashboard.query_one("#bar-readout").render()) + assert "W unavailable · R unavailable" in readout + assert state in readout and "12:00 UTC" in readout diff --git a/tests/test_issue_79.py b/tests/test_issue_79.py index 80b2e72..894fbef 100644 --- a/tests/test_issue_79.py +++ b/tests/test_issue_79.py @@ -95,7 +95,7 @@ class TestFenrisIdentity: ) async with app.run_test(size=(120, 24)) as pilot: headline = str(app.query_one("#headline-band").render()) - assert "🐺 Fenris by Bongbetic" in headline + assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render()) @pytest.mark.asyncio async def test_titlebox_fallback_without_wolf(self, tmp_path): @@ -108,7 +108,7 @@ class TestFenrisIdentity: async with app.run_test(size=(60, 24)) as pilot: headline = str(app.query_one("#headline-band").render()) # Either shows wolf or fallback - both are acceptable - assert "Fenris by Bongbetic" in headline + assert "Fenris by Bongbetic" in str(app.query_one("#identity").render()) @pytest.mark.asyncio async def test_wolf_never_shows_tofu(self, tmp_path): @@ -140,7 +140,7 @@ class TestFenrisIdentity: async with app.run_test(size=(120, 24)) as pilot: headline = str(app.query_one("#headline-band").render()) # Identity appears once, lifespan is separate data - assert "🐺 Fenris by Bongbetic" in headline + assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render()) assert "remaining" in headline.lower() or "projection" in headline.lower() @@ -174,7 +174,7 @@ class TestSingleMakerCredit: ) async with app.run_test(size=(120, 24)) as pilot: headline = str(app.query_one("#headline-band").render()) - assert "Fenris by Bongbetic" in headline + assert "Fenris by Bongbetic" in str(app.query_one("#identity").render()) @pytest.mark.asyncio async def test_empty_store_no_maker_credit_in_strip(self, tmp_path): @@ -275,7 +275,7 @@ class TestPreservedBehavior: """Separate q Quit TUI rail preserved.""" app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") async with app.run_test(size=(120, 24)) as pilot: - rail = str(app.query_one("#quit-rail").render()) + rail = str(app.query_one("#action-rail").render()) assert "q Quit TUI" in rail @pytest.mark.asyncio @@ -287,11 +287,11 @@ class TestPreservedBehavior: ) async with app.run_test(size=(120, 24)) as pilot: headline = str(app.query_one("#headline-band").render()) - assert "polkit" in headline.lower() + assert "polkit" in str(app.query_one("#auth-notice").render()).lower() # Should clear after first tick await pilot.pause(0.25) headline_after = str(app.query_one("#headline-band").render()) - assert "polkit" not in headline_after.lower() + assert not app.query_one("#auth-notice").display @pytest.mark.asyncio async def test_deliberate_pause_block_preserved(self, tmp_path): diff --git a/tests/test_issue_80.py b/tests/test_issue_80.py index e129f90..5758934 100644 --- a/tests/test_issue_80.py +++ b/tests/test_issue_80.py @@ -93,7 +93,7 @@ class TestPresetLoading: """Themes load from preferences and apply to the TUI.""" @pytest.mark.asyncio - async def test_tui_applies_amber_theme_by_default(self, tmp_path): + async def test_tui_applies_chalktone_theme_by_default(self, tmp_path): """TUI starts with the Amber theme when no preferences exist.""" from fenris.tui import FenrisTuiApp @@ -102,7 +102,7 @@ class TestPresetLoading: app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") async with app.run_test() as pilot: # Theme should be fenris-amber - assert app.theme == "fenris-amber" + assert app.theme == "fenris-chalktone" @pytest.mark.asyncio async def test_tui_applies_nord_theme_from_prefs(self, tmp_path): @@ -180,7 +180,7 @@ class TestKeyBindings: app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") async with app.run_test() as pilot: # Start at amber - assert app.theme == "fenris-amber" + assert app.theme == "fenris-chalktone" # Enter drill mode on the graph graph = app.query_one("#usage-history") @@ -382,7 +382,7 @@ class TestSafePersistence: app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") async with app.run_test() as pilot: # TUI should start with default theme - assert app.theme == "fenris-amber" + assert app.theme == "fenris-chalktone" # Dashboard should be functional headline = str(app.query_one("#headline-band").render()) assert headline is not None @@ -401,7 +401,7 @@ class TestSafePersistence: app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") async with app.run_test() as pilot: # TUI should start without crash - assert app.theme in ("fenris-amber", "fenris-nord", "fenris-high-contrast") + assert app.theme in ("fenris-chalktone", "fenris-amber", "fenris-nord", "fenris-high-contrast") # --------------------------------------------------------------------------- diff --git a/tests/test_issue_92.py b/tests/test_issue_92.py index 4922a22..56a407e 100644 --- a/tests/test_issue_92.py +++ b/tests/test_issue_92.py @@ -29,7 +29,7 @@ from fenris.local_day import ( ) from fenris.tui import ( FenrisTuiApp, - DailyBarGraph, + HistoryGraph, _RANGE_OPTIONS, _RANGE_DEFAULT, ) @@ -111,11 +111,12 @@ def _make_app(tmp_path, clock=None): import fenris.tui as tui_mod from datetime import datetime as _real_datetime - mock_dt = type("MockDatetime", (), { - "now": staticmethod(lambda tz=None: clock), - "strptime": staticmethod(lambda *a, **kw: _real_datetime.strptime(*a, **kw)), - "date": staticmethod(lambda: clock.date()), - })() + class MockDatetime(_real_datetime): + @classmethod + def now(cls, tz=None): + return clock + + mock_dt = MockDatetime patcher = patch.object(tui_mod, "datetime", mock_dt) patcher.start() @@ -262,7 +263,7 @@ class TestArrowInspection: async with app.run_test(size=(100, 40)) as pilot: await pilot.pause() graph = app.query_one("#usage-history") - # Focus the graph widget explicitly + await pilot.press("v", "v") graph.focus() await pilot.pause() day_before = graph._day_data[graph.selected_index].get("day") @@ -281,6 +282,7 @@ class TestArrowInspection: async with app.run_test(size=(100, 40)) as pilot: await pilot.pause() graph = app.query_one("#usage-history") + await pilot.press("v", "v") await pilot.click("#usage-history") # Enter drill-down @@ -329,6 +331,7 @@ class TestTodayBinding: async with app.run_test(size=(100, 40)) as pilot: await pilot.pause() graph = app.query_one("#usage-history") + await pilot.press("v", "v") await pilot.click("#usage-history") # Enter drill @@ -546,6 +549,7 @@ class TestDrillDownData: async with app.run_test(size=(100, 40)) as pilot: await pilot.pause() graph = app.query_one("#usage-history") + await pilot.press("v", "v") await pilot.click("#usage-history") # Enter drill-down diff --git a/tests/test_live_graph.py b/tests/test_live_graph.py index aa3dca7..100f553 100644 --- a/tests/test_live_graph.py +++ b/tests/test_live_graph.py @@ -263,7 +263,7 @@ class TestLiveActivityGraph: await pilot.pause() header = str(graph.query_one("#live-header").render()) assert "1 intervals" in header - assert "w Toggle W/R" in header + assert "w W/R" in str(app.query_one("#activity-tools").render()) # --------------------------------------------------------------------------- diff --git a/tests/test_preferences.py b/tests/test_preferences.py index c6449bd..2ef84d7 100644 --- a/tests/test_preferences.py +++ b/tests/test_preferences.py @@ -3,7 +3,7 @@ Covers: - Preference load/save with safe defaults - XDG_CONFIG_HOME user-scoped persistence - - Amber default theme, normal-motion default + - Chalktone default theme, normal-motion default - Invalid/unreadable/unwritable preference data does not crash - Theme presets: Amber, Nord, High Contrast - Reduced motion preference persistence @@ -72,9 +72,9 @@ class TestPreferencePath: class TestDefaults: """When no preference file exists, defaults are returned.""" - def test_default_theme_is_amber(self): + def test_default_theme_is_chalktone(self): """Amber is the default theme preset.""" - assert DEFAULT_THEME == "amber" + assert DEFAULT_THEME == "chalktone" def test_default_reduced_motion_is_false(self): """Normal motion is the default.""" @@ -82,13 +82,13 @@ class TestDefaults: def test_valid_themes_are_all_presets(self): """Three valid presets exist.""" - assert VALID_THEMES == {"amber", "nord", "high_contrast"} + assert VALID_THEMES == {"chalktone", "amber", "nord", "high_contrast"} def test_load_returns_defaults_when_no_file(self, tmp_path): """Missing preference file returns safe defaults.""" with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}): prefs = load_preferences() - assert prefs["theme"] == "amber" + assert prefs["theme"] == "chalktone" assert prefs["reduced_motion"] is False @@ -151,7 +151,7 @@ class TestSafeFailures: with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): prefs = load_preferences() - assert prefs["theme"] == "amber" + assert prefs["theme"] == "chalktone" assert prefs["reduced_motion"] is False def test_unknown_theme_returns_default(self, tmp_path): @@ -165,7 +165,7 @@ class TestSafeFailures: with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): prefs = load_preferences() - assert prefs["theme"] == "amber" + assert prefs["theme"] == "chalktone" def test_missing_keys_get_defaults(self, tmp_path): """Partial preference file fills in missing keys.""" @@ -218,7 +218,7 @@ class TestSafeFailures: with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): prefs = load_preferences() - assert prefs["theme"] == "amber" + assert prefs["theme"] == "chalktone" def test_wrong_type_for_reduced_motion(self, tmp_path): """Non-boolean reduced_motion falls back to default.""" diff --git a/tests/test_status_read.py b/tests/test_status_read.py index 59e0e78..ca55039 100644 --- a/tests/test_status_read.py +++ b/tests/test_status_read.py @@ -67,7 +67,7 @@ async def test_cli_tui_acquisition_parity(tmp_path, kind, service): assert not app.query_one("#main-grid").has_class("paused") await pilot.resize_terminal(60, 18) await pilot.pause() - assert phrase in str(app.query_one("#constrained-summary").render()).lower() + assert phrase in str(app.query_one("#live-render").render()).lower() for text in (cli, strip): assert ("boot: enabled" if service == ACTIVE else "boot: disabled" if service == DISABLED else "boot: unknown") in text assert ("timer: active" if service == ACTIVE else "timer: inactive" if service == DISABLED else "timer: unknown") in text diff --git a/tests/test_themes.py b/tests/test_themes.py index 4cebbef..ede1457 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -2,7 +2,7 @@ Covers: - Three valid presets: Amber, Nord, High Contrast - - Amber is the default with amber graph role + - Chalktone is the default; Amber retains its amber graph role - Theme roles for graph rendering (allocated, unallocated, gap, zero, partial) - Status semantic colours/glyphs/text always win over theme - Global action reachability and focus contrast in every preset @@ -29,13 +29,13 @@ from fenris.themes import ( class TestThemeRegistry: """All three presets are registered with correct names.""" - def test_three_themes_registered(self): - """Exactly three themes exist.""" - assert len(THEMES) == 3 + def test_four_themes_registered(self): + """All four presets are registered.""" + assert len(THEMES) == 4 def test_theme_names(self): """Theme names are amber, nord, high_contrast.""" - assert THEME_NAMES == {"amber", "nord", "high_contrast"} + assert THEME_NAMES == {"chalktone", "amber", "nord", "high_contrast"} def test_get_theme_valid(self): """get_theme returns a Theme for each valid name.""" @@ -44,14 +44,14 @@ class TestThemeRegistry: theme = get_theme(name) assert isinstance(theme, Theme) - def test_get_theme_invalid_returns_amber(self): + def test_get_theme_invalid_returns_chalktone(self): """Unknown theme name returns the amber theme.""" theme = get_theme("nonexistent") - assert theme.name == "fenris-amber" + assert theme.name == "fenris-chalktone" # --------------------------------------------------------------------------- -# Amber theme tests (default, amber graph role) +# Amber theme tests (amber graph role) # --------------------------------------------------------------------------- class TestAmberTheme: @@ -179,7 +179,7 @@ class TestGraphColorRoles: assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"] def test_zero_role_is_dim(self): - """Zero-usage bars use a dim/subtle colour in all themes.""" + """Zero-usage markers use a dim/subtle colour in all themes.""" for name in THEME_NAMES: colors = get_graph_colors(name) # Zero should be distinct from allocated diff --git a/tests/test_tui.py b/tests/test_tui.py index 7c0f3ed..6a975d7 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -40,19 +40,13 @@ from fenris.status import ( ) from fenris.tui import ( FenrisTuiApp, - DailyBarGraph, + HistoryGraph, _format_remaining, _query_drive_health, _query_daily_graph_data, _query_hourly_graph_data, _RANGE_OPTIONS, _RANGE_DEFAULT, - _BAR_HEIGHT, - _GLYPH_ALLOCATED, - _GLYPH_UNALLOCATED, - _GLYPH_GAP, - _GLYPH_ZERO, - _GLYPH_PARTIAL, _MIN_WIDTH, _MIN_HEIGHT, ) @@ -365,15 +359,15 @@ class TestDenseScreen: }): async with app.run_test(): strip = str(app.query_one("#service-strip").render()).lower() - rail = str(app.query_one("#quit-rail").render()) - usage = app.query_one("#usage-history") + rail = str(app.query_one("#action-rail").render()) + usage = app.query_one("#activity-panel") service = app.query_one("#service-strip") - quit_rail = app.query_one("#quit-rail") + quit_rail = app.query_one("#action-rail") assert "continuity" in strip assert "monitoring: active in background · persists across reboots" in strip - assert "r resume — enable monitoring and future boots" in strip + assert "r Resume" in str(app.query_one("#action-rail").render()) assert "q quit" not in strip - assert rail == "q Quit TUI" + assert "q Quit TUI" in rail assert usage.region.y < service.region.y < quit_rail.region.y assert usage.region.bottom <= service.region.y assert service.region.bottom <= quit_rail.region.y @@ -405,13 +399,12 @@ class TestDenseScreen: main_grid = app.query_one("#main-grid") assert str(main_grid.styles.layout) == "" assert main_grid.has_class("paused") - assert len(main_grid.styles.grid_rows) == 6 banner = str(paused_banner.render()).lower() assert "monitoring: paused — deliberate disable" in banner assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner - assert paused_banner.region.height >= 5 - assert paused_banner.region.y < app.query_one("#usage-history").region.y - assert app.query_one("#usage-history").region.bottom <= app.query_one( + assert paused_banner.region.height >= 2 + assert paused_banner.region.y < app.query_one("#activity-panel").region.y + assert app.query_one("#activity-panel").region.bottom <= app.query_one( "#service-strip" ).region.y screenshot = app.export_screenshot() @@ -423,11 +416,6 @@ class TestDenseScreen: assert "monitoring: does not start on next boot" in str( app.query_one("#service-strip").render() ).lower() - dashboard_scroll = app.query_one("#dashboard-scroll") - assert dashboard_scroll.max_scroll_y > 0 - dashboard_scroll.focus() - await pilot.press("end") - assert dashboard_scroll.scroll_y == dashboard_scroll.max_scroll_y footer_text = " ".join( "".join( ElementTree.fromstring(app.export_screenshot()).itertext() @@ -490,23 +478,23 @@ class TestDenseScreen: async with app.run_test() as pilot: headline = str(app.query_one("#headline-band").render()) # Identity now shows Fenris by Bongbetic (issue #79) - assert "Fenris by Bongbetic" in headline - assert auth_notice in headline + assert "Fenris by Bongbetic" in str(app.query_one("#identity").render()) + assert auth_notice in str(app.query_one("#auth-notice").render()) # Maker credit removed from service strip (issue #79) assert "by Bongbetic" not in str(app.query_one("#service-strip").render()) await pilot.pause(0.25) - assert auth_notice not in str(app.query_one("#headline-band").render()) + assert not app.query_one("#auth-notice").display await pilot.pause(0.25) - assert auth_notice not in str(app.query_one("#headline-band").render()) + assert not app.query_one("#auth-notice").display fresh_app = FenrisTuiApp( store_path=tmp_path / "nonexistent.db", refresh_interval_s=0.2, ) async with fresh_app.run_test(): - assert auth_notice in str(fresh_app.query_one("#headline-band").render()) + assert auth_notice in str(fresh_app.query_one("#auth-notice").render()) # --------------------------------------------------------------------------- @@ -600,7 +588,7 @@ class TestFirstRun: strip = str(app.query_one("#service-strip").render()) assert "no observations yet" in headline.lower() assert "r resume — enable monitoring and future boots" in headline.lower() - assert "r resume — enable monitoring and future boots" in strip.lower() + assert "r resume" in str(app.query_one("#action-rail").render()).lower() # --------------------------------------------------------------------------- @@ -864,15 +852,15 @@ class TestQueryHourlyGraphData: # --------------------------------------------------------------------------- -# DailyBarGraph widget unit tests (issue #75) +# HistoryGraph widget unit tests (issue #75) # --------------------------------------------------------------------------- -class TestDailyBarGraph: +class TestHistoryGraph: def test_empty_data(self, tmp_path): """Empty data shows awaiting message.""" app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") # We test the widget directly via the app's compose - graph = DailyBarGraph() + graph = HistoryGraph() # Simulate setting empty data from textual.app import App as TextualApp class _TestApp(TextualApp): @@ -884,14 +872,14 @@ class TestDailyBarGraph: def test_range_default(self): """Default range is 14 days.""" - graph = DailyBarGraph() + graph = HistoryGraph() assert graph.range_days == _RANGE_DEFAULT assert graph.selected_index == -1 assert graph.view_mode == "daily" def test_trim_to_range(self): """Data is trimmed to the selected range.""" - graph = DailyBarGraph() + graph = HistoryGraph() data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100} for d in range(1, 31)] graph._all_day_data = data @@ -902,7 +890,7 @@ class TestDailyBarGraph: def test_range_all_fits(self): """When data fits within range, all days are shown.""" - graph = DailyBarGraph() + graph = HistoryGraph() data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100} for d in range(1, 8)] graph._all_day_data = data @@ -911,7 +899,7 @@ class TestDailyBarGraph: assert len(graph._day_data) == 7 def test_scale_includes_allocated_and_unallocated_writes(self): - graph = DailyBarGraph() + graph = HistoryGraph() graph._all_day_data = [{ "day": "2026-09-16", "total_bytes": 800, @@ -927,7 +915,7 @@ class TestDailyBarGraph: # --------------------------------------------------------------------------- class TestBarGraphTUI: - """Headless tests for the interactive bar graph.""" + """Headless tests for the interactive history plot.""" @pytest.mark.asyncio async def test_graph_renders_with_data(self, tmp_path): @@ -943,20 +931,21 @@ class TestBarGraphTUI: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("v", "v") graph = app.query_one("#usage-history") - assert isinstance(graph, DailyBarGraph) + assert isinstance(graph, HistoryGraph) assert len(graph._day_data) > 0 # Legend should be visible legend = str(app.query_one("#bar-legend").render()) - assert "Alloc" in legend + assert "Unalloc" in legend assert "Zero" in legend range_label = str(app.query_one("#bar-range").render()) assert "14 days" in range_label assert "UTC" in range_label plotted = str(app.query_one("#bar-render").render()) - assert "Writes (" in plotted - assert "Day UTC" in plotted - assert "→" in plotted + assert "Writes (" in legend + assert "UTC" in range_label + assert "─" in plotted @pytest.mark.asyncio async def test_arrow_selection(self, tmp_path): @@ -972,6 +961,7 @@ class TestBarGraphTUI: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("v", "v") graph = app.query_one("#usage-history") app.set_focus(graph) await pilot.pause() @@ -1002,6 +992,7 @@ class TestBarGraphTUI: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("v", "v") graph = app.query_one("#usage-history") app.set_focus(graph) await pilot.pause() @@ -1043,6 +1034,7 @@ class TestBarGraphTUI: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("v", "v") graph = app.query_one("#usage-history") app.set_focus(graph) await pilot.pause() @@ -1079,6 +1071,7 @@ class TestBarGraphTUI: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("v", "v") graph = app.query_one("#usage-history") app.set_focus(graph) await pilot.pause() @@ -1114,8 +1107,9 @@ class TestBarGraphTUI: """Empty store shows awaiting message in graph.""" app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("v", "v") graph = app.query_one("#usage-history") - assert isinstance(graph, DailyBarGraph) + assert isinstance(graph, HistoryGraph) render = str(app.query_one("#bar-render").render()) assert "awaiting" in render.lower() @@ -1124,8 +1118,9 @@ class TestBarGraphTUI: """Graph pane has a border title.""" app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("v", "v") graph = app.query_one("#usage-history") - assert graph.border_title == "Usage history" + assert app.query_one("#activity-panel").border_title == "Drive activity" @pytest.mark.asyncio async def test_glyphs_in_legend(self, tmp_path): @@ -1141,13 +1136,11 @@ class TestBarGraphTUI: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("v", "v") await pilot.pause() legend = str(app.query_one("#bar-legend").render()) - assert _GLYPH_ALLOCATED in legend - assert _GLYPH_UNALLOCATED in legend - assert _GLYPH_GAP in legend - assert _GLYPH_ZERO in legend - assert _GLYPH_PARTIAL in legend + for meaning in ("Writes", "Unalloc", "Gap", "Zero", "Partial"): + assert meaning in legend # --------------------------------------------------------------------------- @@ -1172,6 +1165,7 @@ class TestConstrainedLayout: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("v", "v") # All four regions should be visible assert app.query_one("#headline-band") is not None assert app.query_one("#usage-history") is not None @@ -1180,8 +1174,8 @@ class TestConstrainedLayout: # 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" + summary = app.query_one("#bar-render") + assert "Graph needs" not in str(summary.render()) @pytest.mark.asyncio async def test_constrained_below_80_width(self, tmp_path): @@ -1198,13 +1192,14 @@ class TestConstrainedLayout: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(79, 24)) as pilot: + await pilot.press("v", "v") # 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 = app.query_one("#bar-render") summary_text = str(summary.render()) assert "Graph needs ≥80×24" in summary_text assert "days" in summary_text @@ -1225,10 +1220,11 @@ class TestConstrainedLayout: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 23)) as pilot: + await pilot.press("v", "v") # Should be in constrained mode assert app._is_constrained_mode # Constrained summary should be visible - summary = app.query_one("#constrained-summary") + summary = app.query_one("#bar-render") summary_text = str(summary.render()) assert "Graph needs ≥80×24" in summary_text @@ -1247,11 +1243,12 @@ class TestConstrainedLayout: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(79, 24)) as pilot: + await pilot.press("v", "v") # Start in constrained mode assert app._is_constrained_mode graph = app.query_one("#usage-history") - # Select a day while constrained - graph.selected_index = 5 + # Select a day through the visible graph while constrained. + await pilot.press(*(["left"] * 8)) # Resize to normal — pilot.resize_terminal changes terminal size await pilot.resize_terminal(80, 24) @@ -1285,6 +1282,7 @@ class TestConstrainedLayout: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("v", "v") # Start in normal mode assert not app._is_constrained_mode graph = app.query_one("#usage-history") @@ -1303,7 +1301,7 @@ class TestConstrainedLayout: # Should be in constrained mode assert app._is_constrained_mode # Constrained summary should show selected day context - summary = app.query_one("#constrained-summary") + summary = app.query_one("#bar-render") summary_text = str(summary.render()) assert "Graph needs ≥80×24" in summary_text # Selected day context should survive @@ -1314,8 +1312,9 @@ class TestConstrainedLayout: """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: + await pilot.press("v", "v") assert app._is_constrained_mode - summary = app.query_one("#constrained-summary") + summary = app.query_one("#bar-render") summary_text = str(summary.render()) assert "Graph needs ≥80×24" in summary_text assert "awaiting" in summary_text.lower() @@ -1335,24 +1334,25 @@ class TestConstrainedLayout: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(79, 24)) as pilot: + await pilot.press("v", "v") # 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 + assert app.query_one("#action-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()) + strip = str(app.query_one("#action-rail").render()) assert "p Pause" in strip assert "r Resume" in strip - assert "q quit" not in strip # Quit is in quit-rail + assert "q Quit TUI" in strip # Quit rail should be visible - rail = str(app.query_one("#quit-rail").render()) + rail = str(app.query_one("#action-rail").render()) assert "Quit" in rail @pytest.mark.asyncio @@ -1368,6 +1368,7 @@ class TestConstrainedLayout: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(79, 24)) as pilot: + await pilot.press("v", "v") 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() @@ -1387,6 +1388,7 @@ class TestConstrainedLayout: app = FenrisTuiApp(store_path=tmp_path / "test.db") async with app.run_test(size=(79, 24)) as pilot: + await pilot.press("v", "v") # Quit should still work await pilot.press("q") assert not app.is_running @@ -1396,6 +1398,7 @@ class TestConstrainedLayout: """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: + await pilot.press("v", "v") assert app._is_constrained_mode # Toggle theme await pilot.press("t") @@ -1415,8 +1418,8 @@ class TestConstrainedLayout: assert _MIN_HEIGHT == 24 def test_is_constrained_with_terminal_width(self, tmp_path): - """DailyBarGraph._is_constrained checks terminal width.""" - graph = DailyBarGraph() + """HistoryGraph._is_constrained checks terminal width.""" + graph = HistoryGraph() # Widget-level check (no terminal width) assert graph._is_constrained() is False # Terminal width below minimum