// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari // 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 #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); } } // --- a drag that crosses rows is still one drag ------------------------------ namespace { /// A two-row figure with a wide region on row 0, handed to a host: the fixture for the /// drag capture. The region is what a drag edits, so where it ends up -- and whether the /// owning controller still thinks it is dragging -- says whether the gesture that began on /// it was the gesture the release ended. struct DragRouted { DragRouted() : figure(2u) { figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f}); for (plot::u32 row = 0; row < 2u; ++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}); } plot::Region region; region.x0 = 0.25; region.x1 = 0.75; region.snap = plot::RegionSnap::Free; // the bounds are the test's, not a sample's region_id = figure.plot(0u).add_region(region); host.set_figure(&figure); figure.layout(); } plot::Figure figure; plot::glfw::Host host; plot::u32 region_id = 0u; }; } // namespace /// The finding: `controller_at()` routes *every* pointer event by the row the pixel is in, /// ignoring the row a drag began in. A press on row 0's region, a motion into row 1 and a /// release in row 1 therefore left row 0's controller with `MoveRegion`, `drag_region_`, /// `drag_cursor_` and `drag_fixed_x_` set for ever: it kept publishing the grabbed bound, /// `grabbed_region()` reported a drag that had ended, and a later gesture re-entering row 0 /// resumed the stale edit from the old anchor. The routing now captures the press's row for /// the button's whole life, so the motion and the release reach the controller that owns them. PLOT_TEST(a_drag_that_crosses_rows_is_ended_by_the_release_in_the_other_row) { DragRouted r; const std::vector rows = r.figure.layout().rows; PLOT_CHECK(rows.size() == 2u); plot::input::Controller& row0 = *r.host.row_controller(0u); plot::input::Controller& row1 = *r.host.row_controller(1u); // The screen x of data 0.5 on row 0's own x axis, and the middle of each row. const plot::f32 x = static_cast(r.figure.plot(0u).x_transform(0u).to_screen_x(0.5)); const plot::f32 y_a = rows[0].y + 0.5f * rows[0].h; const plot::f32 y_b = rows[1].y + 0.5f * rows[1].h; // Press on the region's body in row A. r.host.feed_pointer_event({x, y_a, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false}); PLOT_CHECK(row0.drag_mode() == plot::input::DragMode::MoveRegion); PLOT_CHECK(row0.grabbed_region() == r.region_id); // Move into row B with the button still down, then release there. r.host.feed_pointer_event( {x + 4.0f, y_b, 4.0f, y_b - y_a, plot::input::Button::Left, 0u, true, false, false}); r.host.feed_pointer_event( {x + 4.0f, y_b, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true}); // (a) No controller is left holding a drag mode -- the release ended the gesture on the // controller that began it. PLOT_CHECK(row0.drag_mode() == plot::input::DragMode::None); PLOT_CHECK(row1.drag_mode() == plot::input::DragMode::None); // (b) And nothing reports a grabbed handle any more. PLOT_CHECK(row0.grabbed_region() == plot::kInvalid); PLOT_CHECK(row0.grabbed_cursor() == plot::kInvalid); PLOT_CHECK(row1.grabbed_region() == plot::kInvalid); // (d) The region is where the release left it: the press below is in row B, so a motion // re-entering row A must not edit it again from the old anchor. (Before the fix row A's // stale `MoveRegion` resumed on exactly this motion and moved the region further.) const plot::Region after = r.figure.plot(0u).region(r.region_id); r.host.feed_pointer_event({x, y_b, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false}); r.host.feed_pointer_event({x + 20.0f, y_a, 20.0f, y_a - y_b, plot::input::Button::Left, 0u, true, false, false}); r.host.feed_pointer_event( {x + 20.0f, y_a, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true}); const plot::Region now = r.figure.plot(0u).region(r.region_id); PLOT_CHECK(now.x0 == after.x0); PLOT_CHECK(now.x1 == after.x1); PLOT_CHECK(row0.drag_mode() == plot::input::DragMode::None); } // --- a second button does not steal or end the captured row ----------------- namespace { /// A two-row figure handed to a host: the fixture for the capture's *button*. No /// region this time -- what the test observes is which controller a press lands /// on, and a pan is the easiest gesture to watch a plot's range move with. struct TwoRows { TwoRows() : figure(2u) { figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f}); for (plot::u32 row = 0; row < 2u; ++row) { plot::Plot& p = figure.plot(row); ax[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::f32 row_y(plot::u32 row) const { const plot::Rect r = figure.layout().rows[row]; return r.y + 0.5f * r.h; } plot::Figure figure; plot::glfw::Host host; plot::u32 ax[2] = {0u, 0u}; }; } // namespace /// The finding (R5): the capture remembered only the *row*, so a second button /// pressed while a drag ran stole nothing but its release still ended the capture /// -- `pointer_target()` cleared `captured_` on **any** release. A chord (or a /// trackpad that reports a second finger as a button) therefore stranded the first /// button's gesture in its row: the next event of the *first* button was routed by /// the pointer's position again, to a controller that never saw the gesture begin. /// The capture now records the button as well, and only that button's release ends /// it. /// /// What the test observes is purely the host's routing -- which controller a fresh /// press reaches -- and not the controller's drag modes, which a second button's /// press/release pair legitimately rewrites: press-and-move pairs pan whichever /// controller they are delivered to, so "whose range moved" says who got the events. PLOT_TEST(a_second_button_neither_steals_nor_ends_the_captured_row) { TwoRows r; plot::input::Controller& row1 = *r.host.row_controller(1u); const plot::f32 x = 150.0f; // Left press in row 0: the capture is row 0's. r.host.feed_pointer_event({x, r.row_y(0u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false}); // Right press in row 1 while the left gesture runs: the single-gesture policy -- // the capture is not stolen, so this press reaches row 0's controller too, and // row 1's controller sees nothing at all. r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Right, 0u, true, true, false}); PLOT_CHECK(row1.drag_mode() == plot::input::DragMode::None); // The right release must not end the left button's capture (the finding above): // only the owning button's release does. r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Right, 0u, false, false, true}); // The proof that the capture survived: a fresh LEFT press landing physically in // row 1 is still routed to the captured row 0... const plot::Range row0_before = r.figure.plot(0u).x_range(r.ax[0]); const plot::Range row1_before = r.figure.plot(1u).x_range(r.ax[1]); r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false}); r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 40.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false}); // ... so the drag it begins pans row 0 and leaves row 1 alone. (With the capture // gone, this press-and-move belongs to row 1 and the two assertions swap.) PLOT_CHECK(std::abs(r.figure.plot(0u).x_range(r.ax[0]).min - row0_before.min) > 1e-6); PLOT_CHECK(std::abs(r.figure.plot(1u).x_range(r.ax[1]).min - row1_before.min) < 1e-9); // The owning button's release ends the capture, wherever it lands... r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true}); // ... and the next press belongs to the row it lands in again: a fresh press in // row 1 now pans row 1, not row 0. const plot::Range row0_after = r.figure.plot(0u).x_range(r.ax[0]); r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false}); r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 40.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false}); PLOT_CHECK(std::abs(r.figure.plot(1u).x_range(r.ax[1]).min - row1_before.min) > 1e-6); PLOT_CHECK(std::abs(r.figure.plot(0u).x_range(r.ax[0]).min - row0_after.min) < 1e-9); r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true}); } // --- the row-rect cache ------------------------------------------------------ /// The finding (R7): `controller_at()` and `pointer_target()` read /// `Figure::layout()` *per event*, and a non-const `layout()` recomputes -- every /// pointer move on a multi-row figure re-laid every row out (ticks, grid, chrome /// and all), twice for the readers that share a handler. The host now keeps the /// rects of the last `refresh_row_rects()` and every handler reads that cache: the /// run loop refreshes it once per frame, `set_figure()` fills it immediately (so a /// scripted run routes against the figure it was just handed), and in between the /// rows the handlers answer from are the rows the last frame drew -- at most one /// frame stale across a resize, never a second layout. PLOT_TEST(row_routing_reads_the_cache_until_it_is_refreshed) { Routed r(3u); const plot::Rect row1_before = r.figure.layout().rows[1]; const plot::f32 y = row1_before.y + 0.5f * row1_before.h; PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(1u)); // Re-shape the figure without telling the host: the geometry the *next* frame // would draw. The cache still holds the 600-pixel-tall rows, and the handler // must keep answering from them -- with the old per-event layout the same pixel // already answered for the new geometry (row 2), which is exactly what made // every event pay for a relayout. r.figure.set_viewport({0.0f, 0.0f, 300.0f, 300.0f}); r.figure.layout(); PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(1u)); // The scripted-run equivalent of the frame: the loop's one refresh per frame. // From here the handlers answer with the new rects, and the pixel -- the old // row 1's centre, now the bottom edge of a 300-pixel figure -- is row 2's. r.host.refresh_row_rects(); PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(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()); } PLOT_TEST(glfw_host_routes_by_cell) { // A figure of two rows, the first of one cell and the second of two side by side: the // pointer's x as well as its y picks the plot, and the controllers are indexed by the // flattened plot. std::vector cells{1, 2}; plot::Figure figure{std::span(cells)}; figure.set_viewport(plot::Rect{0, 0, 300, 600}); figure.set_col_gap(6.0f); for (plot::u32 i = 0; i < 3; ++i) { figure.plot(i).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); } plot::glfw::Host host; host.set_figure(&figure); const std::vector c = figure.layout().cells; PLOT_CHECK(c.size() == 3u); for (plot::u32 i = 0; i < 3; ++i) { PLOT_CHECK(host.row_controller(i) != nullptr); const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5; PLOT_CHECK(&host.controller_at(static_cast(cx), static_cast(cy)) == host.row_controller(i)); } PLOT_CHECK(host.row_controller(3u) == nullptr); // in the column gap: the nearest cell wins PLOT_CHECK(&host.controller_at(c[1].right() + 1.0f, c[1].y + 5.0f) == host.row_controller(1)); PLOT_CHECK(&host.controller_at(c[2].x - 1.0f, c[2].y + 5.0f) == host.row_controller(2)); }