fix(input): cursors follow the pointer while dragging and snap on drop
This commit is contained in:
1 parent
ea70cf4fca
commit
d2ba67921e
7 files changed
+292
-27
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user