Review fix pass 1 on the task 7 work; four findings, all closed.
1. The Qt demo's region sat across the middle of the window, which is exactly
where its own scripted input check pans: after the check's box zoom the press
opened DragMode::MoveRegion, the x range never moved, and the check printed
'input check pan FAIL' -- under an exit 0, because the verdict was printed and
not returned. The region now rides the left sixth (as the GLFW demo's does),
both checks pass, and the verdict is wired to the exit status. The same audit
found two more printed-only verdicts -- the GTK demo's scripted-resize layout
check and its screenshot write (and Qt's) -- now wired too. What stays
printed-only is the screenshot line of the GLFW host and the ImGui widget,
where the dump happens inside the adapter's frame loop.
2. A follow-ing link group was invisible to the input layer: a member's own axis
must be XFollow::Off (link_x() refuses a Live member), so Controller's
x_zoom_anchor() extrapolated a coordinate from the pointer instead of using
the live edge and a wheel over a row's axis strip walked the window back by
19% of its width (behind 0.000000 -> 0.285809 s). The group now publishes its
own edge to every member (Axis::live_owner + Plot::set_x_live_owner, written
by Figure::push_link, cleared when the group stops following or the row
unlinks), and x_zoom_anchor() reads it. Public-header addition, called out.
Measured after: behind 0.012992 s and the window's right edge bit-identical to
the edge the group published at the gesture.
3. The multiplot summary printed x_link_count() -- a number of groups -- under
'rows in it'. It now reports the members and the group count separately, and
every row line carries its own group id.
4. Host::controller_at() promised to clamp a pixel in a row gap to the nearest
row and only clamped below the last one, so a gap pixel went to row 0. It now
selects by distance, and tests/test_glfw_host.cpp pins it with a 40 px gap --
the first coverage of the host's figure routing, which needs no window.
New cases: tests/test_controller.cpp x2 (the anchor and the hint's lifecycle),
tests/test_glfw_host.cpp x3 (routing, the gap, the single-plot fallback): 229
cases, 0 failed, ASan clean. All five demos exit 0, the three self-checking
modes exit 0, and the amended assertions were confirmed failing against the
pre-fix behaviour.
Adding a grabbable region across the middle of the glfw demo turned the pan
gesture PLOT_DEMO_FOLLOW makes -- and a user's own centre drag -- into a region
drag, which that mode's self-check reported as 'distance behind only by the pan:
NO' and exit 1. The region now rides the left sixth of the live window, so a
press in the middle pans exactly as it always did, and the reason is written
where the bounds are placed.
The multiplot demo also prints its region's last-frame fill rect and bound
columns, so the exit frame's region pixels can be read against the geometry
Plot::layout() measured.
Report: .superpowers/sdd/p2-task-7-report.md
The multi-plot example the phase exists for: three plots stacked in one
Figure, sharing one x link with following armed, each row carrying its own
named signals and its own legend, driven by six StreamSeries at 500 kS/s.
Two cursors and a region with an exact statistics readout reach the direct
manipulation of spec §5 in the same frame, and PLOT_DEMO_SYNC=1 is a
self-checking mode that exits non-zero unless all three rows hold one range
and the shared right edge keeps tracking the newest sample through a zoom, a
pan on another row and more arriving data.
The GLFW host gains the figure mode the demo needs: set_figure() makes the
run loop draw the Figure instead of its own plot and route each pointer,
wheel and key event to the row it lands in, through one Controller per row
(row_controller()). All of it is opt-in; the single-plot path is untouched.
Review of the atlas refactor found one Important and three Minor issues:
- Important: TextAtlas::sync called glGetIntegerv before any available() check,
so a host that synced before plot::gl::load() -- which is legal for a public
class Tasks 2 and 8 build backends on -- segfaulted on a null entry point
instead of receiving the Status the interface promises. It now follows the
handle-constructor convention (available(), then the entry point itself) and
reports Unsupported.
- empty() no longer means "a GL name exists": releasing one is a
glDeleteTextures call, so the no-atlas path forgets the atlas and only resets
the texture when there is a table to release it with.
- end_frame's comment still claimed the frame hands back the unpack alignment
init() set, contradicting the code below it and renderer.hpp.
- The orientation case's texel probe mapped the atlas' u along the cell's +v
axis and vice versa, which is a no-op for its two diagonal probes -- so it
could not fail on a transposed UV mapping, and its own comment misstated the
rule. Probes now follow the shader's rule and include an off-diagonal pair.
- The null-glyph path of add_text (and its twin in walk_text_clipped) was
unreached by any case.
Cases: text_atlas_reports_a_status_when_the_entry_points_are_missing,
add_text_skips_a_codepoint_the_backend_cannot_draw.
The text pipeline stages a glyph's rect *origin* per instance and carries the
rect's *size* once per draw, so a backend whose glyphs have differing rect sizes
-- a TrueType packing trimmed to ink, which is Task 8's shape -- would have drawn
every glyph at whichever size it happened to be told, with nothing downstream
able to tell. Make that constraint structural rather than documented:
- TextBackend::cell_uv() declares the one cell size, and the contract (an atlas
is a fixed-cell grid; a proportional face draws its glyphs *into* a uniform
cell instead of trimming each rect to its ink, and why the pipeline needs it)
is stated on the atlas accessors.
- glyph_is_cell() is the check; add_text()/set_cell() declare the cell from the
backend, and the staging path asserts it for every glyph in a debug build.
A release build draws on with the cell it was told, as before.
- Renderer::flush_text() takes u_glyph_uv from the backend, not from the batch,
so the draw's one rect size can never come from whichever glyph was staged
first.
Final whole-branch review fixes.
1 (Important). Plot::measurement() cached on (series pointer, sample count)
only, so a saturated StreamSeries -- whose size() stays pinned while x0()
advances every acquire() -- froze its readout on the first saturated frame
until a cursor moved. The cache key now carries the series' x0() (new
MeasureSlot::last_x0), so a sliding window recomputes and a static series
still does not: asserted with a new macro-gated counter hook
(plot/detail/measure_count.hpp, PLOT_COUNT_MEASURE_RECOMPUTES, wired like the
pyramid and ring counters, absent from the release library). New case
saturated_stream_measurement_tracks_the_sliding_window: saturated ring,
stationary cursors, ramp in absolute x -- zero recomputes for repeated reads
with nothing moving, one per acquire while it slides.
2-3, 9. Spec: 4.3 described a GL_LINE_STRIP path that does not exist (there is
one rasteriser -- instanced anti-aliased quads -- and dense_threshold_spp
selects the gather level, not a primitive); 8 named a PLOT_GL_CHECK() macro
that exists nowhere, and stated no exception policy. The table, the field's
comment and 8 now describe the real gather-level threshold, the real
glGetError() drain plus the stores' drop-to-orphan fallbacks, and the real
policy: library failures return Status/bool, allocating entry points propagate
std::bad_alloc.
4. The frame handed back neither GL_UNPACK_ALIGNMENT (init() sets 1 for the
atlas) nor the clear colour (queried once with a new glGetFloatv entry).
end_frame() restores both and renderer.hpp lists them in the residue
contract.
5. draw() kept a host TextBackend for layout while drawing with its own font.
The renderer now installs its font unconditionally -- strips and labels measure
and draw with one backend -- with a debug-build diagnostic if a different one
was set, and Plot::set_text_backend() is documented as the no-renderer path.
New case renderer_measures_the_labels_it_draws asserts layout, measuring and
the drawn label's ink agree.
6-10. draw()'s style overwrite stated at the call site; the load-sensitive
wall-clock bound in test_measure.cpp replaced by a counter assertion (same
interval, same fold count over a 512x length ratio); dump_frame_ppm drops
noexcept and says why it may allocate; error.txt ignored (not this project's,
not generated here) rather than deleted.
Verified: debug and release build clean (-Wall -Wextra -Wpedantic -Wconversion
-Wshadow), 4/4 ctest entries pass, 130 cases pass (was 127), umbrella and
header self-containment checks pass, and no counter symbol in the release
archive.
The linux-release preset inherited PLOT_BUILD_TESTS=ON, so the release build
compiled the pyramid cover/merge and ring upload/wait/slot counters into
libplot_core.a and cmake --install shipped them. Set PLOT_BUILD_TESTS=OFF in the
preset; the suite keeps running in linux-debug and linux-asan, which are
unchanged.
Also pin the two header properties the plan calls Global Constraints and that
nothing guarded: every header under include/plot/ compiles standalone, and
include/plot/plot.hpp includes every public one. tests/CMakeLists.txt globs the
headers at configure time (CONFIGURE_DEPENDS) and generates one TU per header
plus an umbrella TU that constructs Plot, Renderer, data::StreamSeries and
input::Controller; a ctest entry builds the header TUs and runs the umbrella,
and cmake/plot_umbrella_check.cmake fails configure and ctest when a public
header is missing from the umbrella.
The exit-frame self-check compared the y label strip to the pixel and printed a
FAIL at GDK_SCALE=2 where it measured 174 -> 172. Measured (temporary probe,
reverted): the strip is `widest major tick label + padding`, and the tick step
follows the axes length, which the resize changes -- 18 majors over 1440 device px
with "-800m" widest, 8 over 1080 with "-500m", 78 vs 76 px in the bitmap font. The
layout was recomputed; the equality was the wrong assertion. The check now asserts
what actually distinguishes a recompute from a scaled stale layout: the axes and
its strips tile the *current* viewport, and the strip is nearer the previous strip
than that strip scaled by the resize ratio (2 px of drift against a scaled 118).
`on_exit_timer`'s main-context pump can re-enter its own 20 ms timeout, which ran
the exit block twice in one observed run; `on_teardown` is guarded the same way so
the producer join, the window destruction and the quit happen exactly once. A window
closed by hand takes the same ordering through `GtkWindow::close-request`, which
`gtk_window_destroy()` does not emit.
The factor both the viewport and every forwarded input coordinate use came from
`gtk_widget_get_scale_factor()`, which GTK documents as "the next higher integer
value" under fractional scaling. It is now derived by `DeviceMapping::from_sizes()`
from the widget's logical allocation and the device size `GtkGLArea::resize`
reports -- the viewport that signal's default handler sets with
`glViewport(0, 0, w, h)` -- so the viewport, the renderer and input are one value
by construction. The public `int scale_factor()` became `f32 device_scale()`.
`kSurfacePixelsPerNotch` is now 100.0 and documented as a *calibration* with a
stated assumption, not a unit conversion attributed to axis_value120/Qt.
`::unrealize` releases the renderer unconditionally, after making the context
current: the error it used to check says nothing about whether GL names exist
(creation failure is the path that returns before a renderer is built), and
skipping the release left the deletes to the wrapper's destructor, with no
context.
The drag anchor caches its press point and offsets in logical pixels and scales
the difference by the factor in force now, so a scale change mid-drag cannot
become a phantom pan.
The demo stops its producer in `on_teardown`, before destroying the window whose
renderer the stream feeds.
The persistent-mapped instance ring had three slots and recorded a fence per
upload, so a frame recycled a slot whose fence was recorded a few draws earlier
*in that same frame*: from the fourth of the demos' nine uploads on, each one
waited on the frame's own work. It is free on a driver that retires draws
promptly (the GLFW host, 1.07 ms/frame) and ~4 ms of the Qt widget's 7.11 ms
under llvmpipe, and it grows with the trace and cursor counts this library
exists for.
* The ring grows to cover a frame's upload count, the frame's write cursor is
the ring index (so a frame writes 0, 1, 2, ... and never a slot twice), one
fence is recorded after the frame's last draw, and the only wait is at the
start of the next frame. A wait that times out, or a slot written with no
fence ever recorded behind it, drops the store to the orphan path -- the
treatment a refused fence already had -- so nothing is ever written into
storage the GPU may still be reading.
* New test-only counters (PLOT_RING_STATS, the PLOT_PYRAMID_COUNT_* pattern:
free functions, no member, so no consumer's layout changes) pin a frame's
upload and wait counts. The new tests assert the frame's slot offsets are
distinct, that the ring grew past its floor to cover the frame, and that a
frame waits at most once per store -- read from the counters and from
glClientWaitSync itself.
* qt6: print the unresolved entry-point names before the load-failure branch,
which used to return first and leave the one failure this widget can have
reporting nothing but a generic message.
* qt6: the cached pointer anchor is now in logical pixels and a move's delta is
the logical difference scaled by the DPR in effect, so a scale change between
two move events (the window dragged onto another monitor mid-drag) is no
longer a cross-scale jump applied as a pan. The anchor moved into a
toolkit-free unit (adapters/qt6/qt_pointer.hpp) with its own test.
GLFW demo headless: 1.07 -> 0.73 ms/frame. Qt widget under xvfb/llvmpipe:
7.1 -> 3.8 ms at dpr 1.0, 6.7 -> 3.6 ms at 1.25, with both upload paths still
rendering and the demo's scripted pan/box-zoom checks passing at both scales.
Zeroing an unusable wheel amount turned it into a zero-notch zoom: the factor was
exactly one, the range did not change, but the zoom body still ran through
set_x_range/set_y_range, which clear auto_scale unconditionally. One garbage wheel
therefore disarmed auto-fit on every axis. on_wheel now rejects an unusable amount
before the event can mutate anything, keeping the position fallback.
The tests that should have caught it are strengthened: the wheel case now asserts
auto scale is still armed (it was passing either way), and the invalid-position
case checks a pan measured from the last real position with the drag alive, rather
than a range nothing could have moved.