From 354e79eea23b8bd9b88ad0c4c8fab8dc6e3fae48 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 13:47:35 +0200 Subject: [PATCH] feat(input): draggable cursors, regions and measurement-band edges --- examples/glfw_demo.cpp | 227 ++++++++++++++++++++++++++++++++++++++ tests/test_controller.cpp | 62 +++++++++++ 2 files changed, 289 insertions(+) diff --git a/examples/glfw_demo.cpp b/examples/glfw_demo.cpp index 3910311..7f7b2f1 100644 --- a/examples/glfw_demo.cpp +++ b/examples/glfw_demo.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -73,6 +74,19 @@ constexpr f64 kSpikeAmp = 3.0; constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF); constexpr plot::Color kCursorAColor = plot::Color::hex(0xFFB020); constexpr plot::Color kCursorBColor = plot::Color::hex(0x35D0A8); +/// The region the direct-manipulation script drags: a fill and an edge no other element +/// of the frame produces -- the trace is blue, the chrome grey, cursor A the accent and +/// cursor B a teal -- so a pixel count of one of them can only be about the region. The +/// two are the render smoke test's own colours, so its predicates read this screenshot +/// too. +constexpr plot::Color kRegionFill = plot::Color::hex(0x33FF00, 0.35f); +constexpr plot::Color kRegionEdge = plot::Color::hex(0xFF00FF); +/// The pan the region script takes, in framebuffer pixels, and the window it freezes the +/// view on, in seconds: 20 ms of this 1 MS/s stream is ~20 000 samples, wider than the +/// axes, so the trace is drawn from the pyramid and the region's interval is hundreds of +/// pixels across. +constexpr plot::u32 kRegionPanPx = 100u; +constexpr plot::f64 kRegionWindowS = 0.020; /// y(t) for sample index `i`: sine + deterministic noise + a two-sample spike. /// @@ -303,6 +317,177 @@ struct FollowScript { } }; +/// The region's geometry as `Plot::layout()` measured it -- the rects the renderer paints +/// and the controller hit-tests, so what this reports is what the frame drew -- or null +/// when the layout holds no such region. +const plot::RegionLayout* region_layout(const plot::Plot& p, plot::u32 region) { + for (const plot::RegionLayout& r : std::as_const(p).layout().regions) { + if (r.region == region) return &r; + } + return nullptr; +} + +/// The shaded interval a region occupies right now, or an empty rect when the layout +/// holds no such region. +plot::Rect region_fill(const plot::Plot& p, plot::u32 region) { + const plot::RegionLayout* r = region_layout(p, region); + return r != nullptr ? r->fill : plot::Rect{}; +} + +/// The scripted direct-manipulation sequence `PLOT_DEMO_REGIONS` runs: the region half of +/// spec ยง5, on a real host, in the shape the follow script above established. +/// +/// A region is geometry in *data* coordinates while what a pointer grabs and a frame +/// paints is its *pixel* rect, so the claims worth making on a live stream are that the +/// rect the layout measured is the rect the data moved, and that it tracks the view +/// rather than the screen. Three frames measure exactly that: +/// +/// (a) freeze the x view on a fixed window behind the live edge -- a user gesture does +/// the same by clearing auto scale, and it is what stops the live fit from sliding +/// the window under the region on every frame -- and place the region on a fraction +/// of what that window shows; +/// (b) pan 100 px with the press *outside* the region's interval, which is the press +/// that means "move the view", and check that the region's fill rect moved by those +/// 100 px and by nothing else, with its width untouched: the region is data-anchored, +/// so the pan moved the data *under* it and the region came along; +/// (c) leave the pointer on the region's left bound (`PLOT_DEMO_REGIONS=hot`) or off the +/// data area (any other value), which is the A/B the screenshot's emphasized handle +/// is counted from. +/// +/// The verdict is carried in `failed`, so `main()` exits non-zero when a check said no, +/// and the run's screenshot is what the pixel counts are read from. +struct RegionScript { + bool enabled = false; + /// Leave a bound hovered as the final pointer state: `PLOT_DEMO_REGIONS=hot`. Any other + /// value leaves the pointer where nothing is hot, so running twice -- with and without + /// `hot` -- is a pixel-level A/B of the emphasis. + bool hover = false; + bool failed = false; + /// Set once the third measurement has been taken: a run that ended before it has not + /// passed, it just has not finished. + bool completed = false; + plot::u32 step = 0; + plot::Rect before{}; + plot::Rect after{}; + plot::RegionGrab grab = plot::RegionGrab::None; + + static RegionScript from_env() { + RegionScript s; + const char* v = std::getenv("PLOT_DEMO_REGIONS"); + s.enabled = v != nullptr && v[0] != '\0' && v[0] != '0'; + s.hover = v != nullptr && std::strcmp(v, "hot") == 0; + return s; + } + + void advance(plot::glfw::Host& host, plot::data::StreamSeries& stream, plot::Plot& p, + plot::u32 axis, plot::u32 region) { + switch (step) { + case 0: { + // The producer starts while the window is being built, so the first frame can + // already hold every sample this needs -- and the renderer installs the font the + // strips and the labels are measured with on its *first* draw. Measuring the + // region's rect against a layout computed before that would measure the default + // strips, so the script waits for a frame the renderer has drawn. + if (stream.size() < 262144u || p.text_backend() == nullptr) return; + const plot::Range live = stream.x_bounds(); + p.set_x_range(axis, {live.max - kRegionWindowS, live.max}); + const plot::Range w = p.x_range(axis); + p.set_region(region, w.min + 0.30 * w.span(), w.min + 0.55 * w.span()); + p.layout(); + const plot::RegionLayout* rl = region_layout(p, region); + if (rl == nullptr) { + failed = true; + break; + } + before = rl->fill; + std::printf("[glfw_demo] region %u: frozen x [%.6f, %.6f] s, fill x %.1f w %.1f h" + " %.1f, bounds at %.1f and %.1f\n", + region, w.min, w.max, before.x, before.w, before.h, + static_cast(plot::handle_column(rl->left_handle)), + static_cast(plot::handle_column(rl->right_handle))); + break; + } + case 1: { + // The rect the pan starts from, from the settled layout of this frame rather than + // from the frame the region was placed on: the strip the chrome reserves settles + // with the tick labels the frozen window produces. + p.layout(); + before = region_fill(p, region); + const plot::RegionLayout* rl = region_layout(p, region); + if (rl == nullptr) { + failed = true; + break; + } + const plot::Rect axes = std::as_const(p).layout().axes; + // Left of the region and inside the axes: a press on no handle, so this is the + // pan and not a region grab. + const plot::f32 x = rl->fill.x - 40.0f; + const plot::f32 y = axes.y + 0.5f * axes.h; + const plot::f64 held = p.x_transform(axis).to_data_x(x); + host.controller().on_pointer( + {x, y, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false}); + const bool panned = host.controller().drag_mode() == plot::input::DragMode::Pan; + host.controller().on_pointer({x + static_cast(kRegionPanPx), y, + static_cast(kRegionPanPx), 0.0f, + plot::input::Button::Left, 0u, true, false, false}); + host.controller().on_pointer({x + static_cast(kRegionPanPx), y, 0.0f, 0.0f, + plot::input::Button::Left, 0u, false, false, true}); + p.layout(); + after = region_fill(p, region); + // The invariant the pan has always held, which is what makes a data-anchored + // region track the view: the data under the press is under the pointer afterwards. + const plot::f64 under = p.x_transform(axis).to_data_x(x + static_cast(kRegionPanPx)); + const plot::f64 delta = static_cast(after.x) - static_cast(before.x); + const bool tracked = std::fabs(delta - static_cast(kRegionPanPx)) <= 1.0; + const bool width_kept = std::fabs(static_cast(after.w) - + static_cast(before.w)) <= 0.5; + const bool data_kept = std::fabs(under - held) <= 1e-9; + failed = failed || !panned || !tracked || !width_kept || !data_kept; + std::printf("[glfw_demo] region pan %u px: drag mode %s, fill x %.1f -> %.1f (delta" + " %.1f, wanted %u), width %.1f -> %.1f, data under the press %.9f -> %.9f\n", + kRegionPanPx, panned ? "Pan" : "NOT PAN", before.x, after.x, delta, + kRegionPanPx, before.w, after.w, held, under); + break; + } + case 2: { + const plot::Rect axes = std::as_const(p).layout().axes; + if (hover) { + const plot::RegionLayout* rl = region_layout(p, region); + if (rl == nullptr) { + failed = true; + break; + } + const plot::f32 col = plot::handle_column(rl->left_handle); + host.controller().on_pointer({col, axes.y + 0.5f * axes.h, 0.0f, 0.0f, + plot::input::Button::Left, 0u, false, false, false}); + grab = host.controller().region_grab(region); + failed = failed || grab != plot::RegionGrab::Left; + } else { + // Inside the window, outside the data area: no handle is hot, so a frame drawn + // from here is the baseline the emphasized one is compared against. + const plot::Rect strip = std::as_const(p).layout().x_label_area; + host.controller().on_pointer({strip.x + 0.5f * strip.w, strip.y + 0.5f * strip.h, + 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, + false}); + grab = host.controller().region_grab(region); + failed = failed || grab != plot::RegionGrab::None; + } + const plot::HotHandles hot = p.hot_handles(); + std::printf("[glfw_demo] region final pointer: grab zone %u, hot region %llu, hot" + " zone %u, hovered cursor %llu\n", + static_cast(grab), static_cast(hot.region), + static_cast(hot.zone), + static_cast(hot.cursor)); + completed = true; + break; + } + default: + return; + } + ++step; + } +}; + } // namespace int main() { @@ -327,11 +512,23 @@ int main() { p.set_cursor_snap(cursor_b, plot::SnapMode::NearestSample); const u32 measure = p.add_measure(cursor_a, cursor_b, trace); + // The region the direct-manipulation script drags and the pixels are counted from. Its + // bounds are placed by the script, once the view it is measured against has been frozen. + plot::Region region_desc; + region_desc.snap = plot::RegionSnap::Free; + region_desc.fill = kRegionFill; + region_desc.edge = kRegionEdge; + region_desc.label = "roi"; + const u32 region = p.add_region(region_desc); + Meter meter; /// The scripted live-follow verification, off unless `PLOT_DEMO_FOLLOW` asks for /// it. It is the same shape as the headless smoke mode above: a driven run whose /// result is a few numbers on stdout. FollowScript script = FollowScript::from_env(); + /// The scripted region sequence, off unless `PLOT_DEMO_REGIONS` asks for it. Same shape + /// as the follow script: a driven run whose verdict is carried out in its `failed`. + RegionScript region_script = RegionScript::from_env(); // 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. Under `Live` the follow path owns the x @@ -343,6 +540,7 @@ int main() { stream.acquire(); // once per frame, on the render thread if (script.enabled) script.advance(host, stream, graph, axis_x); + if (region_script.enabled) region_script.advance(host, stream, graph, axis_x, region); // Re-frame the live stream while the user has not overridden the ranges, and // while the axis is not following. `auto_fit_*` re-arms auto scale, so it may @@ -420,6 +618,20 @@ int main() { // verification and did not get a passing one fails, whether a check said NO or the // sequence never got far enough to say anything (a deadline too short to reach the // fourth measurement -- a run that measured nothing has not passed). + if (region_script.enabled) { + if (region_script.failed) { + std::fprintf(stderr, + "plot_glfw_demo: the region checks failed (see the 'region' lines above)\n"); + return 1; + } + if (!region_script.completed) { + std::fprintf(stderr, + "plot_glfw_demo: the region script did not complete (raise" + " PLOT_DEMO_EXIT_AFTER_MS)\n"); + return 1; + } + } + if (script.enabled) { if (script.failed) { std::fprintf(stderr, @@ -476,6 +688,21 @@ int main() { stats.draw_calls, stats.lod_traces, stats.raw_traces); std::printf("[glfw_demo] measurement %s dx %g s dy %g rms %g mean %g\n", m.valid ? "valid:" : "INVALID", m.dx, m.dy, m.rms, m.mean); + std::printf("[glfw_demo] region x [%g, %g] s, snap %s, %s; checks %s\n", + p.region(region).x0, p.region(region).x1, + p.region(region).snap == plot::RegionSnap::Free ? "free" : "nearest sample", + region_script.enabled ? (region_script.hover ? "bound hovered at exit" : "pointer off the data area at exit") : "not driven", + region_script.enabled ? (region_script.failed ? "FAILED" : "passed") : "-"); + if (const plot::RegionLayout* rl = region_layout(p, region); rl != nullptr) { + // The last frame's geometry, as the layout measured it: the rects the fills, bounds + // and pixel counts all have to agree with. + std::printf("[glfw_demo] region fill x %.1f y %.1f w %.1f h %.1f, bounds at %.1f and" + " %.1f, tab w %.1f, label w %.1f\n", + rl->fill.x, rl->fill.y, rl->fill.w, rl->fill.h, + static_cast(plot::handle_column(rl->left_handle)), + static_cast(plot::handle_column(rl->right_handle)), + rl->left_handle.w, rl->label.w); + } std::printf("[glfw_demo] upload path %s, gl error on the last frame %d\n", r != nullptr && r->using_persistent_upload() ? "persistent-mapped ring" : "orphan", r != nullptr && r->last_frame_had_gl_error() ? 1 : 0); diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index e55bc71..c71da88 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -1093,3 +1093,65 @@ PLOT_TEST(band_edge_drag_moves_the_owning_cursor_and_updates_the_measurement) { PLOT_CHECK_NEAR(narrowed.ymax, 50.0, 1e-9); release(f.controller, to_b); } + +/// A region is geometry in *data* coordinates, and `XFollow::Live` recomputes the x window +/// on every layout -- so the two claims that matter under a live axis are that the region +/// stays exactly where it was put in data space while the view scrolls under it, and that +/// dragging one moves the region rather than being read as a change to the range. The +/// live path owns the range, so both halves are things it could plausibly break: a region +/// measured in pixels would drift with the window, and a drag that reached the range +/// setters would move the view *and* clear nothing good. +PLOT_TEST(region_holds_in_data_space_under_live_follow) { + LiveFixture f(40, 200); // a saturated ring: the window slides, its width does not + f.live(); + f.graph.layout(); + const f64 window_span = f.xr().span(); + const f64 x0 = f.xr().min + 0.25 * window_span; + const f64 x1 = f.xr().min + 0.65 * window_span; + const u32 rid = f.graph.add_region(free_region(x0, x1)); + PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0, 1e-12); + PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1, 1e-12); + const f32 left_before = bound_x(f.graph, rid, true); + const f32 right_before = bound_x(f.graph, rid, false); + PLOT_CHECK(left_before >= 0.0f && right_before > left_before); + + // The window advances on its own, which is what the follow path is for. + const f64 newest_before = f.newest(); + f.feed(3); + f.graph.layout(); + PLOT_CHECK(f.xr().max > newest_before); + PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); + // The region's own interval is untouched, to the last bit ... + PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0, 1e-12); + PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1, 1e-12); + // ... while the pixels it is drawn in moved: the view scrolled under a region that + // stayed in data space. + PLOT_CHECK(bound_x(f.graph, rid, true) < left_before); + PLOT_CHECK(bound_x(f.graph, rid, false) < right_before); + + // A body drag moves the region and nothing else: not the window, not the follow state. + const plot::Range held = f.xr(); + const f32 mid = 0.5f * (bound_x(f.graph, rid, true) + bound_x(f.graph, rid, false)); + const f32 to = mid + 40.0f; + const f64 delta = data_at(f.graph, f.ax, to) - data_at(f.graph, f.ax, mid); + press(f.controller, mid); + PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion); + PLOT_CHECK(f.controller.grabbed_region() == rid); + move_to(f.controller, to); + PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0 + delta, 1e-9); + PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1 + delta, 1e-9); + PLOT_CHECK_NEAR(f.xr().min, held.min, 1e-9); // the range the follow path owns is untouched + PLOT_CHECK_NEAR(f.xr().max, held.max, 1e-9); + PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); + release(f.controller, to); + PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); + PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0 + delta, 1e-9); + PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1 + delta, 1e-9); + // And the region is still where the pointer left it once more samples arrive: the + // window moves, the region does not. + f.feed(2); + f.graph.layout(); + PLOT_CHECK(f.xr().max > held.max); + PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0 + delta, 1e-12); + PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1 + delta, 1e-12); +}