`controller_at()` routed every pointer event by the row the pixel was in, ignoring the row a drag started in, so a press on one figure row followed by a motion and release in another delivered the motion and release to a controller whose `mode_ == None`. The owning controller kept `mode_`, `drag_region_`, `drag_cursor_` and `drag_fixed_x_` set for ever: `publish_hot()` kept publishing the grabbed handle, `grabbed_region()` reported a drag that had ended, and a later gesture re-entering the row resumed the stale edit from the old anchor. `feed_pointer_event()` now owns the routing: a press captures the row it landed in and every motion and the release until the button comes up go to that row (`pointer_target()`), cleared on release and when another figure is set. The GLFW callbacks build the event and call it, so a windowless host or a test can drive the routing too. Two neighbours in the same seam: * `sync_figure_crosshair()` called the four-argument overload unconditionally, so a documented vertical-only crosshair (`horizontal == false`) was promoted to a full cross at the stored y == 0, clamped to the axes edge and made permanent; the change detection also keyed on x alone, missing a y-only move to another row at the same x (two rows showing one horizontal line, spec 17). It now consults `crosshair().horizontal` and compares the hovered row's y. * Only the row under the pointer republished its hot handles, so a row the pointer left kept drawing a hovered marker or bound as if it were still under the pointer, and answered `hovered_region()`/`grabbed_cursor()` for it. The run loop clears every other row's handles, keeping the captured row while a drag owns the button, and reads the frame's one layout once instead of recomputing every row five times. Test: a press on row A's region, a motion into row B, a release in row B asserts no controller keeps a drag mode, nothing reports a grabbed handle, and a later press-and-motion that re-enters row A does not resume the old edit.
191 lines
8.9 KiB
C++
191 lines
8.9 KiB
C++
// 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 <glfw/glfw_host.hpp>
|
|
#include <plot/plot/figure.hpp>
|
|
|
|
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<plot::Rect> 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<plot::Rect> 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<plot::Rect> 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<plot::f32>(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);
|
|
}
|
|
|
|
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());
|
|
}
|