diff --git a/docs/superpowers/plans/2026-09-28-plot-dpi-grid-follow.md b/docs/superpowers/plans/2026-09-28-plot-dpi-grid-follow.md new file mode 100644 index 0000000..c63b629 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-plot-dpi-grid-follow.md @@ -0,0 +1,152 @@ +# `plot` — DPI-consistent sizing, grid classes, and live follow with zoom and mouse panning + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make chrome render at a consistent physical size on any display DPI; split the grid into independently styled major and minor classes tied to the tick classes; and make wheel zoom and mouse panning work *with* live data following instead of destroying it. + +**Architecture:** Three separable changes. A `Style::ui_scale` turns every style metric into a logical unit converted to device pixels at one point per consumer (§10). Grid classes gain per-class width and visibility (§11). The x axis gains an explicit `XFollow` state with a live-edge-relative window, so zoom multiplies the span and panning shifts an offset, neither of which disarms following (§12). + +**Spec:** `docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md` sections 10, 11, 12. Read the relevant section before each task. + +**Priority:** these three come *before* the remaining tasks of `2026-09-28-plot-interaction-and-fonts.md` (drag-and-drop regions, `Figure`, the demo/verification pass, the optional TrueType target). Task A changes the units every later visual task is written in, so it goes first; Task B is small and independent; Task C unblocks the interaction work. + +## Global Constraints + +- `plot_core` and every public header under `include/plot/` stay free of third-party code. +- Public headers MUST be self-contained; the configure-time umbrella and self-containment checks must keep passing. +- C++20; our targets stay clean under `-Wall -Wextra -Wpedantic -Wconversion -Wshadow`, never suppressed per-file. +- No exceptions across the public API boundary: `bool`/`Status` plus out-params. No `Result`. +- **Data-space geometry never scales.** Only chrome does. `dense_threshold_spp` stays a *device*-pixel ratio (it governs rasterisation density, not physical size); everything a user perceives as a size — font px, stroke widths, paddings, swatch and marker sizes, tick spacing — is logical. +- The renderer clears only its viewport region via scissor and hands back host GL state; `renderer.hpp`'s documented residue list stays accurate. +- OpenGL 3.3 core baseline. +- The 156 existing test cases keep passing; any signature change is called out in the task report. +- Every task ends with `cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` green and warning-clean, and commits with the message in its own section. + +--- + +### Task A: UI scale — consistent physical size across DPI + +**Files:** +- Modify: `include/plot/render/style.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `src/render/renderer.cpp`, `include/plot/render/renderer.hpp`, `include/plot/input/controller.hpp` (doc), `src/input/controller.cpp` +- Modify the four adapters: `adapters/glfw/glfw_host.cpp`, `adapters/imgui/imgui_plot.cpp`, `adapters/qt6/qt_plot_widget.cpp`, `adapters/gtk4/gtk_plot_area.cpp` +- Test: `tests/test_render_smoke.cpp`, `tests/test_plot.cpp`, `tests/test_controller.cpp` + +**Interfaces:** +- Consumes: `Style`, `PlotLayout`, `Renderer::draw`, the adapters' existing device-scale computations. +- Produces: `Style::ui_scale` (device px per logical unit) and `Style::device(f32 logical) -> f32`; `Plot::set_ui_scale(f32)` / `Plot::ui_scale()`; `Renderer::draw()` propagating `style().ui_scale` onto the plot before layout. + +- [ ] **Step 1: Classify every metric as logical or device, in the header** + +Go through `Style` field by field and mark each one in its comment as logical (scaled) or device (not). The load-bearing distinctions: `axis_font_px`, `line_width_px`, `grid_*_width`, `axis_*` paddings, `tick_min_px`, cursor/marker/swatch sizes → **logical**; `dense_threshold_spp` → **device**, because it is samples per rasterised pixel, not a physical size. Write the classification into the header before changing any behaviour, so the reviewer can check the code against a written rule rather than against taste. + +- [ ] **Step 2: Route every consumer through `device()`** + +`PlotLayout`'s strip and title sizing, the legend, region chrome and the label paddings go through `style.device(...)` (the plot reads `ui_scale()`). The renderer converts stroke widths, font px, AA fringe and marker sizes through the same call. `dense_threshold_spp` is used raw. + +- [ ] **Step 3: Propagate from the host** + +`Renderer::draw()` sets `plot.set_ui_scale(style_.ui_scale)` before `plot.layout()`. Each adapter sets `renderer.style().ui_scale` from the scale it already computes — GLFW from `framebuffer/window`, Qt from `devicePixelRatioF()`, GTK from the ratio of the `::resize` device size to the widget's logical size (do not use `gtk_widget_get_scale_factor()`, which is the ceiling under fractional scaling), ImGui from `io.DisplayFramebufferScale`. + +- [ ] **Step 4: The controller's tolerances** + +Pointer coordinates reach the controller in device pixels, so its grab radius must be `logical * ui_scale` or grabbing becomes physically harder on a high-DPI display. The controller already holds the plot, so read `plot.ui_scale()`; do not add a parallel setter that can drift. Same for the box-zoom minimum-size click threshold. + +- [ ] **Step 5: Tests** + +- `logical_metrics_are_dpi_independent` — render the same plot at `ui_scale` 1.0 and 2.0 with a 2x framebuffer/viewport and assert the axis label's **device** ink extent doubles while its logical extent (device ÷ scale) is unchanged, and that the label strip's width in logical units is identical across the two. +- `dense_threshold_stays_a_device_ratio` — the LOD level chosen for a fixed samples-per-device-pixel is identical at 1.0 and 2.0, proving the threshold was not scaled. +- `grab_radius_scales_with_dpi` — a press exactly at the logical grab distance from a cursor grabs it at both scales. +- `ui_scale_defaults_to_one` — nothing changes for existing callers. + +- [ ] **Step 6: Verify and commit** + +Run the suite, then run the GLFW and Qt demos headlessly at `ui_scale` 1.0 and at `QT_SCALE_FACTOR=1.25` / `GDK_SCALE=2` where available, and report the axis-text pixel count and the label strip width in **device** pixels at each scale, plus the same figures normalised to logical units. The logical figures must be equal; the device ones must scale. +```bash +git add -A && git commit -m "feat(render): logical style metrics with a UI scale, so chrome has a consistent physical size" +``` + +--- + +### Task B: Major and minor grid as independent styled classes + +**Files:** +- Modify: `include/plot/render/style.hpp`, `include/plot/plot/grid.hpp`, `src/plot/grid.cpp`, `src/render/renderer.cpp` +- Test: `tests/test_plot.cpp`, `tests/test_render_smoke.cpp` + +**Interfaces:** +- Consumes: the tick set (`plot::Tick{f64 value, bool major, std::string label}` and `generate_ticks`'s `include_minors`), `Style` from Task A. +- Produces: `Style::grid_major_width`, `Style::grid_minor_width` (logical), `Style::grid_major_visible`, `Style::grid_minor_visible`; grid segments tagged with their class. + +- [ ] **Step 1: Per-class style** + +Add the four fields with the documented defaults (both visible, both 1.0 logical px). Widths go through `Style::device()`; colours are unchanged and still per class. + +- [ ] **Step 2: One source for line positions, and one for visibility** + +Positions are already single-sourced: `Grid::build()` consumes the tick set and tags each `GridLine` with its `major` flag, and `Axis::refresh(px, min_px, include_minors = true)` already emits minors by default, so a labeled major tick and a major gridline coincide today. Keep that; do not introduce a second spacing derivation. + +**But there are two places to fix, both found by inspection before starting:** +- `src/render/renderer.cpp:550` hardcodes the stroke width as `1.0f` for *both* classes. Route `style.device(style.grid_major_width)` / `grid_minor_width` through per class. +- `include/plot/plot/grid.hpp:24` already carries `bool show_minor = true`, which is a **second source of truth** for visibility alongside the `Style::grid_minor_visible` you are adding. Two switches for one behaviour is the defect class this project keeps hitting. Do a clean cutover: `Style::grid_*_visible` is the only switch, `Grid::show_minor` is deleted, and every caller is migrated. Report any caller you find and why it had the flag. + +Route the class through to the renderer so colour and width are selected per class, and confirm the grid is still built from the primary axis pair's transforms, as the existing comment at `renderer.cpp:537-540` states. + +- [ ] **Step 3: Visibility is a skip, not alpha zero** + +Check visibility before staging, so a disabled class stages no geometry at all. + +- [ ] **Step 4: Tests** + +- `major_grid_lines_coincide_with_major_ticks` — for several ranges, every major gridline's x equals some major tick's x and vice versa; same for minor. +- `disabling_the_minor_grid_removes_its_colour` — with `grid_minor_visible = false` the minor grid colour has zero pixels and the major colour's count is unchanged. +- `disabling_the_major_grid_leaves_the_minor_grid` — the symmetric case. +- `grid_widths_are_per_class_and_scale_with_dpi` — a 2:1 width ratio produces a 2:1 stroke-pixel ratio; and both double in device pixels when `ui_scale` doubles while their logical widths are unchanged. + +- [ ] **Step 5: Commit** +```bash +git add -A && git commit -m "feat(render): independent major and minor grid classes tied to the tick set" +``` + +--- + +### Task C: Live follow with relative zoom and mouse panning + +**Files:** +- Modify: `include/plot/plot/axis.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/input/controller.hpp`, `src/input/controller.cpp`, `src/render/renderer.cpp` (only if the live edge needs exposing) +- Test: `tests/test_plot.cpp`, `tests/test_controller.cpp` + +**Interfaces:** +- Consumes: `Plot`'s axis ranges and transforms, `data::Series::x_bounds()`, the controller's existing wheel and drag paths. +- Produces: `plot::XFollow{Off, Live}`; `Plot::set_x_follow(u32, XFollow)`, `Plot::x_follow(u32)`, `Plot::set_x_offset(u32, f64)`, `Plot::x_offset(u32)`; the live-window recompute; the controller's follow-preserving zoom and pan. + +- [ ] **Step 1: The follow state and the live window** + +Add `XFollow` and the four accessors. `set_x_offset` clamps to `[0, retained_window_span]` (the retained window is what the series' `x_bounds()` reports for a streaming series, so the limit is honest about what is still in the ring). While `x_follow(axis) == Live`, the visible range is recomputed from the series' newest x every time layout is computed: `[newest - offset - span, newest - offset]`, where `span` is the span the axis had. A plot with no trace on that axis, or a non-streaming series, keeps its stored range. `XFollow::Off` is the default and changes nothing. + +- [ ] **Step 2: Relative zoom** + +The wheel path multiplies the span: `span *= factor`, never a fixed increment, so notches compound. Under `Live`, after scaling, adjust `offset` so the anchored data coordinate keeps its screen position: the anchor is the pointer's data x when the pointer is inside `axes`, and the live edge otherwise. Following stays armed. Under `Off`, the behaviour is exactly today's anchor-at-pointer zoom. + +- [ ] **Step 3: Mouse panning under live** + +The left-drag pan path, under `Live`, shifts `offset` by the data delta of the drag and leaves the span alone, clamped at `0` so the view cannot pass the newest sample. Under `Off` the existing pan is unchanged. In both cases, do **not** clear the follow state — and take care that the current code's `set_x_range` call, which clears `auto_scale`, is not the path used under `Live`, or that `Live` re-arms itself after it. + +- [ ] **Step 4: Returning to live** + +`auto_fit` under `Live` sets `offset = 0` and fits the span to the retained window. It leaves `Live` armed. Document it on `auto_fit_x`. + +- [ ] **Step 5: Tests** + +- `live_window_tracks_the_newest_sample` — push samples through a `StreamSeries`, call the update path each frame, assert the range's right edge equals the newest x every frame. +- `wheel_zoom_keeps_following` — zoom while `Live`, assert the span shrank, the right edge is still the newest sample, and `x_follow` is still `Live`. This is the regression the section exists for; it must fail against the current code, where the zoom clears `auto_scale`. +- `wheel_zoom_is_relative` — two half-notches produce the same span as one full notch (within a tolerance), proving multiplication rather than addition. +- `mouse_pan_shifts_the_offset_and_preserves_the_span` — drag, assert the span is unchanged, the offset moved by the data delta, and `x_follow` is still `Live`. +- `pan_cannot_pass_the_live_edge` — a long drag toward the live edge clamps `offset` at 0 rather than inverting the range. +- `offset_survives_new_samples` — with a non-zero offset, wait for more data and assert the window advanced by the same amount, keeping the same data in view. +- `auto_fit_returns_to_the_live_edge` — from a non-zero offset, `auto_fit` sets offset 0, leaves `Live` armed, and fits the retained window. +- `a_static_plot_is_unaffected` — `XFollow::Off` behaves exactly as before for zoom and pan. + +- [ ] **Step 6: Commit** +```bash +git add -A && git commit -m "feat(input): live x follow with relative zoom and mouse panning" +``` diff --git a/docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md b/docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md index e6c64e1..9f7f4aa 100644 --- a/docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md +++ b/docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md @@ -209,3 +209,55 @@ These are inherited from the predecessor spec and are non-negotiable; every task - **Adwaita Mono's licence** is OFL 1.1 with reserved font names; mitigated by a neutral backend name, a committed licence, and stated provenance. - **`Figure` touches the renderer's frame contract.** Mitigated by leaving `draw(Plot&)` untouched and adding `draw(Figure&)` alongside it, so the four shipped adapters keep working unchanged. - **Snapshot/`revision()` staleness** could render a stale atlas. Mitigated by making `revision()` monotone and testing a backend swap mid-run. + +--- + +## 10. Consistent physical sizing: the UI scale + +**This reverses a decision recorded in the predecessor spec.** That document settled on style metrics being *device* pixels and accepted the consequence that labels are relatively smaller on a high-DPI host. That is backwards for a library whose output should look the same everywhere: a 12 px axis label must be the same physical size on a 1x panel and a 2x panel, and a 1 px grid must not be a hairline on one and a slab on the other. + +`Style` gains `f32 ui_scale = 1.0f` — device pixels per logical unit. Every visual metric in `Style` is henceforth a **logical** unit (`axis_font_px`, line widths, grid widths, paddings, swatch and marker sizes, tick minimum spacing), converted to device pixels by one helper per consumer: `PlotLayout` when it sizes label strips, title strips, the legend and region chrome, and the renderer when it converts widths and font px. `Plot` scales the font px it hands to the text pipeline, so a logical 12 px label is drawn at `12 * ui_scale` device px. + +Data-space geometry is untouched: only chrome scales. The pixel-space convention is otherwise unchanged — `Rect` is still device pixels with a top-left origin, so the renderer, the scissor clear and every adapter's event mapping stay as they are. + +The host supplies the value and every adapter already knows it: GLFW from `glfwGetFramebufferSize` / `glfwGetWindowSize`, Qt from `devicePixelRatioF()`, GTK from the two sizes the `::resize` signal reports, ImGui from `io.DisplayFramebufferScale`. `Renderer::draw()` propagates `style().ui_scale` onto the plot before laying out, so layout and drawing cannot disagree about the scale. + +Invariant, and the test that pins it: with `ui_scale = 2.0`, every chrome element's **device** extent doubles while its **logical** extent is unchanged — the same axis label occupies twice the pixels but expresses the same size, and `measure`-derived strip widths scale with it. + +## 11. Grid lines as two styled classes + +`Style` carried separate colours for major and minor grid lines but neither width nor visibility, so a dense plot could not switch the minor grid off, and a fine grid could not be drawn thinner than a coarse one. Each class gains its own width and visibility, all in logical units scaled by §10: + +```cpp +Color grid_major = Color::hex(0x2A3038); +Color grid_minor = Color::hex(0x1C2126); +f32 grid_major_width = 1.0f; // logical px +f32 grid_minor_width = 1.0f; +bool grid_major_visible = true; +bool grid_minor_visible = true; +``` + +The grid emits majors exactly at the major ticks and minors exactly at the minor ticks — one source, the tick set — so a major grid line coincides with a labelled tick by construction rather than by coincidence. Visibility is checked before staging, so a disabled class costs nothing to draw rather than being drawn transparent. + +## 12. Live data: relative zoom and panning that keeps following + +The streaming case is the library's headline use, and today any interaction destroys it: a wheel zoom or a drag calls `set_x_range`, which clears `auto_scale`, so a live plot stops following the newest sample and never resumes. That is wrong for a scrolling display, and it is why panning feels useless there. The fix is an explicit follow state on the x axis instead of an implicit consequence of `auto_scale`: + +```cpp +enum class XFollow { Off, Live }; +// on Plot: +void set_x_follow(u32 axis, XFollow); +XFollow x_follow(u32 axis) const; +void set_x_offset(u32 axis, f64 units_behind_live); // >= 0, clamped to the retained window +f64 x_offset(u32 axis) const; +``` + +With `Live`, the visible window is `[newest - offset - span, newest - offset]`, recomputed from the series' newest x every frame, so new samples keep arriving at the right edge and the plot never stops following. + +- **Relative zoom.** A wheel zoom **multiplies** the span rather than adding to it, so successive notches compound and the zoom is scale-free. The anchor is the data coordinate under the pointer when the pointer is inside `axes`, and the live edge otherwise. Following is preserved either way, and the offset is adjusted so the anchored coordinate does not move on screen. +- **Mouse panning.** A left-drag changes `offset` by the data delta and leaves the span alone — this is the panning a live plot needs, and what "add mouse panning" means here. Dragging toward the live edge stops at `offset == 0`; you cannot pan past the newest sample. Dragging away moves back through the retained window, so the limit is what the `StreamSeries` still holds. +- **Returning to live.** `auto_fit` under `Live` sets `offset = 0` and fits the span to the retained window. It does not leave `Live`. +- **Interactions never silently disable following.** Pan and zoom keep `XFollow::Live` armed; only an explicit `set_x_follow(axis, Off)` turns it off. This is the behavioural change: today's interaction path disarms auto-follow as a side effect. +- **Static plots are unaffected.** `XFollow::Off` is the default, and a plot whose series is not streaming behaves exactly as it does today, including `auto_scale` on the y axis. + +The y axis keeps its existing `auto_scale`; this section governs the x view of a streaming series.