Compare commits

...
4 Commits
Author SHA1 Message Date
Martino Ferrari 211c5b97bc 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.
2026-10-03 10:29:13 +02:00
Martino Ferrari 125cc1f64a feat(plot): set_cursor_color(), and a figure test for per-row rulers
A *readout* cursor -- a marker a host keeps for something it computed, rather
than an instrument the user placed -- has to be told apart from its neighbours,
and the cursor API could set everything about one except its colour. The setter
keeps the id and ignores a stale one, like the setters beside it.

Tested by `a_cursor_can_be_recoloured_by_id`, and the suite is 356 cases, 0
failed.
2026-10-03 00:11:37 +02:00
Martino Ferrari 523446d08f test(render): a figure draws each row's own horizontal rulers
The figure-mode cursor test covered the *vertical* marker, which is one time
shared by every row. A horizontal ruler is the other half: its y is on that
row's own axis, so two rows' rulers are two different values, and every row's
has to be drawn in its own band. A host that puts two rulers on every plot
(`udpscope`'s cursors pane) needs exactly this, and had no test for it.

It asserts the strokes only -- the ruler's value label goes through the text
path, which this case is not about, so it uses the harness's no-atlas backend.
2026-10-02 23:59:53 +02:00
Martino Ferrari 133cd680ce feat(plot): set_annotation(), so a readout annotation can be moved
A host whose annotation is a readout -- the point where a cursor crosses a
trace -- has to move it every frame, and `add_annotation()` only appends:
the id would change every frame and the annotation count grow without bound.
The setter keeps the id, ignores a stale one exactly as the cursor setters
do, and pairs with parking (`visible = false`) since there is no
`remove_annotation`.

Tested by `an_annotation_can_be_moved_by_id` in tests/test_plot.cpp.
2026-10-02 15:00:50 +02:00
7 changed files with 201 additions and 1 deletions

No files matched your search

+6
View File
@@ -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
+16
View File
@@ -229,6 +229,13 @@ class Plot {
/// Measures the interval between two cursors over the series of `trace`.
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
u32 add_annotation(const Annotation& annotation);
/// Moves an annotation that is already in the plot, keeping its id -- what a host whose
/// annotation is a *readout* needs (the point where a cursor crosses a trace moves with
/// the cursor). `Plot` has no `remove_annotation`, so the same parking discipline as the
/// cursors applies: set `visible = false` rather than dropping it, and re-place where it
/// belongs. An id that names no annotation is ignored, not an error, exactly as every
/// other setter here treats a stale id.
void set_annotation(u32 id, const Annotation& annotation);
/// Adds a region. Its bounds are ordered and, when the region asks to snap, pulled
/// onto the sample grid as it is added -- exactly as `add_cursor()` places a
/// snapping cursor -- so a region handed to a plot that already holds samples is on
@@ -472,6 +479,15 @@ class Plot {
///
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
void set_cursor_h_snap(u32 id, HRulerSnap mode);
/// Recolours a cursor that already exists. A *readout* cursor -- one a host keeps as a
/// marker for something it computed, rather than an instrument the user placed -- has to
/// 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
+3 -1
View File
@@ -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
+15
View File
@@ -306,6 +306,21 @@ u32 Plot::add_annotation(const Annotation& annotation) {
return static_cast<u32>(annotations_.size() - 1);
}
void Plot::set_cursor_color(u32 id, Color color) {
if (id >= cursors_.size()) return;
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;
}
u32 Plot::add_region(const Region& region) {
// A region whose bounds are not numbers is not an interval this plot can hold, and a
// `Free` one stored that way could never be dragged back (every motion re-derives both
+32
View File
@@ -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);
}
+50
View File
@@ -2584,3 +2584,53 @@ PLOT_TEST(xy_series_measurement_uses_the_true_nearest_samples) {
PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9);
PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9);
}
// A host whose annotation is a readout -- the point where a cursor crosses a trace --
// moves it every frame. The id has to survive that, and a stale id must be ignored
// rather than read or written out of bounds, exactly as the cursors' setters behave.
// A readout cursor says which readout it belongs to with its colour; the setter keeps the
// id, and a stale one changes nothing.
PLOT_TEST(a_cursor_can_be_recoloured_by_id) {
plot::Plot p;
const u32 id = p.add_cursor(2.0, plot::Color::hex(0x111111));
PLOT_CHECK(p.cursor(id).color.b == plot::Color::hex(0x111111).b);
p.set_cursor_color(id, plot::Color::hex(0x9BE8A0));
PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g);
PLOT_CHECK(p.cursor_count() == 1u); // recoloured, not added
PLOT_CHECK_NEAR(p.cursor(id).x, 2.0, 1e-12);
p.set_cursor_color(42u, plot::Color::hex(0xFFFFFF));
PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g);
}
PLOT_TEST(an_annotation_can_be_moved_by_id) {
plot::Plot p;
const plot::Annotation pin{1.0, 2.0, "first", plot::Color::hex(0xE8E8E8), 0u, 0u, true};
const u32 id = p.add_annotation(pin);
PLOT_CHECK(p.annotation(id).x == 1.0);
PLOT_CHECK(p.annotation(id).text == std::string("first"));
plot::Annotation moved = pin;
moved.x = 3.5;
moved.y = -1.25;
moved.text = "moved";
p.set_annotation(id, moved);
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
PLOT_CHECK_NEAR(p.annotation(id).y, -1.25, 1e-12);
PLOT_CHECK(p.annotation(id).text == std::string("moved"));
PLOT_CHECK(p.annotation_count() == 1u); // moved, not added
// Parked rather than removed, like a cursor: the id stays valid and reads back hidden.
plot::Annotation parked = moved;
parked.visible = false;
p.set_annotation(id, parked);
PLOT_CHECK(!p.annotation(id).visible);
PLOT_CHECK(p.annotation_count() == 1u);
// A stale id changes nothing (and reads a default, as the accessors promise).
plot::Annotation other = pin;
other.x = 99.0;
p.set_annotation(42u, other);
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
}
+79
View File
@@ -5971,3 +5971,82 @@ PLOT_TEST(release_gl_shuts_down_and_the_renderer_can_reinit) {
PLOT_CHECK(!r.last_frame_had_gl_error());
ctx.destroy();
}
// The other orientation, in a figure: a horizontal ruler is per row (its `y` is on that
// row's y axis, so two rows' rulers are two different values), and every row's has to be
// drawn in its own band. The vertical case above covers the shared-time half; this is the
// half where each row owns its instrument, which is what an app's "two rulers per plot"
// needs -- and which the app found undrawn.
PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
const std::vector<plot::f32> samples = wave(101u, 0.0f);
plot::data::ArraySeries series(samples, 0.0, 1.0);
// A strongly red ruler and a strongly green one, so a pixel that is red- or
// green-dominant inside a row's axes can only be that row's ink: the trace is blue and
// the chrome grey (all three channels equal).
const plot::Color red = plot::Color::hex(0xFF2020);
const plot::Color green = plot::Color::hex(0x20FF20);
const plot::f32 y_scales[3] = {671.0e6f, 90.0f, 2.51f};
// The harness's no-atlas backend: a ruler always writes its value as text, and this case
// is about the *stroke*, so the text is asked for and staged as nothing. It goes on the
// *renderer*, not on the plots: `draw(Figure&)` replaces each row's backend with the
// renderer's own on the first frame, so a plot-level backend would be swapped out under a
// batch that had already declared that backend's (absent) cell.
OnePxPerStringBackend no_text;
r.set_text_backend(&no_text);
plot::Figure figure(3u);
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
for (plot::u32 row = 0; row < 3u; ++row) {
plot::Plot& p = figure.plot(row);
const plot::u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay =
p.add_y_axis({"v", {0.0, y_scales[row]}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, kRowBlue, 1.5f, ax, ay});
// A quarter and three quarters up this row's own range: inside its axes whatever the
// scale is, which is the point of a ruler being per row.
p.add_h_ruler(0.25 * y_scales[row], red);
p.add_h_ruler(0.75 * y_scales[row], green);
}
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(figure);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "figure_h_rulers.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
// Rows of the frame that hold a fully covered red or green line, and which row's band
// each falls in. The predicate demands full coverage exactly as the vertical case does.
const auto lines_in = [&](const plot::Rect& area, bool want_red) {
plot::u32 found = 0;
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
plot::u32 run = 0;
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
const plot::u8* px = &frame[(plot::usize(y) * ctx.width + x) * 4u];
const bool ok = want_red ? (px[0] >= 200 && px[1] <= 80 && px[2] <= 80)
: (px[1] >= 200 && px[0] <= 80 && px[2] <= 80);
if (ok) ++run;
}
if (run > (area.w * 3u) / 4u) ++found;
}
return found;
};
const plot::FigureLayout layout = figure.layout();
for (plot::u32 row = 0; row < 3u; ++row) {
const plot::Rect& axes = figure.plot(row).layout().axes;
PLOT_CHECK(lines_in(axes, true) >= 1u); // this row's own A ruler
PLOT_CHECK(lines_in(axes, false) >= 1u); // and its B ruler
}
(void)layout;
}