test(input): region, cursor and band-edge direct-manipulation cases (spec §5)

This commit is contained in:
Martino Ferrari committed 2026-09-28 13:42:26 +02:00
1 parent e4d48cc6c3
commit f423cf6f99
3 files changed
+819

No files matched your search

+268
View File
@@ -12,6 +12,7 @@
#include <plot/render/buffer.hpp>
#include <plot/render/detail/ring_stats.hpp>
#include <plot/render/renderer.hpp>
#include <plot/input/controller.hpp>
#include <atomic>
#include <cmath>
@@ -2207,9 +2208,40 @@ plot::u32 exact_pixels(const std::vector<plot::u8>& frame, plot::u32 fb_w, const
return count;
}
/// True when any row of the column at `x` inside `axes` holds a pixel the predicate
/// accepts. The overlay strokes are blended over whatever is under them, so for them a
/// presence test is the honest one, and it is measured in the axes' own rows rather
/// than over the whole framebuffer: a bound is the axes' height by construction.
bool column_has(plottest::HeadlessContext& ctx, plot::u32 x, const plot::Rect& axes,
bool (*pred)(const plot::u8*)) {
for (plot::u32 row = 0; row < static_cast<plot::u32>(axes.h); ++row) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - static_cast<plot::u32>(axes.y) - row, px));
if (pred(px)) return true;
}
return false;
}
/// The whole framebuffer as a Rect, for the counts that ask "and nowhere else".
const plot::Rect kFrameRect{0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)};
/// The pixels of `area` a predicate accepts. `exact_pixels` above answers "the
/// colour only this element can produce"; this one answers the same question for an
/// element that is *blended* -- a translucent region fill, an anti-aliased stroke --
/// where the test has to name the pattern rather than the colour.
plot::u32 predicate_pixels(const std::vector<plot::u8>& frame, plot::u32 fb_w,
const plot::Rect& area, bool (*pred)(const plot::u8*)) {
plot::u32 count = 0;
for (plot::u32 y = static_cast<plot::u32>(area.y);
y < static_cast<plot::u32>(area.bottom()); ++y) {
for (plot::u32 x = static_cast<plot::u32>(area.x);
x < static_cast<plot::u32>(area.right()); ++x) {
if (pred(&frame[(plot::usize(y) * fb_w + x) * 4u])) ++count;
}
}
return count;
}
/// The legend's entries are drawn where `Plot::layout()` measured them: a filled
/// swatch per trace in that trace's own colour, and the name's ink inside the panel
/// and nowhere else in the data area -- and the panel's presence changes nothing
@@ -3162,3 +3194,239 @@ PLOT_TEST(grid_widths_are_per_class_and_scale_with_dpi) {
PLOT_CHECK_NEAR(r.style().device(r.style().grid_minor_width), 2.0f, 1e-6f);
ctx.destroy();
}
// --- regions: the fill, the bounds and the emphasized handle (spec §5) ---------
namespace {
/// Green dominance: what the region's translucent `0x33FF00` fill leaves over the dark
/// background, over a grey grid line and over the axis line, and what an opaque
/// `0x00FF00` marker leaves at any coverage. Nothing else in these frames is
/// green-dominant -- the trace is blue (`g - b < 0`), the chrome and the text are
/// grey/chalk (`r == g == b`), the bounds are yellow (`r == g > b`) -- so a count of
/// these pixels is a statement about the fill (or the marker) and about nothing else.
/// Conservative on purpose: the fill's own half-pixel fringe at the top and bottom rows
/// is not counted.
bool green_dominant(const plot::u8 px[4]) {
return px[1] > px[0] + 60 && px[1] > px[2] + 60;
}
} // namespace
/// A region's fill and its two bounds are drawn from the geometry `Plot::layout()`
/// measured -- the same rects the controller hit-tests -- so the rect a test asserts, the
/// rect a frame paints and the column a pointer is grabbed at are one set of numbers.
/// The fill shades the interval between the bounds and nothing outside it; each bound is
/// one vertical stroke of the axes' height in the region's own edge colour, in its own
/// column and nowhere else in the frame.
PLOT_TEST(region_fill_and_edges_are_drawn_inside_the_axes) {
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::data::ArraySeries s(sine_samples(), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame
plot::Region reg;
reg.x0 = 100.0;
reg.x1 = 300.0;
reg.snap = plot::RegionSnap::Free;
// Colours no other element of the frame produces, so each count below can only be
// satisfied by the element it names.
reg.fill = plot::Color::hex(0x33FF00, 0.35f);
// Magenta, which `magenta_pixel` already names and neither the fill, the trace, the
// chrome nor the accent (0xFFB020) can produce.
reg.edge = plot::Color::hex(0xFF00FF);
const plot::u32 rid = p.add_region(reg);
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());
dump_if_requested(r, "regions.ppm");
const std::vector<plot::u8> frame = read_frame(ctx);
const plot::PlotLayout layout = p.layout();
PLOT_CHECK(layout.regions.size() == 1u);
const plot::RegionLayout& rl = layout.regions[0];
PLOT_CHECK(rl.region == rid);
const plot::Rect axes = layout.axes;
// The measured geometry is the drawn geometry: the fill spans the axes' full height
// and is clipped to their columns, so it can never reach over a label strip.
PLOT_CHECK(rl.fill.w > 0.0f);
PLOT_CHECK(rl.fill.x >= axes.x && rl.fill.right() <= axes.right());
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.fill.h), static_cast<plot::f64>(axes.h), 1e-6);
// ... and it is the interval the two bounds mark: its edges are the accepted columns
// of the bound strokes, so the rect that is painted is the interval that is grabbed.
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.fill.x),
static_cast<plot::f64>(plot::handle_column(rl.left_handle)), 1e-4);
PLOT_CHECK_NEAR(static_cast<plot::f64>(rl.fill.right()),
static_cast<plot::f64>(plot::handle_column(rl.right_handle)), 1e-4);
const plot::u32 in_fill = predicate_pixels(frame, kW, rl.fill, green_dominant);
const plot::u32 anywhere = predicate_pixels(frame, kW, kFrameRect, green_dominant);
const plot::u32 area = static_cast<plot::u32>(rl.fill.w) * static_cast<plot::u32>(rl.fill.h);
PLOT_CHECK(in_fill > 0u);
// The fill shades essentially the whole of its rect -- the trace and the grid it is
// drawn over take a few percent -- and tints nothing anywhere else in the frame.
PLOT_CHECK(in_fill >= area - area / 8u);
PLOT_CHECK(anywhere == in_fill);
// The bounds: one stroke per bound, drawn in the bound's own column, of the axes'
// height, and nowhere else in the frame.
const plot::u32 left_col =
static_cast<plot::u32>(plot::handle_column(rl.left_handle));
const plot::u32 right_col =
static_cast<plot::u32>(plot::handle_column(rl.right_handle));
PLOT_CHECK(column_has(ctx, left_col, axes, magenta_pixel));
PLOT_CHECK(column_has(ctx, right_col, axes, magenta_pixel));
PLOT_CHECK(!column_has(ctx, left_col + 25u, axes, magenta_pixel)); // the fill is not an edge
PLOT_CHECK(!column_has(ctx, 0u, axes, magenta_pixel));
// Each band is the bound's own column grown by a pixel either side -- and one row
// taller than the axes, because a stroke drawn to `tab.bottom()` covers the
// half-pixel row a rect's truncating cast ends at.
const plot::Rect left_band{static_cast<plot::f32>(left_col) - 1.0f, axes.y, 3.0f,
axes.h + 1.0f};
const plot::Rect right_band{static_cast<plot::f32>(right_col) - 1.0f, axes.y, 3.0f,
axes.h + 1.0f};
const plot::u32 left_edge = predicate_pixels(frame, kW, left_band, magenta_pixel);
const plot::u32 right_edge = predicate_pixels(frame, kW, right_band, magenta_pixel);
const plot::u32 all_edges = predicate_pixels(frame, kW, kFrameRect, magenta_pixel);
PLOT_CHECK(left_edge >= static_cast<plot::u32>(axes.h) / 2u);
PLOT_CHECK(right_edge >= static_cast<plot::u32>(axes.h) / 2u);
PLOT_CHECK(all_edges == left_edge + right_edge);
ctx.destroy();
}
/// The handles a pointer is on are emphasized, and the emphasis is the plot's to draw:
/// `Plot::hot_handles()` carries what the input layer's one hit test found, and the
/// renderer draws that handle in the style's cursor colour and at the handle width
/// `PlotLayout` sized it with. A grabbed handle keeps its emphasis for the whole
/// gesture, so what is emphasized follows the *grab* rather than the pointer -- which is
/// what the last frame here pins, with the pointer off the plot entirely.
PLOT_TEST(grabbed_handle_is_highlighted) {
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::data::ArraySeries s(sine_samples(), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
p.set_x_range(ax, {0.0, 640.0});
plot::Region reg;
reg.x0 = 100.0;
reg.x1 = 300.0;
reg.snap = plot::RegionSnap::Free;
reg.edge = plot::Color::hex(0xFF00FF);
const plot::u32 rid = p.add_region(reg);
// A marker in a colour of its own, so the accent count below can only come from a
// highlighted handle and never from the marker as it is drawn when it is idle.
const plot::u32 cid = p.add_cursor(500.0, plot::Color::hex(0x00FF00));
plot::input::Controller c(p);
c.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::Color accent = r.style().cursor;
auto draw_frame = [&] {
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());
return read_frame(ctx);
};
// Frame 1: the controller has seen no pointer, so nothing is hot and no handle is
// emphasized -- while the bounds themselves are still drawn, in their own colour.
const std::vector<plot::u8> bare = draw_frame();
PLOT_CHECK(exact_pixels(bare, kW, kFrameRect, accent) == 0u);
PLOT_CHECK(predicate_pixels(bare, kW, kFrameRect, magenta_pixel) > 0u);
const plot::PlotLayout layout = p.layout();
PLOT_CHECK(layout.regions.size() == 1u);
const plot::Rect tab = layout.regions[0].left_handle;
// The tab grown by a pixel on each side -- and by that row plus one, because a
// stroke drawn to `tab.bottom()` covers the half-pixel row the truncating cast of a
// rect ends at. The window is the handle's, and the handle's alone.
const plot::Rect band{tab.x - 1.0f, tab.y, tab.w + 2.0f, tab.h + 1.0f};
const plot::f32 col = plot::handle_column(tab);
const plot::f32 cy = layout.axes.y + 0.5f * layout.axes.h;
// Frame 2: the pointer is on the bound, so the bound is the handle being emphasized.
c.on_pointer({col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, false});
PLOT_CHECK(c.region_grab(rid) == plot::RegionGrab::Left);
PLOT_CHECK(p.hot_handles().zone == plot::RegionGrab::Left);
const std::vector<plot::u8> hovered = draw_frame();
const plot::u32 hot = exact_pixels(hovered, kW, kFrameRect, accent);
PLOT_CHECK(hot > 0u);
// At the handle and nowhere else in the frame.
PLOT_CHECK(exact_pixels(hovered, kW, band, accent) == hot);
// The handle is a handle, not a hairline: the accent covers the tab's own width,
// where the idle bound was drawn 1.5 logical px wide in the region's colour.
PLOT_CHECK(hot >= static_cast<plot::u32>(tab.h) * (static_cast<plot::u32>(tab.w) - 2u));
PLOT_CHECK(hot <= static_cast<plot::u32>(tab.h) * (static_cast<plot::u32>(tab.w) + 2u));
PLOT_CHECK(predicate_pixels(hovered, kW, band, magenta_pixel) == 0u);
// Frame 3: the bound is grabbed and dragged, then the pointer leaves the plot. The
// hover is over -- `region_grab()` says so -- but the gesture is not, so the bound the
// drag holds stays emphasized, at the column the drag has moved it to.
c.on_pointer({col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
PLOT_CHECK(c.grabbed_region() == rid);
const plot::f32 moved = col + 60.0f;
c.on_pointer({moved, cy, 60.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false});
c.on_pointer({moved, static_cast<plot::f32>(kH) + 20.0f, 0.0f, 0.0f, plot::input::Button::Left,
0u, true, false, false});
PLOT_CHECK(c.region_grab(rid) == plot::RegionGrab::None); // the pointer is off the data area
PLOT_CHECK(p.hot_handles().region == rid);
PLOT_CHECK(p.hot_handles().zone == plot::RegionGrab::Left);
const std::vector<plot::u8> dragged = draw_frame();
const plot::PlotLayout moved_layout = p.layout();
const plot::Rect moved_tab = moved_layout.regions[0].left_handle;
PLOT_CHECK_NEAR(static_cast<plot::f64>(plot::handle_column(moved_tab)),
static_cast<plot::f64>(moved), 0.5);
const plot::Rect moved_band{moved_tab.x - 1.0f, moved_tab.y, moved_tab.w + 2.0f,
moved_tab.h + 1.0f};
const plot::u32 held = exact_pixels(dragged, kW, kFrameRect, accent);
PLOT_CHECK(held > 0u);
PLOT_CHECK(exact_pixels(dragged, kW, moved_band, accent) == held);
// Releasing off the plot ends both the gesture and the emphasis.
c.on_pointer({moved, static_cast<plot::f32>(kH) + 20.0f, 0.0f, 0.0f, plot::input::Button::Left,
0u, false, false, true});
PLOT_CHECK(c.grabbed_region() == plot::kInvalid);
PLOT_CHECK(p.hot_handles().region == plot::kInvalid);
PLOT_CHECK(exact_pixels(draw_frame(), kW, kFrameRect, accent) == 0u);
// The same rule for a cursor marker: hovered, it is drawn as a handle in the accent,
// in its own column and nowhere else, and its own colour is gone from that column.
const plot::f32 marker_col = static_cast<plot::f32>(p.x_transform(ax).to_screen_x(p.cursor(cid).x));
c.on_pointer({marker_col, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, false});
PLOT_CHECK(c.hovered_cursor() == cid);
PLOT_CHECK(p.hot_handles().cursor == cid);
const std::vector<plot::u8> over_marker = draw_frame();
const plot::u32 marker_hot = exact_pixels(over_marker, kW, kFrameRect, accent);
const plot::Rect marker_band{marker_col - 4.0f, layout.axes.y, 9.0f,
layout.axes.h + 1.0f};
PLOT_CHECK(marker_hot > 0u);
PLOT_CHECK(exact_pixels(over_marker, kW, marker_band, accent) == marker_hot);
PLOT_CHECK(predicate_pixels(over_marker, kW, marker_band, green_dominant) == 0u);
ctx.destroy();
}