test(input): region, cursor and band-edge direct-manipulation cases (spec §5)

This commit is contained in:
Martino Ferrari committed 2026-09-28 13:42:26 +02:00
1 parent e4d48cc6c3
commit f423cf6f99
3 files changed
+819

No files matched your search

+372
View File
@@ -721,3 +721,375 @@ PLOT_TEST(live_gestures_survive_a_degenerate_range) {
PLOT_CHECK_NEAR(f.xr().max, f.newest() - f.graph.x_offset(f.ax), 1e-9);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
}
// --- direct manipulation: grabbable cursors, regions and band edges (spec §5) ---
namespace {
/// The x column a region's bound sits at, as `Plot::layout()` measured it -- the same
/// number the controller hit-tests and the renderer draws the bound in. Negative when
/// the layout holds no such region, which no caller here should see.
f32 bound_x(plot::Plot& p, u32 region, bool left) {
for (const plot::RegionLayout& r : p.layout().regions) {
if (r.region != region) continue;
return plot::handle_column(left ? r.left_handle : r.right_handle);
}
return -1.0f;
}
/// A region whose bounds are stored exactly as written: `Free` snap, so a drag's
/// endpoints are the numbers the gesture put there rather than the sample grid
/// `add_region()` pulls a snapping region onto.
plot::Region free_region(f64 x0, f64 x1) {
plot::Region r;
r.x0 = x0;
r.x1 = x1;
r.snap = plot::RegionSnap::Free;
return r;
}
/// The data x under one device-pixel column.
f64 data_at(const plot::Plot& p, u32 axis, f32 px) {
return p.x_transform(axis).to_data_x(static_cast<f64>(px));
}
/// The device column a data x is drawn at.
f32 column_of(const plot::Plot& p, u32 axis, f64 data) {
return static_cast<f32>(p.x_transform(axis).to_screen_x(data));
}
void hover(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, false, false, false});
}
void press(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, true, true, false});
}
void move_to(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, true, false, false});
}
void release(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, false, false, true});
}
/// Root-mean-square of the y values of the `y(i) == i` samples in [begin, end]: what
/// the band case's measurement has to match exactly.
f64 ramp_rms(u32 begin, u32 end) {
f64 sq = 0.0;
for (u32 i = begin; i <= end; ++i) sq += static_cast<f64>(i) * static_cast<f64>(i);
return std::sqrt(sq / static_cast<f64>(end - begin + 1u));
}
} // namespace
/// A marker is a handle like any other and needs no modifier to be moved: the hit
/// test runs before the pan is ever considered, so a press on a cursor is that
/// cursor's drag while a press anywhere else still pans. `Ctrl` is not a second
/// gesture -- it must not turn a press on a marker into anything else, which is the
/// regression to watch for.
PLOT_TEST(cursor_is_grabbed_without_a_modifier) {
Fixture f;
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
const f32 sx = screen_x_of(f.plot, f.ax, 50.0);
const f64 range_min = f.plot.x_range(f.ax).min;
press(f.controller, sx);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
// The plot publishes the same answers, for the renderer and for a host that wants
// a resize cursor or a status line of its own.
PLOT_CHECK(f.plot.hot_handles().cursor == cid);
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == cid);
move_to(f.controller, sx + 100.0f);
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, sx + 100.0f), 1e-9);
PLOT_CHECK(f.plot.cursor(cid).x > 50.0);
// The view did not pan: the gesture belonged to the marker, not to the ranges.
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, range_min, 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
release(f.controller, sx + 100.0f);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == plot::kInvalid);
// The same press with ctrl is the same gesture, not a different mode.
const f32 moved_to = column_of(f.plot, f.ax, f.plot.cursor(cid).x);
const f64 min_before = f.plot.x_range(f.ax).min;
press(f.controller, moved_to, 200.0f, kCtrl);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
move_to(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, moved_to - 40.0f), 1e-9);
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, min_before, 1e-9); // still no pan
release(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
}
/// The other half of "a superset of the old gesture": a press that is on no handle
/// reaches the pan exactly as it did before, with and without ctrl, and neither the
/// cursor nor the region under the view is disturbed.
PLOT_TEST(pressing_away_from_a_handle_still_pans) {
Fixture f;
const u32 cid = f.plot.add_cursor(20.0, plot::Color::hex(0x00FF00));
const u32 rid = f.plot.add_region(free_region(40.0, 60.0));
for (const u32 mods : {0u, kCtrl}) {
const f32 px = screen_x_of(f.plot, f.ax, 85.0);
const f64 grabbed = data_at(f.plot, f.ax, px);
const f64 min_before = f.plot.x_range(f.ax).min;
press(f.controller, px, 200.0f, mods);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid);
PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid);
PLOT_CHECK(f.plot.hot_handles().region == plot::kInvalid);
// The pan is driven by the event's own delta, so this motion has to report one:
// a host that sends an absolute position with dx = 0 has not moved the drag.
f.controller.on_pointer(
{px + 70.0f, 200.0f, 70.0f, 0.0f, plot::input::Button::Left, mods, true, false, false});
release(f.controller, px + 70.0f, 200.0f, mods);
// Grab-and-drag, the invariant the pan has always held: the data under the press
// is under the pointer afterwards.
PLOT_CHECK_NEAR(data_at(f.plot, f.ax, px + 70.0f), grabbed, 1e-9);
PLOT_CHECK(f.plot.x_range(f.ax).min < min_before);
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, 20.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 40.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 60.0, 1e-9);
}
}
/// Nearest-first resolution: a bound within the grab radius beats the body it sits
/// inside, and the body answers only away from both bounds. The bounds are *inside*
/// the fill, so a body-first hit test would report `Body` here and a region could
/// never be resized.
PLOT_TEST(region_edge_wins_over_region_body) {
Fixture f;
const u32 rid = f.plot.add_region(free_region(30.0, 70.0));
const f32 left = bound_x(f.plot, rid, true);
const f32 right = bound_x(f.plot, rid, false);
PLOT_CHECK(left >= 0.0f && right > left);
const f32 mid = 0.5f * (left + right);
// No pointer seen yet: nothing is on anything.
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::None);
hover(f.controller, mid);
PLOT_CHECK(f.controller.hovered_region() == rid);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
PLOT_CHECK(f.plot.hot_handles().region == rid);
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Body);
hover(f.controller, left);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Left);
hover(f.controller, right);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Right);
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Right);
// A couple of pixels inside the bound is still the bound, not the body.
hover(f.controller, left + 2.0f);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
// Just outside the fill nothing is hot, so the `Body` answer above is not an
// accident of treating a wide neighbourhood as the region.
hover(f.controller, left - 12.0f);
PLOT_CHECK(f.controller.hovered_region() == plot::kInvalid);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::None);
// Each bound opens the drag that moves that bound; the body opens the one that
// moves the interval.
press(f.controller, left);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
PLOT_CHECK(f.controller.grabbed_region() == rid);
release(f.controller, left);
press(f.controller, right);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionRight);
release(f.controller, right);
press(f.controller, mid);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
release(f.controller, mid);
PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid);
// A cursor marker is hit-tested before any region, so a press on it is still the
// cursor's drag.
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
const f32 marker = screen_x_of(f.plot, f.ax, 50.0);
hover(f.controller, marker);
// The region is under the pointer too -- the marker sits inside its interval, so the
// region's answer is `Body` -- and the cursor still wins the drag: the two hit tests
// are separate, and the cursor's is the one that decides the gesture.
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
PLOT_CHECK(f.controller.hovered_region() == rid);
press(f.controller, marker);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid);
release(f.controller, marker);
}
/// A body drag translates both bounds by the *same* data delta -- the width is
/// preserved exactly -- and each motion is a function of the pointer rather than a
/// step from the last one, so a pointer that returns to where it started returns the
/// region there too even when the host's reported deltas say otherwise.
PLOT_TEST(region_body_move_preserves_width) {
Fixture f;
const u32 rid = f.plot.add_region(free_region(30.0, 70.0));
const f64 width = f.plot.region(rid).x1 - f.plot.region(rid).x0;
const f32 mid = 0.5f * (bound_x(f.plot, rid, true) + bound_x(f.plot, rid, false));
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, mid + 120.0f);
const f64 delta = data_at(f.plot, f.ax, mid + 120.0f) - data_at(f.plot, f.ax, mid);
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 30.0 + delta, 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 70.0 + delta, 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x1 - f.plot.region(rid).x0, width, 1e-9);
PLOT_CHECK(f.plot.region(rid).x0 > 30.0);
// The deltas here are a lie -- 0, 0 while the pointer moved back -- and the bounds
// are rebuilt from the pointer's own coordinate, so nothing accumulates.
f.controller.on_pointer({mid, 200.0f, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false});
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 30.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 70.0, 1e-9);
release(f.controller, mid);
}
/// A bound dragged past its partner *swaps* the interval rather than inverting or
/// clamping it: the store keeps (min, max), so the region always has a positive
/// width, and the pointer keeps hold of the bound it grabbed -- which, having crossed
/// over, is now the other one.
PLOT_TEST(region_bounds_stay_ordered_when_dragged_past_each_other) {
Fixture f;
const u32 rid = f.plot.add_region(free_region(30.0, 70.0));
const f32 right = bound_x(f.plot, rid, false);
// Drag the right bound well past the left one.
const f32 to = column_of(f.plot, f.ax, 12.0);
const f64 want = data_at(f.plot, f.ax, to);
PLOT_CHECK(want < 30.0); // the drag really does cross the other bound
press(f.controller, right);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionRight);
move_to(f.controller, to);
PLOT_CHECK_NEAR(f.plot.region(rid).x0, want, 1e-9); // the dragged bound, now the lower one
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 30.0, 1e-9); // the one it was dragged past
PLOT_CHECK(f.plot.region(rid).x0 < f.plot.region(rid).x1);
// The grabbed bound is still the one being moved -- it is `x0` now, and the next
// motion moves it without the gesture having to be restarted.
const f32 back = column_of(f.plot, f.ax, 20.0);
move_to(f.controller, back);
PLOT_CHECK_NEAR(f.plot.region(rid).x0, data_at(f.plot, f.ax, back), 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 30.0, 1e-9);
release(f.controller, back);
// And the mirror image: the left bound dragged past the right one.
f.plot.set_region(rid, 30.0, 70.0);
const f32 left = bound_x(f.plot, rid, true);
const f32 far = column_of(f.plot, f.ax, 90.0);
const f64 want_far = data_at(f.plot, f.ax, far);
press(f.controller, left);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
move_to(f.controller, far);
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 70.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x1, want_far, 1e-9);
PLOT_CHECK(f.plot.region(rid).x0 < f.plot.region(rid).x1);
release(f.controller, far);
}
/// A region whose two bounds share a column cannot be grabbed by an edge -- an edge
/// would win the hit test every time and the region could never be moved again once
/// collapsed -- so it is grabbed by its body, which keeps it movable. The threshold is
/// the narrowest region whose bounds a pointer can tell apart: below it the body
/// answers, above it the bound does.
PLOT_TEST(collapsed_region_cannot_be_grabbed_by_an_edge) {
Fixture f;
const u32 rid = f.plot.add_region(free_region(50.0, 50.0));
const f32 col = bound_x(f.plot, rid, true);
PLOT_CHECK_NEAR(static_cast<f64>(col), static_cast<f64>(bound_x(f.plot, rid, false)), 1e-6);
hover(f.controller, col);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
press(f.controller, col);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
move_to(f.controller, col + 60.0f);
const f64 delta = data_at(f.plot, f.ax, col + 60.0f) - data_at(f.plot, f.ax, col);
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 50.0 + delta, 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 50.0 + delta, 1e-9); // moved, still collapsed
release(f.controller, col + 60.0f);
// Two pixels wide is still narrower than the minimum (3 logical px at scale 1): the
// same answer at either bound.
const u32 narrow = f.plot.add_region(free_region(data_at(f.plot, f.ax, 400.0f),
data_at(f.plot, f.ax, 402.0f)));
hover(f.controller, 400.0f);
PLOT_CHECK(f.controller.region_grab(narrow) == plot::RegionGrab::Body);
hover(f.controller, 401.0f);
PLOT_CHECK(f.controller.region_grab(narrow) == plot::RegionGrab::Body);
press(f.controller, 401.0f);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
release(f.controller, 401.0f);
// Six pixels is wide enough to tell the bounds apart, and there the bound wins.
const u32 wide = f.plot.add_region(free_region(data_at(f.plot, f.ax, 400.0f),
data_at(f.plot, f.ax, 406.0f)));
hover(f.controller, 400.0f);
PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Left);
hover(f.controller, 406.0f);
PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Right);
}
/// The shaded measurement band's two edges *are* the cursors that bound it, so
/// dragging one is the cursor drag with a different hit target: the grab maps to the
/// cursor owning that edge, the other edge stays where it was, and `Plot::measurement`
/// -- which the demo and the renderer both read -- follows as it already does.
PLOT_TEST(band_edge_drag_moves_the_owning_cursor_and_updates_the_measurement) {
constexpr u32 kSamples = 100u;
std::vector<f32> samples(kSamples);
for (u32 i = 0; i < kSamples; ++i) samples[i] = static_cast<f32>(i);
plot::data::ArraySeries s(std::move(samples), 0.0, 1.0);
Fixture f;
f.ay = f.plot.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
const u32 tr = f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay});
const u32 a = f.plot.add_cursor(10.0, plot::Color::hex(0x00FF00));
const u32 b = f.plot.add_cursor(60.0, plot::Color::hex(0xFF00FF));
const u32 m = f.plot.add_measure(a, b, tr);
PLOT_CHECK(f.plot.measurement(m).valid);
PLOT_CHECK_NEAR(f.plot.measurement(m).mean, 35.0, 1e-9); // samples 10..60
PLOT_CHECK_NEAR(f.plot.measurement(m).dx, 50.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.measurement(m).ymax, 60.0, 1e-9);
// The band's left edge is cursor `a` at its own column, and the drag moves that
// cursor -- not the band, not the view. The pointer lands *between* two samples and
// the readout is over the nearest sample to each cursor, which is the measurement's
// documented rule: a cursor at 20.3 measures from the sample at x == 20.
const f32 edge_a = screen_x_of(f.plot, f.ax, 10.0);
const f32 to = column_of(f.plot, f.ax, 20.3);
press(f.controller, edge_a);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == a);
move_to(f.controller, to);
PLOT_CHECK_NEAR(f.plot.cursor(a).x, 20.3, 1e-3); // the bound followed the pointer
PLOT_CHECK_NEAR(f.plot.cursor(b).x, 60.0, 1e-9); // the other edge did not move
const plot::Measurement moved = f.plot.measurement(m);
PLOT_CHECK(moved.valid);
PLOT_CHECK_NEAR(moved.dx, 40.0, 1e-9); // samples 20..60: 41 of them, dx == 1
PLOT_CHECK_NEAR(moved.mean, 40.0, 1e-9);
PLOT_CHECK_NEAR(moved.ymin, 20.0, 1e-9);
PLOT_CHECK_NEAR(moved.ymax, 60.0, 1e-9);
PLOT_CHECK_NEAR(moved.rms, ramp_rms(20u, 60u), 1e-9);
PLOT_CHECK_NEAR(moved.integral, static_cast<f64>((20 + 60) * 41 / 2), 1e-9);
release(f.controller, to);
// The right edge is cursor `b`, and grabbing it moves `b` alone.
const f32 edge_b = screen_x_of(f.plot, f.ax, 60.0);
const f32 to_b = column_of(f.plot, f.ax, 50.3);
press(f.controller, edge_b);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == b);
move_to(f.controller, to_b);
PLOT_CHECK_NEAR(f.plot.cursor(b).x, 50.3, 1e-3);
PLOT_CHECK_NEAR(f.plot.cursor(a).x, 20.3, 1e-3);
const plot::Measurement narrowed = f.plot.measurement(m);
PLOT_CHECK(narrowed.valid);
PLOT_CHECK_NEAR(narrowed.dx, 30.0, 1e-9); // samples 20..50: 31 of them
PLOT_CHECK_NEAR(narrowed.mean, 35.0, 1e-9);
PLOT_CHECK_NEAR(narrowed.ymin, 20.0, 1e-9);
PLOT_CHECK_NEAR(narrowed.ymax, 50.0, 1e-9);
release(f.controller, to_b);
}