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

+5 -2
View File
@@ -89,7 +89,10 @@ using plot::RegionGrab;
/// grabbed cursor's orientation owns -- `set_cursor_x()` for a marker, /// grabbed cursor's orientation owns -- `set_cursor_x()` for a marker,
/// `set_cursor_y()` for a ruler -- and a dragged ruler is clamped to its own y /// `set_cursor_y()` for a ruler -- and a dragged ruler is clamped to its own y
/// axis' range, because a pointer past the top or bottom of `axes` has no data /// axis' range, because a pointer past the top or bottom of `axes` has no data
/// under it to follow (see `move_cursor()`). /// under it to follow (see `move_cursor()`). The writes are *free*, so the marker
/// tracks the pointer for the whole gesture rather than hopping between the samples
/// it would otherwise snap to; the cursor's own snap rule is applied once, on
/// release (`Plot::snap_cursor()`), exactly as a region's is.
/// 2. **Regions next**, resolved nearest-first against the geometry `Plot::layout()` /// 2. **Regions next**, resolved nearest-first against the geometry `Plot::layout()`
/// measured: a bound within the grab radius beats the body (so the bound of a /// measured: a bound within the grab radius beats the body (so the bound of a
/// wide region is grabbable even though the pointer is inside the fill), the /// wide region is grabbable even though the pointer is inside the fill), the
@@ -161,7 +164,7 @@ using plot::RegionGrab;
/// The plot geometry the controller hit-tests in is device pixels (see /// The plot geometry the controller hit-tests in is device pixels (see
/// `PlotLayout`), and each adapter converts its pointer coordinates into the same /// `PlotLayout`), and each adapter converts its pointer coordinates into the same
/// space before forwarding them. A tolerance written as a device constant would /// space before forwarding them. A tolerance written as a device constant would
/// therefore change its *physical* size with the display: a 5 px grab radius is /// therefore change its *physical* size with the display: a 7 px grab radius is
/// half the hand movement on a 2x panel, so the same cursor becomes harder to /// half the hand movement on a 2x panel, so the same cursor becomes harder to
/// catch there. Both tolerances -- the cursor grab radius and the box-zoom click /// catch there. Both tolerances -- the cursor grab radius and the box-zoom click
/// threshold -- are therefore logical constants, converted through /// threshold -- are therefore logical constants, converted through
+17
View File
@@ -12,6 +12,23 @@ enum class SnapMode {
NearestSample, ///< the cursor is pulled onto the nearest sample of the trace's x grid. NearestSample, ///< the cursor is pulled onto the nearest sample of the trace's x grid.
}; };
/// How a setter stores the coordinate it is handed: the *act* of placing, not the
/// cursor's own state (which is `SnapMode`/`HRulerSnap`).
///
/// `Snapped` applies the cursor's own snap rule at once -- the default, and what a
/// programmatic placement wants: the coordinate a caller asks for is put on the grid the
/// cursor asked for. `Free` stores the value verbatim and leaves the snap to a later act
/// (`Plot::snap_cursor()`), which is what a *drag* wants: the marker follows the pointer
/// under the hand and lands on the grid only when the gesture ends, exactly as a region's
/// bounds do between `Plot::set_region()` and `Plot::snap_region()`. The two are told
/// apart by which act the caller means, not by a second copy of the snap state -- the
/// cursor still holds one `SnapMode` and one `HRulerSnap`, and `Free` here does not
/// change either.
enum class CursorPlacement {
Snapped, ///< apply the cursor's own snap rule now (the default).
Free, ///< store the coordinate verbatim; snap later, if at all.
};
/// Which of a cursor's two coordinates is live, and how it reads: the instrument /// Which of a cursor's two coordinates is live, and how it reads: the instrument
/// the plot has always had, and the same one turned a quarter turn (spec §14). /// the plot has always had, and the same one turned a quarter turn (spec §14).
/// ///
+30 -5
View File
@@ -388,19 +388,44 @@ class Plot {
// --- cursors and measurements ----------------------------------------- // --- cursors and measurements -----------------------------------------
/// Moves a **vertical** cursor along x, and invalidates the measurements that depend /// Moves a **vertical** cursor along x, and invalidates the measurements that depend
/// on cursors. A `SnapMode::NearestSample` cursor is pulled onto the nearest sample of /// on cursors. With the default `CursorPlacement::Snapped`, a `SnapMode::NearestSample`
/// the first visible trace on its x axis. /// cursor is pulled onto the nearest sample of the first visible trace on its x axis;
/// with `CursorPlacement::Free` the value is stored verbatim, whatever the snap, and the
/// grid is applied later by `snap_cursor()`.
///
/// The two are the two things a caller does with this method: place a marker
/// programmatically (default -- snap now), or move it under a pointer for the length of
/// a gesture (`Free`). `input::Controller::move_cursor()` is the second, so **the value
/// this stores while that drag runs, and the value `cursor(id).x` reports, is the free
/// pointer position, not the snapped one** -- the snap is committed once, on release
/// (`snap_cursor()`, from `Controller::end_drag()`). A caller reading `cursor(id).x`
/// mid-drag reads what the user sees under the pointer; after the drop it reads the
/// snapped result. A host that wants the cursor to *hop* between samples as it is
/// dragged -- the old behaviour -- calls this without `Free` on every motion.
/// ///
/// A horizontal ruler's `x` is not a coordinate of its: this is *ignored* for one, so /// A horizontal ruler's `x` is not a coordinate of its: this is *ignored* for one, so
/// the dead field of an orientation can never be written behind its back (see /// the dead field of an orientation can never be written behind its back (see
/// CursorOrientation). `set_cursor_y()` is the ruler's half of the pair. /// CursorOrientation). `set_cursor_y()` is the ruler's half of the pair.
void set_cursor_x(u32 id, f64 x); void set_cursor_x(u32 id, f64 x, CursorPlacement placement = CursorPlacement::Snapped);
/// Sets the snap of a **vertical** cursor, re-placing it under the new rule at once. /// Sets the snap of a **vertical** cursor, re-placing it under the new rule at once.
/// Ignored for a horizontal ruler, whose snap is `HRulerSnap` -- see /// Ignored for a horizontal ruler, whose snap is `HRulerSnap` -- see
/// `set_cursor_h_snap()`. /// `set_cursor_h_snap()`.
void set_cursor_snap(u32 id, SnapMode mode); void set_cursor_snap(u32 id, SnapMode mode);
/// Moves a **horizontal** ruler along y, re-placing it under its own snap rule. /// Applies a cursor's own snap rule to the coordinate it already holds: the nearest
/// sample for a `SnapMode::NearestSample` marker, the nearest y tick for a
/// `HRulerSnap::NearestTick` ruler, and nothing for a `Free` cursor of either
/// orientation. The one act `input::Controller::end_drag()` commits a cursor drag with,
/// so the drop lands on the grid -- the same shape as `snap_region()`, which a region
/// drag ends with, and the reason both orientations snap on drop rather than
/// continuously.
void snap_cursor(u32 id);
/// Moves a **horizontal** ruler along y, re-placing it under its own snap rule unless
/// `CursorPlacement::Free` is passed -- in which case the value is stored verbatim and
/// the snap waits for `snap_cursor()`, exactly as `set_cursor_x()`'s `Free` does for a
/// marker. The two orientations therefore snap at the *same* moment of a gesture (the
/// drop), not one continuously and the other on release.
/// ///
/// Ignored for a vertical cursor -- `y` is not its coordinate -- and stored verbatim for /// Ignored for a vertical cursor -- `y` is not its coordinate -- and stored verbatim for
/// a ruler, exactly as `set_cursor_x()` stores a `Free` cursor's x: a y outside the axis' /// a ruler, exactly as `set_cursor_x()` stores a `Free` cursor's x: a y outside the axis'
@@ -409,7 +434,7 @@ class Plot {
/// that has left the axes has no data under it, so `input::Controller` keeps a dragged /// that has left the axes has no data under it, so `input::Controller` keeps a dragged
/// ruler on its axis' range rather than storing a coordinate no pointer could reach /// ruler on its axis' range rather than storing a coordinate no pointer could reach
/// (see `Controller::move_cursor()`). /// (see `Controller::move_cursor()`).
void set_cursor_y(u32 id, f64 y); void set_cursor_y(u32 id, f64 y, CursorPlacement placement = CursorPlacement::Snapped);
/// Sets the snap of a **horizontal** ruler: `NearestTick` pulls its y onto the nearest /// Sets the snap of a **horizontal** ruler: `NearestTick` pulls its y onto the nearest
/// tick of its y axis' tick set -- majors and minors alike, the one list the axis /// tick of its y axis' tick set -- majors and minors alike, the one list the axis
/// refreshed -- and `Free` leaves it where it is. /// refreshed -- and `Free` leaves it where it is.
+20 -5
View File
@@ -28,15 +28,18 @@ constexpr f64 kZoomPerPixel = std::numbers::ln10 / 200.0;
/// radius the comparison needs through `Style::device()` at the plot's own scale /// radius the comparison needs through `Style::device()` at the plot's own scale
/// (see `cursor_at()`). An unscaled constant would make grabbing physically harder /// (see `cursor_at()`). An unscaled constant would make grabbing physically harder
/// -- half as forgiving -- on a 2x display, which is the defect the scale exists to /// -- half as forgiving -- on a 2x display, which is the defect the scale exists to
/// prevent. /// prevent. Comfortably wider than a marker's 1 px stroke, so a press lands on the
constexpr f32 kCursorHitRadiusLogical = 5.0f; /// handle the hover highlight promised: `cursor_at()` is the *one* value both the
/// hover and the grab read, and a region's edge bands are cut back from this same
/// number, so the affordance the host sees and the gesture it gets agree.
constexpr f32 kCursorHitRadiusLogical = 7.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for /// A box-zoom selection narrower than this on either side is a click. Logical for
/// the same reason: the gesture is the same hand movement on any display. /// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f; constexpr f32 kMinBoxLogical = 4.0f;
/// The narrowest *interior* a region's body keeps, in **logical** pixels: the edge bands are /// The narrowest *interior* a region's body keeps, in **logical** pixels: the edge bands are
/// cut back until this much of the middle is left, so a region too narrow for the full grab /// cut back until this much of the middle is left, so a region too narrow for the full grab
/// radius on each side is still something a pointer can take hold of and *move*. Without it /// radius on each side is still something a pointer can take hold of and *move*. Without it
/// every interior point of a region narrower than twice the radius (10 logical px) lies /// every interior point of a region narrower than twice the radius (14 logical px) lies
/// within a bound's reach, and the bound branch would always win -- such a region could be /// within a bound's reach, and the bound branch would always win -- such a region could be
/// resized but never moved. Half of it either side of the midpoint, which is what makes the /// resized but never moved. Half of it either side of the midpoint, which is what makes the
/// middle of even the narrowest region with grabbable bounds (3 logical px) reachable. /// middle of even the narrowest region with grabbable bounds (3 logical px) reachable.
@@ -457,6 +460,10 @@ void Controller::end_drag(const PointerEvent& e) {
// the drag itself stores them verbatim, so they follow the pointer instead of // the drag itself stores them verbatim, so they follow the pointer instead of
// sticking to the sample grid as it moves (see Plot::snap_region()). // sticking to the sample grid as it moves (see Plot::snap_region()).
if (drag_region_ != kInvalid) plot_->snap_region(static_cast<u32>(drag_region_)); if (drag_region_ != kInvalid) plot_->snap_region(static_cast<u32>(drag_region_));
// And the same for a grabbed cursor: `move_cursor()` wrote the free pointer position
// for the whole gesture, so this is where the marker lands on its own snap rule --
// the drop commits the snap, both orientations alike (see Plot::snap_cursor()).
if (drag_cursor_ != kInvalid) plot_->snap_cursor(static_cast<u32>(drag_cursor_));
mode_ = DragMode::None; mode_ = DragMode::None;
selection_ = Rect{}; selection_ = Rect{};
drag_cursor_ = kInvalid; drag_cursor_ = kInvalid;
@@ -539,6 +546,12 @@ void Controller::move_cursor(const PointerEvent& e) {
// A grabbed cursor mutates the coordinate its own orientation owns and nothing else, and // A grabbed cursor mutates the coordinate its own orientation owns and nothing else, and
// each half names the one setter for it -- so a ruler dragged along y can never disturb a // each half names the one setter for it -- so a ruler dragged along y can never disturb a
// marker's x, and the dead coordinate of either is untouched by construction. // marker's x, and the dead coordinate of either is untouched by construction.
//
// Both writes are `CursorPlacement::Free`: the marker follows the pointer for the whole
// gesture and lands on its grid only when the drag ends (`end_drag()` calls
// `Plot::snap_cursor()`), so a snapping cursor tracks the hand instead of hopping between
// samples -- the defect reported from use. A caller reading `cursor(id).x` / `.y` while
// the drag runs reads that free position.
if (cursor.is_horizontal()) { if (cursor.is_horizontal()) {
// Clamped to the ruler's own y axis: a pointer that has left the axes has no data under // Clamped to the ruler's own y axis: a pointer that has left the axes has no data under
// it, and the coordinate it extrapolates to is past the end of the axis the ruler reads // it, and the coordinate it extrapolates to is past the end of the axis the ruler reads
@@ -553,10 +566,12 @@ void Controller::move_cursor(const PointerEvent& e) {
// the transform widens it. Order the bounds rather than assuming `min <= max`, or an // the transform widens it. Order the bounds rather than assuming `min <= max`, or an
// inverted axis would collapse the dragged ruler onto one end (and the call is UB). // inverted axis would collapse the dragged ruler onto one end (and the call is UB).
plot_->set_cursor_y(id, std::clamp(y, std::min(axis.range.min, axis.range.max), plot_->set_cursor_y(id, std::clamp(y, std::min(axis.range.min, axis.range.max),
std::max(axis.range.min, axis.range.max))); std::max(axis.range.min, axis.range.max)),
CursorPlacement::Free);
return; return;
} }
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x))); plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)),
CursorPlacement::Free);
} }
void Controller::move_region(const PointerEvent& e) { void Controller::move_region(const PointerEvent& e) {
+22 -4
View File
@@ -571,7 +571,7 @@ void Plot::autoscale_axes() {
} }
} }
void Plot::set_cursor_x(u32 id, f64 x) { void Plot::set_cursor_x(u32 id, f64 x, CursorPlacement placement) {
if (id >= cursors_.size()) return; if (id >= cursors_.size()) return;
Cursor& cursor = cursors_[id]; Cursor& cursor = cursors_[id];
// `x` is the vertical cursor's coordinate and nobody else's: a ruler's `x` is dead, and // `x` is the vertical cursor's coordinate and nobody else's: a ruler's `x` is dead, and
@@ -579,10 +579,26 @@ void Plot::set_cursor_x(u32 id, f64 x) {
// cursor is (see CursorOrientation). // cursor is (see CursorOrientation).
if (cursor.is_horizontal()) return; if (cursor.is_horizontal()) return;
cursor.x = x; cursor.x = x;
cursor.x = snapped_x(cursor); // `Free` stores the value and stops: a drag moves the marker under the pointer and the
// snap is a separate act (`snap_cursor()`), which is what keeps the two orientations
// agreeing about *when* they land on their grid -- see CursorPlacement.
if (placement == CursorPlacement::Snapped) cursor.x = snapped_x(cursor);
mark_measures_dirty(); mark_measures_dirty();
} }
void Plot::snap_cursor(u32 id) {
if (id >= cursors_.size()) return;
const Cursor& cursor = cursors_[id];
// One act, the orientation's own rule: the ruler's half of the same shape a region's
// `snap_region()` has. Both setters default to `Snapped`, so re-storing the coordinate
// the cursor already holds is what applies the rule.
if (cursor.is_horizontal()) {
set_cursor_y(id, cursor.y);
} else {
set_cursor_x(id, cursor.x);
}
}
void Plot::set_cursor_snap(u32 id, SnapMode mode) { void Plot::set_cursor_snap(u32 id, SnapMode mode) {
if (id >= cursors_.size()) return; if (id >= cursors_.size()) return;
Cursor& cursor = cursors_[id]; Cursor& cursor = cursors_[id];
@@ -595,12 +611,14 @@ void Plot::set_cursor_snap(u32 id, SnapMode mode) {
mark_measures_dirty(); mark_measures_dirty();
} }
void Plot::set_cursor_y(u32 id, f64 y) { void Plot::set_cursor_y(u32 id, f64 y, CursorPlacement placement) {
if (id >= cursors_.size()) return; if (id >= cursors_.size()) return;
Cursor& ruler = cursors_[id]; Cursor& ruler = cursors_[id];
if (!ruler.is_horizontal()) return; if (!ruler.is_horizontal()) return;
ruler.y = y; ruler.y = y;
ruler.y = snapped_y(ruler); // As `set_cursor_x()`: `Free` is the drag's half of the gesture, and `snap_cursor()`
// commits the ruler's own rule on release.
if (placement == CursorPlacement::Snapped) ruler.y = snapped_y(ruler);
// No mark_measures_dirty(), for `add_h_ruler()`'s reason: a measurement is read from two // No mark_measures_dirty(), for `add_h_ruler()`'s reason: a measurement is read from two
// x coordinates, and a ruler's y is not one of them. // x coordinates, and a ruler's y is not one of them.
} }
+161 -11
View File
@@ -380,8 +380,8 @@ PLOT_TEST(grab_radius_scales_with_dpi) {
PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6); PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6);
const f32 sx = screen_x_of(p, ax, 50.0); 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 inside = 0.9f * 7.0f * scale; // logical: within the grab radius
const f32 outside = 1.1f * 5.0f * scale; // logical: just past it 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, c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false}); false});
PLOT_CHECK(c.hovered_cursor() == cid); 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); 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 /// 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 /// 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; /// 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 /// 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 /// 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 /// 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. /// coordinate and nothing else.
PLOT_TEST(vertical_and_horizontal_cursors_do_not_confuse_each_other) { PLOT_TEST(vertical_and_horizontal_cursors_do_not_confuse_each_other) {
Fixture f; 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 /// 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 /// 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 /// 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 /// 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 /// 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. /// wide enough for two full bands and an interior keeps the radius it always had.
PLOT_TEST(narrow_region_stays_movable_by_its_body) { PLOT_TEST(narrow_region_stays_movable_by_its_body) {
Fixture f; 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 f32 column = 300.0f; // one strip of columns per region below
for (const f32 width_px : {4.0f, 6.0f, 9.0f}) { 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 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 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 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. // 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; 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); 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 -- // 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. // 4 px becomes 9.8 with six notches -- so the marker leaves without the pointer moving at
f.controller.on_wheel({px, 200.0f, 0.0f, 3.0f, 0u}); // 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(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) > radius);
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid); PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
PLOT_CHECK(f.plot.hot_handles().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 // And the other way round: the matching zoom out about the same column brings it back
// stationary pointer, where the published state has to be the marker again. // 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}); 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(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) <= radius);
PLOT_CHECK(f.controller.hovered_cursor() == cid); PLOT_CHECK(f.controller.hovered_cursor() == cid);
PLOT_CHECK(f.plot.hot_handles().cursor == cid); PLOT_CHECK(f.plot.hot_handles().cursor == cid);
+37
View File
@@ -552,6 +552,43 @@ PLOT_TEST(a_cursor_owns_only_the_coordinate_and_snap_of_its_orientation) {
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.0, 1e-12); PLOT_CHECK_NEAR(p.cursor(marker).x, 12.0, 1e-12);
} }
/// The setter's two acts, and the explicit commit between them: `CursorPlacement::Free` stores
/// the coordinate verbatim even on a `NearestSample` cursor -- the value a drag writes under the
/// pointer -- and `snap_cursor()` is what later puts it on the grid. The default is the other
/// act (snapping now), so a programmatic placement is unchanged; a ruler's commit uses its own
/// rule, which is what makes the two orientations snap at the same moment of a gesture.
PLOT_TEST(free_placement_defers_the_snap_until_snap_cursor) {
plot::data::ArraySeries s(ramp(100), 0.0, 1.0); // samples at x = 0, 1, 2, ...
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {0.0, 99.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
const u32 marker = p.add_cursor(12.0, plot::Color::hex(0xFFB020));
p.set_cursor_snap(marker, plot::SnapMode::NearestSample);
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.0, 1e-12);
// Free: the value is kept, and it is on no sample of the grid.
p.set_cursor_x(marker, 12.6, plot::CursorPlacement::Free);
PLOT_CHECK_NEAR(p.cursor(marker).x, 12.6, 1e-12);
// The commit applies the cursor's own rule -- the nearest sample -- exactly once.
p.snap_cursor(marker);
PLOT_CHECK_NEAR(p.cursor(marker).x, 13.0, 1e-12);
// The default placement snaps at once, so the free act is an opt-in, not a new default.
p.set_cursor_x(marker, 20.6);
PLOT_CHECK_NEAR(p.cursor(marker).x, 21.0, 1e-12);
// A ruler's commit is its own rule: a Free y is kept, and `snap_cursor()` is a no-op on a
// `Free` ruler -- there is no sample grid a y coordinate could land on.
const u32 ruler = p.add_h_ruler(50.0, plot::Color::hex(0x35D0A8));
p.set_cursor_y(ruler, 33.37, plot::CursorPlacement::Free);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 33.37, 1e-12);
p.snap_cursor(ruler);
PLOT_CHECK_NEAR(p.cursor(ruler).y, 33.37, 1e-12);
}
/// `NearestTick` lands on a tick of the ruler's own y axis, and `Free` stores the value it was /// `NearestTick` lands on a tick of the ruler's own y axis, and `Free` stores the value it was
/// handed -- so the two modes are told apart by what the store holds, not by a flag. The tick set /// handed -- so the two modes are told apart by what the store holds, not by a flag. The tick set
/// is the one `layout()` refreshed: ticks are generated there and nowhere else, which is why a /// is the one `layout()` refreshed: ticks are generated there and nowhere else, which is why a