feat(cursor): a cursor's stroke is its own -- width and dash

Cursors took `Style::line_width_px` and were always solid, so a host had no way to place a
*readout mark* on the data (a measured level, a peak, a pulse's edge) without it reading as
another instrument framing the plot. `set_cursor_stroke(id, width, dash)` gives it its own, the
same shape as `set_cursor_color()`: per cursor, because one plot holds both kinds.

`width <= 0` means the style's default for that cursor (`handle_width_px` while hovered or
grabbed, `line_width_px` otherwise), and a hover can only make the stroke *thicker* -- the cue
that the pointer has hold of something must not be the thing that hides it. `dash.off <= 0` is
solid, the convention a trace's, the crosshair's and the grid's dash already share.

Test: a_cursor_stroke_can_be_thinner_and_dashed -- a default cursor and a thin dashed one in the
same frame. The dashed column alternates ink and gap (153/151 at the 4/4 the test asks for)
while the solid one has no gaps; the ink laid down over each stroke's whole run of columns is
less for the thin one. That last assertion is deliberately *not* a single-column peak: a cursor
is a data position and is not snapped, so the solid stroke's ink split across two columns and
read *lighter* than the thin one -- which is how this test first failed.
This commit is contained in:
Martino Ferrari committed 2026-10-03 15:22:01 +02:00
1 parent c4c0a49466
commit fba8d985cf
6 files changed
+154 -5

No files matched your search

+110
View File
@@ -1273,6 +1273,116 @@ PLOT_TEST(the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major) {
ctx.destroy();
}
/// A cursor's stroke is its own: a host that places *readout marks* on the data -- a measured
/// level, a peak, a pulse's edges -- wants them lighter and broken, so they read as annotations
/// over the data rather than as more chrome around it. Two cursors in one frame, one default and
/// one given a thin dashed stroke, so the comparison is in the same pixels: the dashed column
/// alternates ink and gap down its length while the solid one does not, and its ink sits closer
/// to the background, which at 1x is what a sub-pixel logical width *is*.
PLOT_TEST(a_cursor_stroke_can_be_thinner_and_dashed) {
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));
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
// Green: no other element of this frame is green-dominant (the grid is grey-dark and the axis
// line is chalk), so the predicate below can only find these two cursors.
const plot::Color ink = plot::Color::hex(0x00FF00);
p.add_cursor(3.0, ink);
const plot::u32 thin = p.add_cursor(7.0, ink);
p.set_cursor_snap(thin, plot::SnapMode::Free);
p.set_cursor_stroke(thin, 0.75f, plot::Vec2{3.0f, 3.0f});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::Rect& a = p.layout().axes;
const plot::Color bg = r.style().background;
const auto px_at = [&](plot::u32 cx, plot::u32 cy, plot::u8 px[4]) {
return ctx.read_pixel(cx, ctx.height - 1u - cy, px);
};
const auto bg_distance = [&](const plot::u8 px[4]) {
const auto d = [](plot::u8 v, plot::f32 want) {
return std::abs(static_cast<int>(v) - static_cast<int>(want * 255.0f + 0.5f));
};
return d(px[0], bg.r) + d(px[1], bg.g) + d(px[2], bg.b);
};
// The two cursor columns, as the *centre of each run* of green columns: a stroke with an
// anti-aliased edge covers two or three columns, so taking the first two hits found one cursor
// twice (238 and 239) and never looked at the other -- which is how this test first reported
// "neither is dashed" with both columns' ink identical.
plot::u32 cols[2] = {0u, 0u};
plot::u32 run_from[2] = {0u, 0u};
plot::u32 run_to[2] = {0u, 0u};
int found = 0;
bool in_run = false;
plot::u32 run_start = 0;
const auto close_run = [&](plot::u32 end_column) {
if (found >= 2) return;
cols[found] = (run_start + end_column) / 2u;
run_from[found] = run_start;
run_to[found] = end_column;
++found;
};
for (plot::u32 cx = static_cast<plot::u32>(a.x); cx < static_cast<plot::u32>(a.x + a.w); ++cx) {
bool green = false;
for (plot::u32 row = static_cast<plot::u32>(a.y) + 4u;
row < static_cast<plot::u32>(a.y + a.h) - 4u && !green; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(px_at(cx, row, px));
green = green_pixel(px);
}
if (green && !in_run) {
in_run = true;
run_start = cx;
} else if (!green && in_run) {
in_run = false;
close_run(cx - 1u);
}
}
if (in_run) close_run(static_cast<plot::u32>(a.x + a.w) - 1u);
PLOT_CHECK(found == 2);
plot::u32 ink_rows[2] = {0u, 0u};
plot::u32 gap_rows[2] = {0u, 0u};
// The ink each stroke lays down, summed over its whole run of columns: a cursor is a *data*
// position and so is deliberately not snapped, so which columns (and how brightly) a stroke
// covers depends on where its coordinate falls between pixels. Comparing one column's peak made
// the *solid* stroke look lighter than the thin one, because its ink happened to split across
// two columns. What is asked here is the thing the style states: less ink per unit length.
long long total_ink[2] = {0, 0};
for (int c = 0; c < 2; ++c) {
for (plot::u32 cx = run_from[c]; cx <= run_to[c]; ++cx) {
for (plot::u32 row = static_cast<plot::u32>(a.y) + 4u;
row < static_cast<plot::u32>(a.y + a.h) - 4u; ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(px_at(cx, row, px));
if (green_pixel(px)) {
if (cx == cols[c]) ++ink_rows[c];
total_ink[c] += bg_distance(px);
} else if (pixel_is(px, bg) && cx == cols[c]) {
++gap_rows[c];
}
}
}
}
std::printf("[cursor-stroke] solid x=%u [%u..%u] ink=%u gaps=%u total=%lld; "
"thin x=%u [%u..%u] ink=%u gaps=%u total=%lld\n",
cols[0], run_from[0], run_to[0], ink_rows[0], gap_rows[0], total_ink[0], cols[1],
run_from[1], run_to[1], ink_rows[1], gap_rows[1], total_ink[1]);
PLOT_CHECK(gap_rows[0] == 0u); // the default stroke is continuous
PLOT_CHECK(ink_rows[1] > 0u && gap_rows[1] > 0u); // the custom one is dashed
PLOT_CHECK(total_ink[1] < total_ink[0]); // and lays down less ink
ctx.destroy();
}
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;