From 211c5b97bc8430b58e9afe4b0ee414cce2ccc1b4 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 3 Oct 2026 10:29:13 +0200 Subject: [PATCH] feat(input): a cursor can be unselectable, for a host's readout marks A host that draws the result of a computation as a cursor -- a ruler at a level it measured, a marker at a time it found -- must not let the user drag it: the value belongs to the measurement, not to the pointer. Parking it outside the axis and re-placing it every frame does not achieve that, because a drag is held for the gesture's life and the marker follows the pointer between frames, so the readout *looks* draggable however often it is put back. `Cursor::selectable` defaults true; the controller's hit test skips an unselectable cursor, so it is neither hovered nor grabbed and its hit radius goes to whatever is behind it. Set through `Plot::set_cursor_selectable()`. Tested by `an_unselectable_cursor_is_neither_hovered_nor_grabbed` in tests/test_controller.cpp. --- include/plot/plot/cursor.hpp | 6 ++++++ include/plot/plot/plot.hpp | 4 ++++ src/input/controller.cpp | 4 +++- src/plot/plot.cpp | 5 +++++ tests/test_controller.cpp | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/include/plot/plot/cursor.hpp b/include/plot/plot/cursor.hpp index f3fe6e2..34d4844 100644 --- a/include/plot/plot/cursor.hpp +++ b/include/plot/plot/cursor.hpp @@ -86,6 +86,12 @@ struct Cursor { u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`. u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`. bool visible = true; + /// Whether the pointer may hit this cursor at all: `false` for a *readout* -- a marker a + /// host keeps to show something it computed, which the user must not be able to drag. The + /// controller's `cursor_at()` skips it, so it is neither hovered nor grabbed, and the + /// hit radius it would have taken is left to whatever is behind it. Drawn exactly as a + /// visible one is: this is about the gesture, not the ink. + bool selectable = true; /// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one /// place the orientation becomes a branch, so every consumer takes the same decision and diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 6bea531..c772ade 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -484,6 +484,10 @@ class Plot { /// be told apart from its neighbours, and its colour is how it says which readout it /// belongs to. An id that names no cursor is ignored, like every other setter here. void set_cursor_color(u32 id, Color color); + /// Makes a cursor unselectable (see `Cursor::selectable`): the pointer cannot hover or + /// grab it, which is what a host wants for a marker it draws as a *readout*. An id that + /// names no cursor is ignored, like every other setter here. + void set_cursor_selectable(u32 id, bool selectable); /// The measurement for `id`. Re-evaluated only when a cursor moved, when the /// measure was added, or when the series underneath it changed: a different diff --git a/src/input/controller.cpp b/src/input/controller.cpp index bc33eac..05f4b37 100644 --- a/src/input/controller.cpp +++ b/src/input/controller.cpp @@ -925,7 +925,9 @@ u64 Controller::cursor_at(f32 px, f32 py) const { f32 best_d = 0.0f; for (u32 i = 0; i < plot_->cursor_count(); ++i) { const Cursor& cursor = plot_->cursor(i); - if (!cursor.visible) continue; + // A readout is drawn but not offered to the pointer: skipping it here is what makes it + // undraggable, rather than fighting the drag a frame at a time. + if (!cursor.visible || !cursor.selectable) continue; // One hit test for both orientations, because a cursor *is* one line across the axes: // what the pointer has to be near is that line's own coordinate -- the column of a // vertical marker, the row of a horizontal ruler -- measured with the same radius diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 2b79539..f5de9d9 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -311,6 +311,11 @@ void Plot::set_cursor_color(u32 id, Color color) { cursors_[id].color = color; } +void Plot::set_cursor_selectable(u32 id, bool selectable) { + if (id >= cursors_.size()) return; + cursors_[id].selectable = selectable; +} + void Plot::set_annotation(u32 id, const Annotation& annotation) { if (id >= annotations_.size()) return; annotations_[id] = annotation; diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index 0e07373..6bea53d 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -2309,3 +2309,35 @@ PLOT_TEST(double_click_needs_both_timestamps_and_the_window) { f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 20.1}); } + +// A readout cursor is drawn but not offered to the pointer: the host that keeps it is showing +// a computed value, and a drag on it would be a drag on a number rather than on an instrument. +// The hit test is where that is enforced -- re-placing the marker every frame would only fight +// the drag, which is the shape this replaces. +PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) { + plot::Plot p; + p.set_viewport({0, 0, 800, 400}); + const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false}); + const u32 readout = p.add_cursor(50.0, plot::Color::hex(0x00FF00)); + p.set_cursor_selectable(readout, false); + PLOT_CHECK(p.cursor(readout).visible); // drawn all the same + PLOT_CHECK(!p.cursor(readout).selectable); + // A normal cursor right beside it stays grabbable, so the flag is a property of one cursor + // and not a switch on the plot. + const u32 normal = p.add_cursor(50.0, plot::Color::hex(0xFF0000)); + + plot::input::Controller c(p); + c.set_viewport({0, 0, 800, 400}); + const f32 sx = screen_x_of(p, ax, 50.0); + c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false}); + PLOT_CHECK(c.hovered_cursor() == normal); // the readout is not in the running + + // Press and move: the pointer grabbed the *normal* cursor (the two are at the same x, so + // the readout would otherwise be the nearer hit), and the readout's own x never moves. + const f64 before = p.cursor(readout).x; + c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, true, true, false}); + PLOT_CHECK(c.grabbed_cursor() == normal); + c.on_pointer({sx + 40, 200, 40, 0, plot::input::Button::Left, 0, true, false, false}); + PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12); + PLOT_CHECK(c.grabbed_cursor() == normal); +}