feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
This commit is contained in:
1 parent
f132a5ec92
commit
38ef459897
77 files changed
+5905
-688
No files matched your search
+53
-19
@@ -111,6 +111,10 @@ void Host::close() noexcept {
|
||||
// hands each row its rect every frame). Dropping them would silently stop routing
|
||||
// gestures to the rows on the second run.
|
||||
have_pointer_ = false;
|
||||
// The cached rows described a figure's geometry; the next run() re-reads them from
|
||||
// scratch, and until then `controller_at()` answers the single-plot controller
|
||||
// rather than rects from a window that no longer exists.
|
||||
row_rects_.clear();
|
||||
// A closed host reports no size: a second run() must not see the previous
|
||||
// window's framebuffer size before its first refresh_sizes().
|
||||
fb_width_ = 0;
|
||||
@@ -210,13 +214,12 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
|
||||
// that hands each row its rect (and so its own `Plot` its viewport). The single
|
||||
// controller is untouched -- each row has its own, see controller_at().
|
||||
figure_->set_viewport(viewport);
|
||||
// Every row's controller gets that row's rect, so a gesture -- routed by
|
||||
// pointer_target() or injected by a scripted run -- is hit-tested against the
|
||||
// geometry the last frame drew, and the two readers of a row's geometry cannot
|
||||
// disagree about it. The rows come off the one recomputing layout of this frame --
|
||||
// calling `layout()` again here, or in `controller_at()`, would lay every row out a
|
||||
// second and a third time for the same frame.
|
||||
const std::vector<Rect>& rows = figure_->layout().rows;
|
||||
// The frame's one layout of the figure, into the cache every event handler
|
||||
// between now and the next frame reads (R7): the rows below, and the routing of
|
||||
// whatever the next `glfwPollEvents()` batch delivers, all take *these* rects
|
||||
// rather than each paying for another layout.
|
||||
refresh_row_rects();
|
||||
const std::vector<Rect>& rows = row_rects_;
|
||||
for (u32 i = 0; i < row_controllers_.size(); ++i) {
|
||||
if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]);
|
||||
}
|
||||
@@ -353,6 +356,7 @@ void Host::on_cursor_pos(f64 wx, f64 wy) noexcept {
|
||||
have_pointer_ = true;
|
||||
e.mods = mods_now(window_);
|
||||
e.down = any_button_down(window_);
|
||||
e.time_s = glfwGetTime(); // GLFW's monotonic clock; only differences are read
|
||||
feed_pointer_event(e);
|
||||
}
|
||||
|
||||
@@ -380,6 +384,7 @@ void Host::on_mouse_button(int button, int action, int mods) noexcept {
|
||||
e.pressed = action == GLFW_PRESS;
|
||||
e.released = action == GLFW_RELEASE;
|
||||
e.down = any_button_down(window_);
|
||||
e.time_s = glfwGetTime(); // the double-click detector reads differences of these
|
||||
feed_pointer_event(e);
|
||||
}
|
||||
|
||||
@@ -406,9 +411,16 @@ void Host::on_key(int key, int action, int mods) noexcept {
|
||||
case GLFW_KEY_MINUS:
|
||||
case GLFW_KEY_KP_SUBTRACT: mapped = input::kKeyZoomOut; break;
|
||||
case GLFW_KEY_F: mapped = input::kKeyFit; break;
|
||||
case GLFW_KEY_ESCAPE:
|
||||
if (action == GLFW_PRESS) glfwSetWindowShouldClose(window_, GLFW_TRUE);
|
||||
return;
|
||||
// Arrows pan (10% of the span per press, repeats included) and Escape cancels
|
||||
// the active drag: translated, exactly like the ASCII set above, and the
|
||||
// controller decides what each key does. Escape no longer closes the window --
|
||||
// the window manager's close button and Alt+F4 are the way to quit, and a key
|
||||
// the controller has a gesture for must reach it.
|
||||
case GLFW_KEY_ESCAPE: mapped = input::kKeyEscape; break;
|
||||
case GLFW_KEY_LEFT: mapped = input::kKeyLeft; break;
|
||||
case GLFW_KEY_RIGHT: mapped = input::kKeyRight; break;
|
||||
case GLFW_KEY_UP: mapped = input::kKeyUp; break;
|
||||
case GLFW_KEY_DOWN: mapped = input::kKeyDown; break;
|
||||
default: return;
|
||||
}
|
||||
input::KeyEvent e;
|
||||
@@ -438,12 +450,27 @@ void Host::set_figure(Figure* figure) noexcept {
|
||||
spread_y_ = 0.0;
|
||||
captured_ = false;
|
||||
captured_row_ = 0u;
|
||||
if (figure_ == nullptr) return;
|
||||
captured_button_ = input::Button::Left;
|
||||
if (figure_ == nullptr) {
|
||||
row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_`
|
||||
return;
|
||||
}
|
||||
const u32 rows = figure_->row_count();
|
||||
row_controllers_.reserve(rows);
|
||||
for (u32 i = 0; i < rows; ++i) {
|
||||
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
||||
}
|
||||
// The new figure's rects go into the cache now, not at the next frame: an event
|
||||
// arriving between set_figure() and the first frame -- a scripted run, the headless
|
||||
// tests -- must be routed against the figure it was just handed, and the next
|
||||
// `refresh_row_rects()` re-reads them once per frame as usual.
|
||||
refresh_row_rects();
|
||||
}
|
||||
|
||||
void Host::refresh_row_rects() noexcept {
|
||||
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
||||
// instead of paying this again, which is the whole point of the cache (R7).
|
||||
row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector<Rect>{};
|
||||
}
|
||||
|
||||
input::Controller* Host::row_controller(u32 row) noexcept {
|
||||
@@ -452,10 +479,12 @@ input::Controller* Host::row_controller(u32 row) noexcept {
|
||||
|
||||
input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
|
||||
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||
// The figure's one layout for this call: both readers below take the row rects it produced, so an
|
||||
// event lays the figure out once and not once per reader -- this is the hottest path a multi-row
|
||||
// figure has, and a layout now rebuilds every row's ticks, grid and crosshair geometry.
|
||||
const std::vector<Rect>& rows = figure_->layout().rows;
|
||||
// The cached rects `refresh_row_rects()` filled -- the same ones the last frame
|
||||
// drew -- and not a fresh `Figure::layout()`: an event pays no layout, which is
|
||||
// what made a burst of pointer moves on a multi-row figure re-lay every row out
|
||||
// per move (R7). Between a resize and the loop's next refresh the cache is one
|
||||
// frame stale, which is the documented worst case.
|
||||
const std::vector<Rect>& rows = row_rects_;
|
||||
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
||||
const u32 row = row_in(rows, y);
|
||||
@@ -472,13 +501,14 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
|
||||
controller_.on_pointer(e);
|
||||
return;
|
||||
}
|
||||
pointer_target(e.x, e.y, e.pressed, e.released).on_pointer(e);
|
||||
pointer_target(e.x, e.y, e.button, e.pressed, e.released).on_pointer(e);
|
||||
}
|
||||
|
||||
input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept {
|
||||
input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
||||
bool released) noexcept {
|
||||
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
|
||||
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
||||
const std::vector<Rect>& rows = figure_->layout().rows;
|
||||
const std::vector<Rect>& rows = row_rects_;
|
||||
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
|
||||
|
||||
u32 row = 0u;
|
||||
@@ -488,12 +518,16 @@ input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool release
|
||||
// to a controller that never saw the press, which would leave the owner's drag open
|
||||
// for ever (and let a later gesture resume it from the old anchor).
|
||||
row = captured_row_;
|
||||
if (released) captured_ = false;
|
||||
// Only the owning button's release ends the capture (R5): a second button's
|
||||
// release -- pressed meanwhile as a chord -- must not strand the first button's
|
||||
// drag, and its press must not steal the capture to the row it landed in.
|
||||
if (released && button == captured_button_) captured_ = false;
|
||||
} else {
|
||||
row = row_in(rows, y);
|
||||
if (pressed) {
|
||||
captured_ = true;
|
||||
captured_row_ = row;
|
||||
captured_button_ = button;
|
||||
}
|
||||
}
|
||||
// The captured row is re-read against this frame's rects: the figure can be resized
|
||||
|
||||
+51
-12
@@ -128,9 +128,10 @@ class Host {
|
||||
input::Controller* row_controller(u32 row) noexcept;
|
||||
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
|
||||
/// the single-plot loop -- while no figure is set, and the controller of the figure
|
||||
/// row that pixel lands in otherwise. The row rects come from `Figure::layout()`, the
|
||||
/// same ones the renderer draws, so a gesture lands on the row it was aimed at; a
|
||||
/// pixel inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
|
||||
/// row that pixel lands in otherwise. The row rects come from the cache
|
||||
/// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture
|
||||
/// lands on the row it was aimed at and no event lays the figure out; a pixel
|
||||
/// inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
|
||||
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag
|
||||
/// that leaves the data area still ends where it should.
|
||||
///
|
||||
@@ -147,13 +148,38 @@ class Host {
|
||||
///
|
||||
/// The routing is the row the event lands in, with one exception that is the whole
|
||||
/// point of it: a press **captures** the row it landed in, and every motion and the
|
||||
/// release until that button comes up go to the captured row. A drag that crosses
|
||||
/// release until *that button* comes up go to the captured row. A drag that crosses
|
||||
/// into a neighbouring figure row is therefore still the drag it began as -- the
|
||||
/// owning controller sees the motion and the release that end it -- rather than
|
||||
/// being handed to a controller whose `mode_ == None`, which would leave the owner's
|
||||
/// drag open for ever and let a later gesture resume it from the old anchor.
|
||||
///
|
||||
/// Single-gesture policy: a figure routes one button-down gesture at a time. The
|
||||
/// first button to press claims the capture; a second button pressed meanwhile --
|
||||
/// a chord, or a trackpad that reports a second finger as a button -- neither
|
||||
/// steals the capture to another row nor ends it, and only the owning button's
|
||||
/// release ends it. Whatever the second button's events do to the controllers is
|
||||
/// the controllers' business; the host only guarantees they all arrive at the row
|
||||
/// the first press claimed.
|
||||
void feed_pointer_event(const input::PointerEvent& e) noexcept;
|
||||
|
||||
/// Re-reads the figure's row rects into the cache every event handler below
|
||||
/// reads -- the one `Figure::layout()` the routing costs between two frames. The
|
||||
/// run loop calls this once per frame, right after the figure's viewport is set,
|
||||
/// so the rects the handlers answer from are the rects that frame draws; the
|
||||
/// handlers themselves never lay the figure out.
|
||||
///
|
||||
/// A driver that injects events without running the loop -- a scripted run, the
|
||||
/// headless tests -- calls this after re-configuring the figure (a new viewport,
|
||||
/// a new row gap, `set_figure()`): it is the scripted equivalent of the frame the
|
||||
/// run loop would have staged. Until it runs, the handlers answer from the last
|
||||
/// refreshed rects, which is also what happens across a window resize: GLFW
|
||||
/// delivers the resize and any pointer events queued behind it in one batch, the
|
||||
/// events before the resize see the rows the previous frame drew, and the loop's
|
||||
/// next `refresh_row_rects()` brings the cache onto the new geometry -- rows are
|
||||
/// at most one frame stale, never wrong by a second layout.
|
||||
void refresh_row_rects() noexcept;
|
||||
|
||||
/// The gesture controller the host feeds. Valid for the host's whole life.
|
||||
input::Controller& controller() noexcept { return controller_; }
|
||||
|
||||
@@ -198,11 +224,12 @@ class Host {
|
||||
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
|
||||
|
||||
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
|
||||
/// a press claims the row it landed in (`captured_row_`) and every motion and the
|
||||
/// release until the button comes up are routed to it; `controller_at()` is the pure
|
||||
/// query that never captures. Also hands the target its row's viewport, so the
|
||||
/// hit-test geometry is the last frame's.
|
||||
input::Controller& pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept;
|
||||
/// a press claims the row it landed in (`captured_row_` + `captured_button_`) and
|
||||
/// every motion and the release until *that button* comes up are routed to it;
|
||||
/// `controller_at()` is the pure query that never captures. Also hands the target
|
||||
/// its row's viewport, so the hit-test geometry is the last frame's.
|
||||
input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed,
|
||||
bool released) noexcept;
|
||||
|
||||
/// The row the pointer's events currently go to: the captured row while a drag owns
|
||||
/// the button, the row under the pointer otherwise. The run loop uses it to clear
|
||||
@@ -254,11 +281,23 @@ class Host {
|
||||
f64 spread_y_ = 0.0;
|
||||
bool spread_active_ = false;
|
||||
|
||||
/// The row a button-down drag has captured: the row its press landed in, which
|
||||
/// owns its motions and its release until the button comes up. `captured_` is
|
||||
/// false between gestures, and cleared when another figure is set.
|
||||
/// The row a button-down drag has captured, and the button that owns the capture:
|
||||
/// the row its press landed in owns that button's motions and its release until
|
||||
/// the button comes up. `captured_` is false between gestures, and cleared when
|
||||
/// another figure is set. The button is recorded because only the owning button's
|
||||
/// release ends the capture (R5): a second button pressed while the drag runs is
|
||||
/// routed to the same row and must neither steal the capture nor end it, or its
|
||||
/// release would strand the first button's drag.
|
||||
bool captured_ = false;
|
||||
u32 captured_row_ = 0u;
|
||||
input::Button captured_button_ = input::Button::Left;
|
||||
|
||||
/// The figure's row rects as of the last `refresh_row_rects()` -- the cache the
|
||||
/// event routing reads instead of laying the figure out per event (R7). Filled by
|
||||
/// `set_figure()` and once per frame by the run loop; empty while no figure is
|
||||
/// set, which is the state `controller_at()`/`pointer_target()` fall back to the
|
||||
/// single-plot controller on.
|
||||
std::vector<Rect> row_rects_;
|
||||
|
||||
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
|
||||
u32 fb_height_ = 0;
|
||||
|
||||
Reference in new issue
Block a user