fix(input): cursors follow the pointer while dragging and snap on drop

This commit is contained in:
Martino Ferrari committed 2026-09-28 20:46:14 +02:00
1 parent ea70cf4fca
commit d2ba67921e
7 files changed
+292 -27

No files matched your search

+161 -11
View File
@@ -380,8 +380,8 @@ PLOT_TEST(grab_radius_scales_with_dpi) {
PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6);
const f32 sx = screen_x_of(p, ax, 50.0);
const f32 inside = 0.9f * 5.0f * scale; // logical: within the grab radius
const f32 outside = 1.1f * 5.0f * scale; // logical: just past it
const f32 inside = 0.9f * 7.0f * scale; // logical: within the grab radius
const f32 outside = 1.1f * 7.0f * scale; // logical: just past it
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false});
PLOT_CHECK(c.hovered_cursor() == cid);
@@ -831,6 +831,154 @@ PLOT_TEST(cursor_is_grabbed_without_a_modifier) {
release(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
}
/// A snapping cursor follows the pointer for the whole drag: the drag writes the free
/// pointer position (`CursorPlacement::Free`) and the snap is committed on the drop, so on a
/// *sparse* row -- where the samples are far apart -- the marker tracks the hand instead of
/// hopping between sample columns. This is the reported defect ("they stick to data in a
/// wired way") measured rather than asserted: every step is within a pixel of the pointer.
PLOT_TEST(cursor_follows_the_pointer_freely_while_dragging) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {0.0, 4.0}, false, plot::AxisPlace::Left, false});
std::vector<f32> ys{0.0f, 1.0f, 2.0f, 3.0f, 4.0f};
plot::data::ArraySeries s(std::move(ys), 0.0, 25.0); // 5 samples over [0, 100]
f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay});
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
f.plot.set_cursor_snap(cid, plot::SnapMode::NearestSample);
f.plot.layout();
const f32 start = screen_x_of(f.plot, f.ax, 50.0);
const f32 pitch = screen_x_of(f.plot, f.ax, 75.0) - start;
PLOT_CHECK(pitch > 100.0f); // the row really is sparse: one sample hop is over 100 px
press(f.controller, start);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
for (f32 d = 7.0f; d <= 0.9f * pitch; d += 7.0f) {
const f32 px = start + d;
move_to(f.controller, px);
const f32 cursor_px = column_of(f.plot, f.ax, f.plot.cursor(cid).x);
PLOT_CHECK(std::fabs(cursor_px - px) <= 1.0f); // tracks the pointer, not a sample column
}
release(f.controller, start + 0.9f * pitch);
}
/// The drop commits the snap: during the drag the stored x is the free pointer position and
/// equal to no sample of the grid, and after the release it is the nearest sample. Both
/// halves in one case, so the moment the snap runs is pinned rather than inferred.
PLOT_TEST(cursor_snaps_when_the_drag_ends) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {0.0, 4.0}, false, plot::AxisPlace::Left, false});
std::vector<f32> ys{0.0f, 1.0f, 2.0f, 3.0f, 4.0f};
plot::data::ArraySeries s(std::move(ys), 0.0, 25.0); // samples at x = 0,25,50,75,100
f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay});
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
f.plot.set_cursor_snap(cid, plot::SnapMode::NearestSample);
f.plot.layout();
const f32 start = screen_x_of(f.plot, f.ax, 50.0);
const f32 pitch = screen_x_of(f.plot, f.ax, 75.0) - start;
const f32 to = start + 0.55f * pitch; // between the 50 and 75 samples, nearer 75
press(f.controller, start);
move_to(f.controller, to);
const f64 mid = f.plot.cursor(cid).x;
PLOT_CHECK_NEAR(mid, data_at(f.plot, f.ax, to), 1e-9); // the free pointer position
PLOT_CHECK(std::fabs(mid - 50.0) > 1.0 && std::fabs(mid - 75.0) > 1.0); // on no sample
release(f.controller, to);
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, 75.0, 1e-9); // the drop landed on the grid
}
/// A cursor within the grab radius takes the press from a region's edge at the same point:
/// the hit test runs cursor-first, and both are hot, so what a press means is the cursor.
/// With the cursor clear of the bound the region wins its own edge back -- the precedence is
/// a *proximity* rule, not a blanket one that would brick the region.
PLOT_TEST(cursor_wins_the_grab_over_a_nearby_region_handle) {
Fixture f;
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);
// A region whose left bound sits 6 px from the marker -- inside the 7 px grab radius.
const f32 edge = marker + 6.0f;
const u32 rid = f.plot.add_region(
free_region(data_at(f.plot, f.ax, edge), data_at(f.plot, f.ax, edge + 80.0f)));
hover(f.controller, edge);
PLOT_CHECK(f.controller.hovered_cursor() == cid);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left); // both are hot
press(f.controller, edge);
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, edge);
// Move the cursor well clear: now no cursor is close, and the region's own edge wins.
f.plot.set_cursor_x(cid, data_at(f.plot, f.ax, marker - 200.0f));
hover(f.controller, edge);
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
press(f.controller, edge);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
PLOT_CHECK(f.controller.grabbed_region() == rid);
release(f.controller, edge);
}
/// The other half of the precedence rule: a region with no cursor near it is as draggable as
/// it ever was -- each zone still opens its own gesture, the edge is still an edge, and a
/// body drag still preserves the width exactly.
PLOT_TEST(a_region_without_a_cursor_nearby_is_still_draggable) {
Fixture f;
const u32 cid = f.plot.add_cursor(10.0, plot::Color::hex(0x00FF00)); // far from the region
const u32 rid = f.plot.add_region(free_region(40.0, 60.0));
const f32 left = bound_x(f.plot, rid, true);
const f32 right = bound_x(f.plot, rid, false);
const f32 mid = 0.5f * (left + right);
PLOT_CHECK(right > left);
hover(f.controller, left);
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
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);
const f64 width = f.plot.region(rid).x1 - f.plot.region(rid).x0;
press(f.controller, mid);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
move_to(f.controller, mid + 40.0f);
PLOT_CHECK_NEAR(f.plot.region(rid).x1 - f.plot.region(rid).x0, width, 1e-9); // moved, same width
release(f.controller, mid + 40.0f);
(void)cid;
}
/// The affordance is honest: the furthest press that *hovers* the cursor is exactly the
/// furthest that *grabs* it, because hover and grab read the one radius (`cursor_at()`).
/// One pixel past it neither does -- so there is no band where the highlight promises a grab
/// the press then refuses.
PLOT_TEST(hover_and_grab_use_the_same_radius) {
Fixture f;
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);
f32 radius = -1.0f;
for (f32 d = 0.0f; d <= 20.0f; d += 1.0f) {
hover(f.controller, marker + d);
if (f.controller.hovered_cursor() != cid) break;
radius = d;
}
PLOT_CHECK(radius >= 6.0f && radius <= 8.0f); // the reliable-grab range the brief asks for
press(f.controller, marker + radius);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
release(f.controller, marker + radius);
hover(f.controller, marker + radius + 1.0f);
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
press(f.controller, marker + radius + 1.0f);
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
release(f.controller, marker + radius + 1.0f);
}
/// Spec §14 on the real pointer path: a horizontal ruler is grabbed by the same hit test as a
/// marker -- no modifier, the same radius -- and the drag writes the coordinate its own
/// orientation owns. The ruler's y follows the pointer's row; its x stays the dead field it is;
@@ -908,7 +1056,7 @@ PLOT_TEST(horizontal_ruler_follows_the_pointer_on_an_inverted_y_range) {
/// Nearest-first resolution *across* orientations: a pointer within reach of both a marker and a
/// ruler takes the nearer of the two, so neither widens the other's target -- which is what lets
/// one hit test serve two instruments. Each press below is 1 px from its winner and 4 px from the
/// other, both inside the 5 logical px grab radius. What the winner's drag writes is its own
/// other, both inside the logical grab radius. What the winner's drag writes is its own
/// coordinate and nothing else.
PLOT_TEST(vertical_and_horizontal_cursors_do_not_confuse_each_other) {
Fixture f;
@@ -1339,14 +1487,14 @@ PLOT_TEST(region_snap_is_applied_once_on_release) {
/// A region the brief promises a pointer can still *move*: one whose bounds sit closer
/// together than the fixed grab radius allows on both sides. With that radius kept on each
/// bound, every point of a region narrower than twice it (10 logical px) lies within a
/// bound, every point of a region narrower than twice it (14 logical px) lies within a
/// bound's reach, so the edge branch always wins and the interior is unreachable -- such a
/// region could be resized but never moved, and the collapsed-region rescue only engages
/// below 3 px. The bands are therefore cut back until the middle is left, while a region
/// wide enough for two full bands and an interior keeps the radius it always had.
PLOT_TEST(narrow_region_stays_movable_by_its_body) {
Fixture f;
const f32 radius = f.plot.style().device(5.0f); // the controller's own grab radius, logical
const f32 radius = f.plot.style().device(7.0f); // the controller's own grab radius, logical
f32 column = 300.0f; // one strip of columns per region below
for (const f32 width_px : {4.0f, 6.0f, 9.0f}) {
@@ -1397,7 +1545,7 @@ PLOT_TEST(hot_handles_follow_a_gesture_that_moves_the_geometry) {
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
const u32 off_centre = f.plot.add_cursor(75.0, plot::Color::hex(0xFF00FF));
const u32 rid = f.plot.add_region(free_region(70.0, 90.0));
const f32 radius = f.plot.style().device(5.0f);
const f32 radius = f.plot.style().device(7.0f);
// The pointer sits four pixels to the right of the marker: on it, and published as such.
const f32 px = screen_x_of(f.plot, f.ax, 50.0) + 4.0f;
@@ -1405,15 +1553,17 @@ PLOT_TEST(hot_handles_follow_a_gesture_that_moves_the_geometry) {
PLOT_CHECK(f.plot.hot_handles().cursor == cid);
// A zoom *in* about the pointer grows the marker's offset from it by the zoom factor --
// 4 px becomes 6.3 -- so the marker leaves without the pointer moving at all.
f.controller.on_wheel({px, 200.0f, 0.0f, 3.0f, 0u});
// 4 px becomes 9.8 with six notches -- so the marker leaves without the pointer moving at
// all. Six rather than three: the grab radius is 7 px, and the point is a marker *out* of
// reach, so the zoom has to clear it comfortably.
f.controller.on_wheel({px, 200.0f, 0.0f, 6.0f, 0u});
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) > radius);
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid);
// And the other way round: a zoom out about the same column brings it back under the
// stationary pointer, where the published state has to be the marker again.
f.controller.on_wheel({px, 200.0f, 0.0f, -3.0f, 0u});
// And the other way round: the matching zoom out about the same column brings it back
// under the stationary pointer, where the published state has to be the marker again.
f.controller.on_wheel({px, 200.0f, 0.0f, -6.0f, 0u});
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) <= radius);
PLOT_CHECK(f.controller.hovered_cursor() == cid);
PLOT_CHECK(f.plot.hot_handles().cursor == cid);