SIGN IN SIGN UP

feat: soft auto-range and trend cues for single-row sparklines (#276)

## Summary

Part 2 of the sparkline-readability chain (#272 → #273 → #274). The single-row braille sparklines moved to fixed axes in #272 for stability, but a 4-dot column parks typical operating values inside a single quantization band: a GPU near 41% utilization always renders at level 1 of 4 on a 0..100 axis, so real variation is invisible. This PR restores visible texture for every single-row sparkline by softly zooming the axis into the visible history window, with guardrails so it does not reintroduce the flapping the fixed axes were built to avoid, and adds a per-metric trend glyph to the header.

## What changed

- `src/ui/scale.rs`: new pure `soft_range(history, min_span, grid, domain) -> (min, max)` helper. It takes the finite window min/max, enforces a per-metric minimum span (expanding symmetrically around the data center and sliding back inside the domain when an edge is crossed), rounds the bounds outward to a coarse grid (stateless hysteresis), and clamps to the hard domain. No new mutable state anywhere. Degenerate inputs (empty or all-non-finite history, `min_span` wider than the domain, `grid <= 0`, inverted or zero-width domain) degrade gracefully and never panic or invert the axis.
- `src/ui/scale.rs`: exported per-metric parameters as documented constants (`PERCENT_SOFT_MIN_SPAN` 20 / `PERCENT_SOFT_GRID` 5 / `PERCENT_DOMAIN` (0,100); `TEMP_SOFT_MIN_SPAN` 10 / `TEMP_SOFT_GRID` 5; `ANE_SOFT_MIN_SPAN` 2 / `ANE_SOFT_GRID` 1) plus `power_soft_min_span` (max(2 W, 20% of the ceiling)) and `power_soft_grid` (1 W ≤ 20 W, 5 W ≤ 100 W, else 25 W) derived from the fixed power ceiling. The existing fixed-domain helpers (`temp_range`, `power_range`, `ane_range`) are retained and now double as the domain argument to `soft_range`; they stay available for the multi-row fixed-axis graphs coming in #274. Temperature's soft domain uses a 0°C floor (not `TEMP_FLOOR_C`) so a genuinely cool sensor can zoom below 30°C.
- `src/ui/scale.rs` (follow-up fix): `soft_range` now pre-clamps the raw history min/max into the hard domain before minimum-span enforcement. Without this, a history lying entirely outside the domain (e.g. a mis-scaled remote reading) whose raw span already met `min_span` skipped the span-enforcement step, and the final domain clamp alone collapsed the axis to a zero-width range outside the domain instead of rebuilding a proper in-domain window. Covered by a new `soft_range_out_of_domain_data_stays_inside_domain` test; `soft_range_clamps_to_temperature_domain`'s expected bounds were updated to match the corrected (wider, in-domain) axis.
- `src/ui/local_header.rs`: all five header sparklines (CPU %, GPU %, RAM %, Pwr, Tmp) switched from fixed ranges to soft ranges. Added a one-cell trend glyph (`↑ ↗ → ↘ ↓`) immediately after each latest value, coloured in the metric's theme colour, via a pure `trend_glyph` helper. The slope is the delta between the latest sample and the sample ~5 back (oldest when the history is shorter), classified with per-metric thresholds (% 1.0/5.0, temp 0.5/2.0, power 0.2/1.0). Fewer than two samples renders a space so the columns stay aligned; inter-metric spacing is unchanged.
- `src/ui/gpu_sparkline_panel.rs`: the GPU Util / GPU Mem / GPU Temp / ANE / Pkg Power rows use soft ranges, and each row's `scale_badge` now reports the actual soft range in use (e.g. `40-60`) instead of the fixed axis.
- `src/ui/remote_sparkline_panel.rs`: the GPU/CPU util, memory, and temperature rows switched to soft ranges the same way.
- Module docs in `scale.rs`, `local_header.rs`, `gpu_sparkline_panel.rs`, and `remote_sparkline_panel.rs` updated to describe the fixed-domain + soft-zoom relationship and the trend glyph.
- Tests: `test_build_rows_apple_silicon` updated to assert the new soft-range badges (computed by hand from the fixture histories) since the axis semantics intentionally changed. Unit tests cover min-span enforcement per metric kind including near-domain-edge expansion (data hugging 0 and 100), grid-rounding stability under jitter, hard-domain clamping (%, temperature, power), out-of-domain history, degenerate inputs, soft-range badge formatting, and trend classification for all five glyphs plus the insufficient-history case. A render-level test (`test_draw_local_header_bar_renders_trend_glyphs`) was added to confirm the trend glyph actually lands in the right metric's column of the rendered output, not just that the pure classification helper returns the right glyph in isolation.

## Test plan

- [x] `cargo test --lib ui::scale` (38 passed)
- [x] `cargo test --lib ui::local_header` (22 passed)
- [x] `cargo test --lib ui::gpu_sparkline_panel` (20 passed)
- [x] `cargo test --lib ui::remote_sparkline_panel` (8 passed)
- [x] `cargo check --lib --tests` (clean)
- [x] `cargo clippy --lib --tests -- -D warnings` (clean)
- [x] `cargo fmt --check` (clean)

Refs #272

Closes #273
J
Jeongkyu Shin committed
e27cd09eeb7981bedfa7c3b1bc283106430a42d8
Parent: d3306f6
Committed by GitHub <noreply@github.com> on 7/20/2026, 1:28:59 AM