diff --git a/adapters/glfw/glfw_host.cpp b/adapters/glfw/glfw_host.cpp index 20b4b73..53d22ad 100644 --- a/adapters/glfw/glfw_host.cpp +++ b/adapters/glfw/glfw_host.cpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace plot::glfw { namespace { @@ -422,15 +423,22 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept { const FigureLayout& layout = figure_->layout(); const std::vector& rows = layout.rows; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; + // The row the pixel is in, or -- when it is in none of them, which is the row gap a + // host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the + // row whose rect is nearest. Selection by distance rather than by "the first that + // matches" is what makes a pixel in a gap belong to the row it is *near*: the rows + // tile the figure at the default gap of 0, so this is unreachable until a host asks + // for one, and a gesture in a visible gap must not be routed to row 0 by default. u32 row = 0u; - if (y >= rows.back().y + rows.back().h) { - row = static_cast(rows.size() - 1u); // below every row: the nearest one is the last - } else { - for (u32 i = 0; i < rows.size(); ++i) { - if (y >= rows[i].y && y < rows[i].y + rows[i].h) { - row = i; - break; - } + f32 best = std::numeric_limits::infinity(); + for (u32 i = 0; i < rows.size(); ++i) { + const f32 top = rows[i].y; + const f32 bottom = rows[i].y + rows[i].h; + const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f); + if (distance < best) { + best = distance; + row = i; + if (distance == 0.0f) break; // inside this row: nothing can be nearer } } (void)x; // the rows tile the figure vertically, so y alone picks one diff --git a/adapters/glfw/glfw_host.hpp b/adapters/glfw/glfw_host.hpp index 0738611..6f86947 100644 --- a/adapters/glfw/glfw_host.hpp +++ b/adapters/glfw/glfw_host.hpp @@ -117,6 +117,19 @@ class Host { /// mode or a hover out of it (`controller()` is the single-plot loop's, and is /// not the one figure gestures go to). 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()`, + /// 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. + /// + /// It also hands that row its own viewport, which is what keeps the controller's + /// hit-test geometry in step with the layout between two frames. The event callbacks + /// route through it, and so can a host that wants to know where a synthetic gesture + /// would land. + input::Controller& controller_at(f32 x, f32 y) noexcept; /// The gesture controller the host feeds. Valid for the host's whole life. input::Controller& controller() noexcept { return controller_; } @@ -155,18 +168,6 @@ class Host { void on_scroll(f64 dx, f64 dy) noexcept; void on_key(int key, int action, int mods) 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 outside every row (the row gap, or a chrome strip - /// below the last row) is clamped to the nearest row rather than dropped, so a - /// drag that leaves the data area still ends where it should. - /// - /// It also hands that row its own viewport, which is what keeps the controller's - /// hit-test geometry in step with the layout between two frames. - input::Controller& controller_at(f32 x, f32 y) noexcept; - Plot plot_; input::Controller controller_; ///< forwards its viewport to `plot_` std::optional renderer_; diff --git a/examples/gtk_demo.cpp b/examples/gtk_demo.cpp index 4b53094..0df94f1 100644 --- a/examples/gtk_demo.cpp +++ b/examples/gtk_demo.cpp @@ -278,6 +278,12 @@ struct Demo { /// and the teardown (scheduled as an idle source). bool exit_started = false; bool torn_down = false; + /// The exit path's verdicts, carried out of the run so `main` can return them: whether the + /// scripted resize produced a recomputed layout, and whether the screenshot the headless + /// mode exists for actually reached the disk. Both are properties a caller checks with the + /// exit status, so neither may live only in stdout. + bool layout_recomputed = false; + bool screenshot_written = true; ///< false only when a dump was asked for and failed bool cursors_follow = true; Meter meter; @@ -364,6 +370,7 @@ gboolean on_render_after(GtkGLArea*, GdkGLContext*, gpointer user_data) { const plot::Rect viewport = demo.area->device_viewport(); const bool written = dump_area_ppm(demo.dump_path, static_cast(viewport.w), static_cast(viewport.h)); + demo.screenshot_written = written; std::printf("[gtk_demo] screenshot %s (%dx%d GL px): %s\n", demo.dump_path, static_cast(viewport.w), static_cast(viewport.h), written ? "written" : "FAILED"); @@ -446,6 +453,12 @@ gboolean on_exit_timer(gpointer user_data) { after.x_title_height)) <= 0.5f; const bool metric_strip = drift < std::fabs(after.axes.x - scaled_strip); const bool recomputed = after.axes.w != before.axes.w && tiles && metric_strip; + // The verdict is carried out of the run, not only printed: a headless caller reads the + // exit status, and a check whose only output is a line of stdout is a check that passes + // whatever it finds. A run that cannot resize at all (a window manager or an X server + // that clamps the window to the screen) fails here, which is the honest answer -- the + // layout this run is supposed to prove is the layout it never got. + demo.layout_recomputed = recomputed; std::printf("\n[gtk_demo] ---- exit frame ----\n"); std::printf("[gtk_demo] resize logical %dx%d -> %dx%d, device viewport %ux%u, scale %g\n", width_before, height_before, gtk_widget_get_width(demo.area->widget()), @@ -663,5 +676,18 @@ int main(int argc, char** argv) { " resources went with it)"); g_object_unref(app); - return app_result; + if (app_result != 0) return app_result; + if (!demo.screenshot_written) { + std::fprintf(stderr, "plot_gtk_demo: the screenshot could not be written\n"); + return 1; + } + if (demo.automated && !demo.layout_recomputed) { + std::fprintf(stderr, + "plot_gtk_demo: the scripted resize did not produce a recomputed layout (see the" + " 'exit frame' lines above; the window must be able to reach %dx%d on this" + " screen)\n", + kExitWidth, kExitHeight); + return 1; + } + return 0; } diff --git a/examples/multiplot_demo.cpp b/examples/multiplot_demo.cpp index e9551a3..8021124 100644 --- a/examples/multiplot_demo.cpp +++ b/examples/multiplot_demo.cpp @@ -266,17 +266,15 @@ f64 newest_x(const Series& series) { /// is what "new data keeps arriving at the right edge" means for a window the user has /// moved away from the edge. /// -/// One measurement is deliberately *not* checked, and the reason is worth stating because -/// the demo is where it shows: the zoom above rolls over the axis strip, outside `axes`, -/// where a single `Live` axis anchors at its live edge and keeps the window ending at the -/// newest sample. A row of a following link group holds `XFollow::Off` -- the group owns -/// the follow state, and `link_x()` refuses a `Live` member -- so `Controller`'s -/// `x_zoom_anchor()` cannot see that the window is live and falls back to the data -/// coordinate the pointer's x extrapolates to (the middle of the axes for a strip-centre -/// pointer). The zoom still multiplies the width and keeps following, but it also walks -/// the window back from the edge. Measured here rather than assumed: `behind` after the -/// zoom is what it is, and the checks below tolerate it while pinning everything the -/// library does guarantee. +/// The zoom above rolls over the axis strip, outside `axes`, where the anchor is the live +/// edge rather than the coordinate under the pointer. That is the one thing a *link group* +/// used to lose: a member's axis is `XFollow::Off` by construction (the group owns the +/// follow state, and `link_x()` refuses a `Live` member), so `Controller::x_zoom_anchor()` +/// could not see that the window was live and fell back to the extent the pointer's x +/// extrapolates to -- the middle of the axes -- which walked the window back off the edge +/// by ~19 % of its width. A following group now publishes its own edge to every member +/// (`Plot::set_x_live_owner`), the controller reads it, and `behind` after the zoom must +/// still be ~0 -- which the check below asserts, so the fix cannot silently regress. /// /// The verdict is carried out of the run in `failed`, so `main()` exits non-zero on a /// failed check -- a check whose result only stdout knows is not a check -- and also when @@ -293,6 +291,12 @@ struct SyncScript { f64 span[4]{}; bool shared[4]{}; bool live[4]{}; + /// The live edge the group had published to its members when the zoom was made: the + /// coordinate the window's right edge must still be at afterwards, to the bit. Reading + /// it here rather than comparing against the newest sample is what makes the check + /// exact -- the edge moves while the frame runs, so "the window ends at the newest + /// sample" is true only to within the one frame the measurement takes. + f64 anchor_edge = 0.0; static SyncScript from_env() { SyncScript s; @@ -314,10 +318,22 @@ struct SyncScript { if (series[0]->size() < 65536u || figure.plot(0).text_backend() == nullptr) return; record(0, figure, series); break; - case 1: + case 1: { + // The edge the zoom must anchor on. A following group publishes it to every member + // (`Plot::set_x_live_owner`), which is what `Controller::x_zoom_anchor()` now reads + // for a gesture outside `axes` -- without it the group's live window is invisible to + // the input layer and the anchor is the extent the pointer's x extrapolates to. + f64 edge = 0.0; + if (!figure.plot(kRows - 1u).x_live_owner(0u, edge)) { + std::fprintf(stderr, "[multiplot] sync: no live edge was published to the rows\n"); + failed = true; + break; + } + anchor_edge = edge; notches(host, figure); record(1, figure, series); break; + } case 2: pan(host, figure); record(2, figure, series); @@ -396,8 +412,15 @@ struct SyncScript { const bool following = live[0] && live[1] && live[2] && live[3]; // (a) the steady state sits on the live edge. const bool at_edge = std::fabs(behind[0]) <= kBatchS; - // (b) the zoom, made on the bottom row, narrowed the one shared window. + // (b) the zoom, made on the bottom row, narrowed the one shared window -- and, because + // the anchor outside `axes` is the live edge, left the window *ending on that edge*, + // to the bit. `zoom_on_edge` is the same statement with the slack a frame of arriving + // data costs: the edge moves on while the frame runs, so the window is within 5 % of + // its own width of the newest sample -- where anchoring on the extrapolated coordinate + // left it 30 % behind. const bool zoomed = span[1] < span[0] * 0.99; + const bool anchor_kept = near(right[1], anchor_edge, 1e-9 * std::fabs(anchor_edge) + 1e-12); + const bool zoom_on_edge = behind[1] <= 0.05 * span[1]; // (c) the pan, made on the middle row, moved the window back and left the width alone. const bool panned_back = behind[2] > behind[1] + 1e-3; const bool width_kept = near(span[2], span[1], 1e-9 * span[1] + 1e-12); @@ -408,15 +431,18 @@ struct SyncScript { const bool tracked = near((right[3] - right[2]) - (newest[3] - newest[2]), 0.0, kBatchS); const bool behind_kept = near(behind[3], behind[2], kBatchS); const bool width_kept_after_slide = near(span[3], span[1], 1e-9 * span[1] + 1e-12); - failed = !(one_range && following && at_edge && zoomed && panned_back && width_kept && slid && - tracked && behind_kept && width_kept_after_slide); + failed = !(one_range && following && at_edge && zoomed && anchor_kept && zoom_on_edge && + panned_back && width_kept && slid && tracked && behind_kept && + width_kept_after_slide); completed = true; std::printf( "[multiplot] sync checks: one range across the rows %s; following armed %s; at the live" - " edge %s; the zoom narrowed the shared window %s; the pan moved the window back %s;" - " the right edge tracked the newest sample %s\n", + " edge %s; the zoom narrowed the shared window %s; the zoom kept the window ending on the" + " live edge %s (to the bit %s, behind %.6f s); the pan moved the window back %s; the right" + " edge tracked the newest sample %s\n", one_range ? "yes" : "NO", following ? "yes" : "NO", at_edge ? "yes" : "NO", - zoomed ? "yes" : "NO", panned_back && width_kept ? "yes" : "NO", + zoomed ? "yes" : "NO", zoom_on_edge ? "yes" : "NO", anchor_kept ? "yes" : "NO", behind[1], + panned_back && width_kept ? "yes" : "NO", tracked && slid && behind_kept && width_kept_after_slide ? "yes" : "NO"); std::fflush(stdout); } @@ -635,21 +661,26 @@ int main() { static_cast(series[kSine]->size()), static_cast(series[kSine]->size()) * kDx, kSampleRate * 1.0e-3, yr0.min, yr0.max); + // `x_link_count()` counts link *groups* (excluding the unlinked group 0), so it is + // labelled as one and the members are listed separately: the readout a reader checks the + // link with must not say "rows" about a number of groups. + u32 members = 0; + for (u32 row = 0; row < kRows; ++row) members += figure.x_link(row) == link ? 1u : 0u; std::printf("[multiplot] x link group %u: range [%.6f, %.6f] s (width %.6f), follow %s," - " rows in it %u\n", - link, shared.min, shared.max, shared.span(), figure.x_link_follow(link) == - plot::XFollow::Live - ? "Live" - : "Off", - figure.x_link_count()); + " %u member row%s of %u link group%s\n", + link, shared.min, shared.max, shared.span(), + figure.x_link_follow(link) == plot::XFollow::Live ? "Live" : "Off", members, + members == 1u ? "" : "s", figure.x_link_count(), + figure.x_link_count() == 1u ? "" : "s"); for (u32 row = 0; row < kRows; ++row) { const plot::Plot& p = figure.plot(row); const plot::Rect ra = p.layout().axes; std::printf("[multiplot] row %u x [%.6f, %.6f] s, axes %.0fx%.0f at (%.0f, %.0f)," - " traces %u, legend rows %u\n", + " traces %u, legend rows %u, in link group %u\n", row, p.x_range(0).min, p.x_range(0).max, static_cast(ra.w), static_cast(ra.h), static_cast(ra.x), static_cast(ra.y), - p.trace_count(), static_cast(p.layout().legend.rows.size())); + p.trace_count(), static_cast(p.layout().legend.rows.size()), + figure.x_link(row)); } std::printf("[multiplot] region %u: x [%g, %g] s, n %llu, mean %.4f, rms %.4f, min" " %.4f, max %.4f, integral %.4f\n", diff --git a/examples/qt_demo.cpp b/examples/qt_demo.cpp index 0fac443..71661a3 100644 --- a/examples/qt_demo.cpp +++ b/examples/qt_demo.cpp @@ -316,6 +316,11 @@ int main(int argc, char** argv) { const auto keep_focus = [widget] { widget->setFocus(); }; Meter meter; + /// The scripted input check's verdict, read after `exec()`: a FAIL that is only printed + /// is a check no caller can act on -- the exit status is what a headless run reads. Same + /// for the exit frame's screenshot: the headless mode exists to produce that file. + bool inputs_ok = true; + bool shot_ok = true; // The cursors ride the live window until the user takes over: the first // ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale, // stops them where the user put them. The region's bounds ride the same window. @@ -343,7 +348,12 @@ int main(int argc, char** argv) { const plot::Range xr = p.x_range(axis_x); p.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span()); p.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span()); - p.set_region(region, xr.min + 0.40 * xr.span(), xr.min + 0.60 * xr.span()); + // The left sixth of the window, not its middle: a region is a grabbable handle, so + // one laid across the centre turns the pan this demo's scripted input check makes + // (and the pan a user makes) into a region drag -- the same trap the GLFW demo hit. + // The cursors are not within a grab radius of the centre either, which is what + // keeps the check's press a pan. + p.set_region(region, xr.min + 0.06 * xr.span(), xr.min + 0.18 * xr.span()); } } @@ -460,7 +470,9 @@ int main(int argc, char** argv) { } if (screenshot != nullptr && screenshot[0] != '\0') { const bool written = dump_widget_ppm(image, screenshot); + shot_ok = written; std::printf("[qt_demo] screenshot %s: %s\n", screenshot, written ? "written" : "FAILED"); + if (!written) std::fprintf(stderr, "plot_qt_demo: the screenshot could not be written\n"); } std::printf("[qt_demo] init %s: %s\n", plot::to_string(widget->init_status().code), widget->init_status().message); @@ -534,9 +546,15 @@ int main(int argc, char** argv) { static_cast(check_axes.w), static_cast(check_axes.h)); std::printf("[qt_demo] input check box-zoom %s expected x [%.9f, %.9f], got [%.9f, %.9f]\n", box_ok ? "PASS" : "FAIL", box_expect_min, box_expect_max, box_got.min, box_got.max); + inputs_ok = box_ok && drag_ok; std::printf("[qt_demo] input check pan %s expected x [%.9f, %.9f], got [%.9f, %.9f]\n", drag_ok ? "PASS" : "FAIL", drag_expect_min, drag_expect_min + before_drag.span(), drag_got.min, drag_got.max); + if (!inputs_ok) { + std::fprintf(stderr, + "plot_qt_demo: the scripted input check failed (see the 'input check' lines" + " above)\n"); + } // The two gestures above left the plot zoomed and panned (that is what makes // them checks). Put the live framing back before the run summary reads the @@ -594,5 +612,6 @@ int main(int argc, char** argv) { // (so the plot's GL resources are released while their context is still alive, // and before `graph`, which the widget points at), then `graph`, then the // already-joined `producer`, then `stream` itself. - return app_result; + if (app_result != 0) return app_result; + return inputs_ok && shot_ok ? 0 : 1; } diff --git a/include/plot/plot/axis.hpp b/include/plot/plot/axis.hpp index c2649f7..cce4d7d 100644 --- a/include/plot/plot/axis.hpp +++ b/include/plot/plot/axis.hpp @@ -63,6 +63,19 @@ struct Axis { /// directly, and `Plot::layout()`'s placement, which re-clamps it against the /// retained window every frame. `Plot::x_offset()` reads it back. f64 offset = 0.0; + /// Set by an *owner* that is following this axis' window on the plot's behalf: a + /// `Figure` link group, while it follows, writes its own live edge here every frame. + /// A member's own `follow` must be `Off` (`Figure::link_x()` refuses a `Live` member + /// -- the group owns the shared window and its follow state), so without this the + /// input layer could not see that the axis is following: the controller's wheel + /// anchor would extrapolate a coordinate from the pointer instead of using the live + /// edge, and a zoom made from the axis strip would walk the window back off the edge. + /// + /// `live_owner_newest` is the *owner's* edge, not this plot's: rows may be fed at + /// different rates, and a group's edge is the union of its members'. Cleared when the + /// group stops following or the row leaves it. + bool live_owner = false; + f64 live_owner_newest = 0.0; /// Ticks for the last `refresh()`, ascending, majors and (when asked) minors. std::vector ticks; diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index 35bba35..17fe19e 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -213,6 +213,12 @@ class Figure { f64 offset = 0.0; XFollow follow = XFollow::Off; bool has_range = false; + /// The group's live edge, as the last `place_live_window()` found it, and whether it + /// found one at all. Published to every member by `push_link()` (`Plot::set_x_live_owner`) + /// so the input layer can see that the member's window is following even though the + /// member's own axis must be `Off`. + f64 live_newest = 0.0; + bool has_live = false; std::vector rows; std::vector pushed; }; diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 5e7136c..46f16f1 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -325,6 +325,14 @@ class Plot { /// zoom made from outside `axes`. False -- and `newest` untouched -- when the axis /// has no such trace, i.e. when there is no live edge to follow. bool x_live_edge(u32 axis, f64& newest) const; + /// Publishes -- or clears -- the live edge of an owner that is following this axis' + /// window on the plot's behalf (a `Figure` link group while it follows; see + /// `Axis::live_owner`). A no-op for an axis that does not exist. + void set_x_live_owner(u32 axis, bool live, f64 newest) noexcept; + /// Whether an owner is following this axis' window, and the edge it is following: the + /// answer `set_x_live_owner()` last wrote. False -- and `newest` untouched -- for an + /// axis no owner has claimed, and for an axis that does not exist. + bool x_live_owner(u32 axis, f64& newest) const noexcept; /// Data -> pixels for one axis, built from the axis' range, log flag and the /// current layout. Always invertible: a collapsed range or a collapsed axes diff --git a/src/input/controller.cpp b/src/input/controller.cpp index a695df5..8d46b47 100644 --- a/src/input/controller.cpp +++ b/src/input/controller.cpp @@ -564,10 +564,18 @@ f64 Controller::x_zoom_anchor(u32 axis, f32 px, f32 py) const { // off the plot keep the window ending at the newest sample instead of drifting // away from it. Any other axis keeps the extrapolated coordinate, and `Off` is // the default, so nothing changes for a plot that is not following. - if (!std::as_const(*plot_).layout().axes.contains(px, py) && - plot_->x_follow(axis) == XFollow::Live) { + // + // A figure row's axis is `Off` even while its window follows, because a link group + // owns the shared window *and* the follow state (`Figure::link_x()` refuses a `Live` + // member) -- so the axis' own follow flag is not the whole answer. A following group + // publishes its edge to every member (`Plot::x_live_owner()`), and that edge is read + // second: it is the group's -- the union of its members' newest x -- which is the + // coordinate the window actually ends at, and the member's own series would be a + // different answer in a figure whose rows are fed at different rates. + if (!std::as_const(*plot_).layout().axes.contains(px, py)) { f64 live = 0.0; - if (plot_->x_live_edge(axis, live)) return live; + if (plot_->x_follow(axis) == XFollow::Live && plot_->x_live_edge(axis, live)) return live; + if (plot_->x_live_owner(axis, live)) return live; } return plot_->x_transform(axis).to_data_x(static_cast(px)); } diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index 7959f0f..07acf9a 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -67,6 +67,10 @@ u32 Figure::x_link(u32 row) const noexcept { } void Figure::unlink_row(u32 row) noexcept { + // A row that leaves its group stops following anything, so the hint a following group + // published to it goes with the membership: leaving `has_live` set behind would leave a + // former member anchoring a zoom on an edge it no longer follows. + if (row < plots_.size()) plots_[row].set_x_live_owner(0u, false, 0.0); for (XLink& group : links_) { for (std::size_t i = 0; i < group.rows.size(); ++i) { if (group.rows[i] != row) continue; @@ -172,8 +176,12 @@ Status Figure::set_x_link_follow(u32 group, XFollow follow) { XLink& g = links_[group - 1u]; g.follow = follow; if (follow != XFollow::Live) { - // Disarming leaves the range exactly where it is, as it does on a single axis: - // the last live window becomes an ordinary stored range. + // Disarming leaves the range exactly where it is, as it does on a single axis: the + // last live window becomes an ordinary stored range. The live edge goes with the + // follow state -- nothing may anchor on an edge no group is following any more -- and + // this path does not go through `push_link()`, which is the other place the hint is + // written, so it is cleared here. + for (const u32 row : g.rows) plots_[row].set_x_live_owner(0u, false, 0.0); return Status{}; } // Arming starts from the span the group already holds -- as `Plot::set_x_follow()` @@ -220,6 +228,10 @@ bool Figure::live_edge(const XLink& group, Range& out) const { } bool Figure::place_live_window(XLink& group) { + // Every early return leaves the group with no live edge published: a group that is not + // following, one whose members hold nothing streaming, and one whose span is not a span + // all have no edge for a member's controller to anchor on. + group.has_live = false; if (group.follow != XFollow::Live) return false; Range live{}; if (!live_edge(group, live)) return false; @@ -234,13 +246,24 @@ bool Figure::place_live_window(XLink& group) { // An offset past that would put the window's right edge behind the oldest sample // left, i.e. over data that is gone. group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min)); + group.live_newest = live.max; + group.has_live = true; group.range = Range{live.max - group.offset - group.span, live.max - group.offset}; return true; } void Figure::push_link(XLink& group) { + // The member's window is the group's and the group is what follows, so the edge is + // published with it: a gesture that asks where the live edge is (the controller's wheel + // anchor, outside `axes`) reads the group's edge rather than extrapolating from the + // pointer, which is the difference between a zoom that keeps the window on the newest + // sample and one that walks it back. A group that is not following publishes "no owner", + // which is what clears the hint the frame after `set_x_link_follow(g, Off)`. + const bool owned = group.follow == XFollow::Live && group.has_live; for (std::size_t i = 0; i < group.rows.size(); ++i) { - plots_[group.rows[i]].set_x_range(0u, group.range); + Plot& p = plots_[group.rows[i]]; + p.set_x_range(0u, group.range); + p.set_x_live_owner(0u, owned, group.live_newest); group.pushed[i] = group.range; } } diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index a6c7c00..9dccba7 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -327,6 +327,18 @@ bool Plot::x_live_edge(u32 axis, f64& newest) const { return true; } +void Plot::set_x_live_owner(u32 axis, bool live, f64 newest) noexcept { + if (axis >= x_axes_.size()) return; + x_axes_[axis].live_owner = live; + x_axes_[axis].live_owner_newest = live ? newest : 0.0; +} + +bool Plot::x_live_owner(u32 axis, f64& newest) const noexcept { + if (axis >= x_axes_.size() || !x_axes_[axis].live_owner) return false; + newest = x_axes_[axis].live_owner_newest; + return true; +} + bool Plot::live_x_bounds(u32 axis, Range& out) const { bool any = false; for (const Trace& t : traces_) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d17190d..e6b487b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,6 +52,15 @@ if(TARGET plot::imgui) target_link_libraries(plot_tests PRIVATE plot::imgui imgui) endif() +# The GLFW host's figure-mode routing -- which row's controller a pointer pixel reaches -- +# is this project's logic too, and it needs no window: the cases drive `controller_at()` +# against the rects `Figure::layout()` computes. Guarded on the adapter's target for the +# same reason as the ImGui block above. +if(TARGET plot::glfw) + target_sources(plot_tests PRIVATE test_glfw_host.cpp) + target_link_libraries(plot_tests PRIVATE plot::glfw) +endif() + # The Qt adapter's pointer anchor is deliberately toolkit-free (see # adapters/qt6/qt_pointer.hpp), so this one piece of that adapter's translation is # tested without linking Qt -- and without `PLOT_BUILD_QT6`, since no Qt target has diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index 57bba76..7b17af2 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -1388,3 +1388,124 @@ PLOT_TEST(a_middle_drag_on_a_figures_bottom_row_zooms_the_shared_x_axis) { PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).span(), shared_before.span(), 1e-12); PLOT_CHECK_NEAR(figure.x_link_range(group).span(), shared_before.span(), 1e-12); } + +// The live edge a *link group* follows, seen by the input layer. +// +// A figure row's x axis is `XFollow::Off` by construction -- the group owns the shared +// window and its follow state, and `link_x()` refuses a `Live` member -- so before a +// following group published its edge, `Controller::x_zoom_anchor()` could not see that the +// axis was following at all. A wheel over the row's axis strip then anchored on the +// coordinate the pointer's x extrapolates to (the middle of the axes for a strip-centre +// pointer) rather than on the live edge, and the window walked back off the newest sample +// by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps +// the edge, and the hint that makes it possible exists exactly while a group follows. +PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) { + constexpr u32 kFigureRows = 3u; + constexpr f64 kWheelDx = 1.0 / 1000.0; + plot::data::StreamSeries series(1u << 12, 0.0, kWheelDx); + plot::f32 samples[512]; + for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast(i % 7u); + series.push(samples, 512u); + series.acquire(); + + plot::Figure figure(kFigureRows); + figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f}); + for (u32 row = 0; row < kFigureRows; ++row) { + plot::Plot& p = figure.plot(row); + p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"}); + PLOT_CHECK(p.trace_count() == 1u); + } + const u32 group = figure.add_x_link(); + for (u32 row = 0; row < kFigureRows; ++row) PLOT_CHECK(figure.link_x(row, group)); + PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.25})); + PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live)); + figure.layout(); + + const f64 newest = series.x_bounds().max; + PLOT_CHECK_NEAR(figure.x_link_range(group).max, newest, 1e-12); + for (u32 row = 0; row < kFigureRows; ++row) { + f64 edge = 0.0; + PLOT_CHECK(figure.plot(row).x_live_owner(0u, edge)); + PLOT_CHECK_NEAR(edge, newest, 1e-12); + // The member's own axis is still Off: the group is what follows. + PLOT_CHECK(figure.plot(row).x_follow(0u) == plot::XFollow::Off); + } + + const plot::PlotLayout& bottom = figure.plot(kFigureRows - 1u).layout(); + const f32 strip_x = bottom.axes.x + 0.5f * bottom.axes.w; + const f32 strip_y = bottom.x_label_area.y + 0.5f * bottom.x_label_area.h; + PLOT_CHECK(bottom.x_label_area.contains(strip_x, strip_y)); + const plot::Range before = figure.x_link_range(group); + + plot::input::Controller c(figure.plot(kFigureRows - 1u)); + c.set_viewport(figure.viewport()); + const plot::input::WheelEvent wheel{strip_x, strip_y, 0.0f, 1.0f, 0u}; + for (u32 i = 0; i < 3u; ++i) c.on_wheel(wheel); + figure.layout(); + + const plot::Range after = figure.x_link_range(group); + // The zoom reached the group's one window and narrowed it... + PLOT_CHECK(after.span() < before.span() * 0.99); + // ... and left it ending on the live edge, which is the anchor a gesture outside `axes` + // must use. Pre-fix this is `newest - 0.2 span` or so: the extrapolated anchor. + PLOT_CHECK_NEAR(after.max, newest, 1e-12); + for (u32 row = 0; row < kFigureRows; ++row) { + PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).max, after.max, 1e-12); + PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).min, after.min, 1e-12); + } +} + +PLOT_TEST(a_figures_live_edge_hint_is_published_only_while_the_group_follows) { + constexpr u32 kFigureRows = 2u; + plot::data::StreamSeries series(1u << 10, 0.0, 1.0 / 100.0); + plot::f32 samples[64]; + for (u32 i = 0; i < 64u; ++i) samples[i] = 1.0f; + series.push(samples, 64u); + series.acquire(); + + plot::Figure figure(kFigureRows); + figure.set_viewport({0.0f, 0.0f, 400.0f, 400.0f}); + for (u32 row = 0; row < kFigureRows; ++row) { + plot::Plot& p = figure.plot(row); + p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"}); + } + const u32 group = figure.add_x_link(); + for (u32 row = 0; row < kFigureRows; ++row) PLOT_CHECK(figure.link_x(row, group)); + PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.5})); + PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live)); + figure.layout(); + + f64 edge = 0.0; + PLOT_CHECK(figure.plot(0u).x_live_owner(0u, edge)); + PLOT_CHECK_NEAR(edge, series.x_bounds().max, 1e-12); + + // Disarming takes the edge away at once -- nothing may anchor on an edge no group is + // following -- and leaves the range exactly where it was. + const plot::Range held = figure.x_link_range(group); + PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Off)); + PLOT_CHECK(!figure.plot(0u).x_live_owner(0u, edge)); + PLOT_CHECK_NEAR(figure.x_link_range(group).max, held.max, 1e-12); + + // Arming publishes it again, and a row that leaves the group loses it. + PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live)); + figure.layout(); + PLOT_CHECK(figure.plot(1u).x_live_owner(0u, edge)); + PLOT_CHECK(figure.link_x(1u, 0u)); // back to the unlinked group + PLOT_CHECK(!figure.plot(1u).x_live_owner(0u, edge)); + PLOT_CHECK(figure.plot(0u).x_live_owner(0u, edge)); // row 0 is still in the group + + // A plot no figure owns is never claimed, `Live` or not: the hint is an owner's, and a + // single plot anchors on its own edge through `x_live_edge()`, which is unchanged. + plot::Plot solo; + solo.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + solo.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + solo.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"}); + solo.set_x_follow(0u, plot::XFollow::Live); + solo.layout(); + PLOT_CHECK(solo.x_follow(0u) == plot::XFollow::Live); + PLOT_CHECK(!solo.x_live_owner(0u, edge)); +} diff --git a/tests/test_glfw_host.cpp b/tests/test_glfw_host.cpp new file mode 100644 index 0000000..5cdac33 --- /dev/null +++ b/tests/test_glfw_host.cpp @@ -0,0 +1,101 @@ +// The GLFW host's event routing in figure mode: which row's controller a pointer pixel +// reaches. +// +// This is the adapter's own logic and not GLFW's -- a figure is several plots stacked in +// one framebuffer, and the host has to decide which of them a gesture was aimed at -- so it +// is tested here rather than through a window. Nothing below calls `run()`: the case needs +// no GL context, no display and no window, only the rects `Figure::layout()` computes and +// the controllers the host builds from them. +#include "harness.hpp" + +#include +#include + +namespace { + +/// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so +/// each row lays out as a plot rather than as an empty rect), handed to a host. +struct Routed { + explicit Routed(plot::u32 rows, plot::f32 gap = 0.0f) : figure(rows) { + figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f}); + figure.set_row_gap(gap); + for (plot::u32 row = 0; row < rows; ++row) { + plot::Plot& p = figure.plot(row); + p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + } + host.set_figure(&figure); + figure.layout(); + } + + plot::Figure figure; + plot::glfw::Host host; +}; + +} // namespace + +PLOT_TEST(the_host_routes_a_pixel_to_the_row_it_is_in) { + Routed r(3u); + const std::vector rows = r.figure.layout().rows; + PLOT_CHECK(rows.size() == 3u); + for (plot::u32 row = 0; row < 3u; ++row) { + PLOT_CHECK(r.host.row_controller(row) != nullptr); + const plot::f32 x = rows[row].x + 0.5f * rows[row].w; + const plot::f32 y = rows[row].y + 0.5f * rows[row].h; + PLOT_CHECK(&r.host.controller_at(x, y) == r.host.row_controller(row)); + } + // Above the first row and below the last one (the figure's own margins) are still the + // nearest row rather than a dropped event: a drag that leaves the data area must end + // where it should. + PLOT_CHECK(&r.host.controller_at(150.0f, 0.0f) == r.host.row_controller(0u)); + PLOT_CHECK(&r.host.controller_at(150.0f, 599.0f) == r.host.row_controller(2u)); +} + +PLOT_TEST(the_host_routes_a_pixel_in_a_row_gap_to_the_nearest_row) { + // A gap is what `set_row_gap()` asks for. Rows tile the figure at the default of 0, so + // this is the only way a pixel can land in none of them -- and the routing must then + // answer with the row it is *near*, not with row 0, which is what a "first row that + // matches, else whatever `row` started at" loop would do. + Routed r(3u, 40.0f); + const std::vector rows = r.figure.layout().rows; + PLOT_CHECK(rows.size() == 3u); + for (plot::u32 row = 0; row + 1u < 3u; ++row) { + const plot::f32 gap = rows[row + 1u].y - rows[row].bottom(); + PLOT_CHECK(gap > 0.0f); + const plot::f32 middle = rows[row].bottom() + 0.5f * gap; + // Just past a row's bottom edge the gap pixel is that row's... + PLOT_CHECK(&r.host.controller_at(150.0f, rows[row].bottom() + 1.0f) == + r.host.row_controller(row)); + // ... and just above the next row's top edge it is the next row's. + PLOT_CHECK(&r.host.controller_at(150.0f, rows[row + 1u].y - 1.0f) == + r.host.row_controller(row + 1u)); + // The middle of the gap goes to the nearer of the two rows -- and, whatever it picks, + // to a row of *this* figure rather than to a default: this is the assertion the + // "first match, else whatever `row` started at" loop failed, since it answered row 0 + // for every gap pixel. + plot::input::Controller& in_gap = r.host.controller_at(150.0f, middle); + PLOT_CHECK(&in_gap == r.host.row_controller(row) || &in_gap == r.host.row_controller(row + 1u)); + PLOT_CHECK(&in_gap != r.host.row_controller(2u) || row + 1u == 2u); + } +} + +PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) { + plot::glfw::Host host; + PLOT_CHECK(host.figure() == nullptr); + PLOT_CHECK(host.row_controller(0u) == nullptr); + // With no figure the routing is the single-plot loop's: one controller for every pixel. + PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller()); + PLOT_CHECK(&host.controller_at(1000.0f, 1000.0f) == &host.controller()); + + plot::Figure figure(2u); + figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f}); + host.set_figure(&figure); + PLOT_CHECK(host.figure() == &figure); + PLOT_CHECK(host.row_controller(0u) != nullptr); + PLOT_CHECK(host.row_controller(2u) == nullptr); // a row that does not exist + // And clearing it goes back to the single-plot answer. + host.set_figure(nullptr); + PLOT_CHECK(host.figure() == nullptr); + PLOT_CHECK(host.row_controller(0u) == nullptr); + PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller()); +}