diff --git a/docs/styling.md b/docs/styling.md index 56773df..5784b05 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -104,6 +104,18 @@ same width a dense minor grid reads as a second set of ticks. The dash is a frag discard, not extra geometry: the vertices are the line either way. Set `grid_minor_dash_off = 0` for the old solid subdivision. +## A cursor's stroke + +`set_cursor_color()` gives a cursor its own colour and `set_cursor_stroke()` its own width and +dash (`Cursor::width`, `Cursor::dash`). Per cursor rather than per plot, because a host uses +cursors for two different jobs: the instruments that frame a reading — a marker at an edge, a pull +handle — and the *readout marks* a computation places on the data (a measured level, a peak, a +pulse's edges). Those want different weights. `width <= 0` means the style's default for that +cursor (`handle_width_px` while it is 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` (the default) is solid — the same convention as a trace's, +the crosshair's and the grid's. + ## The crosshair's dash `crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no diff --git a/include/plot/plot/cursor.hpp b/include/plot/plot/cursor.hpp index 34d4844..3f32f69 100644 --- a/include/plot/plot/cursor.hpp +++ b/include/plot/plot/cursor.hpp @@ -6,6 +6,7 @@ #include #include +#include namespace plot { @@ -92,6 +93,19 @@ struct Cursor { /// 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; + /// The stroke's logical width; `<= 0` means the style's default for this cursor + /// (`Style::handle_width_px` while it is hovered or grabbed, `Style::line_width_px` + /// otherwise). Per cursor, like the colour, because a host uses cursors for two different + /// jobs: the instruments that frame a reading (a marker at an edge, a pull handle) and the + /// *readout marks* a computation places on the data (a measured level, a peak). Those want + /// different weights, and a hover can only make the stroke thicker, never thinner -- the cue + /// that the pointer has hold of something must not be the thing that hides it. + f32 width = 0.0f; + /// The dash, logical pixels: ink length and gap. `off <= 0` (the default) is a solid stroke -- + /// the same convention as a trace's, the crosshair's and the grid's. A dashed readout mark is + /// distinguishable by *form* from the solid chrome around it, which is what an instrument that + /// reports a number should be. + Vec2 dash{}; /// 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 c05e4f5..b581fd2 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); + + /// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash + /// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`. + void set_cursor_stroke(u32 id, f32 width, Vec2 dash); /// 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. diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 6e47f66..5169b40 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -311,6 +311,12 @@ void Plot::set_cursor_color(u32 id, Color color) { cursors_[id].color = color; } +void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) { + if (id >= cursors_.size()) return; + cursors_[id].width = width; + cursors_[id].dash = dash; +} + void Plot::set_cursor_selectable(u32 id, bool selectable) { if (id >= cursors_.size()) return; cursors_[id].selectable = selectable; diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 6e1642e..03c8547 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -1117,7 +1117,12 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) { // belongs to the plot, and `to_screen_y()` of a range maximum can undershoot `axes.y` by // a floating-point ulp, which a bare `<` would mistake for "outside". Only the ones // beyond that margin are skipped. - const f32 half = 0.5f * style_.device(on ? style_.handle_width_px : style_.line_width_px); + // The cursor's own width when it has one, else the style's; a hover or a grab can only make + // it thicker (the cue that the pointer has hold of something). + const f32 own = cursor.width > 0.0f ? style_.device(cursor.width) + : style_.device(style_.line_width_px); + const f32 width = on ? std::max(own, style_.device(style_.handle_width_px)) : own; + const f32 half = 0.5f * width; if (horizontal ? (at < layout.axes.y - half || at > layout.axes.bottom() + half) : (at < layout.axes.x - half || at > layout.axes.right() + half)) { continue; @@ -1141,15 +1146,13 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) { stage_segment(x_range.min, x_range.min, y_drawn, x_range.max, y_drawn, log_x, plot.y_axis(cursor.axis_y).log); flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min), - on ? style_.cursor : cursor.color, - style_.device(on ? style_.handle_width_px : style_.line_width_px)); + on ? style_.cursor : cursor.color, width, cursor.dash); } else { const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast(drawn)); stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max, plot.x_axis(cursor.axis_x).log, log_y); flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn), - on ? style_.cursor : cursor.color, - style_.device(on ? style_.handle_width_px : style_.line_width_px)); + on ? style_.cursor : cursor.color, width, cursor.dash); } draw_cursor_label(plot, layout, cursor, drawn); } diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 8c30c33..1ad59b0 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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(kW), static_cast(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(kW), static_cast(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(v) - static_cast(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(a.x); cx < static_cast(a.x + a.w); ++cx) { + bool green = false; + for (plot::u32 row = static_cast(a.y) + 4u; + row < static_cast(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(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(a.y) + 4u; + row < static_cast(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;