test(input): region, cursor and band-edge direct-manipulation cases (spec §5)
This commit is contained in:
1 parent
e4d48cc6c3
commit
f423cf6f99
3 files changed
+819
No files matched your search
@@ -721,3 +721,375 @@ PLOT_TEST(live_gestures_survive_a_degenerate_range) {
|
|||||||
PLOT_CHECK_NEAR(f.xr().max, f.newest() - f.graph.x_offset(f.ax), 1e-9);
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - f.graph.x_offset(f.ax), 1e-9);
|
||||||
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- direct manipulation: grabbable cursors, regions and band edges (spec §5) ---
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The x column a region's bound sits at, as `Plot::layout()` measured it -- the same
|
||||||
|
/// number the controller hit-tests and the renderer draws the bound in. Negative when
|
||||||
|
/// the layout holds no such region, which no caller here should see.
|
||||||
|
f32 bound_x(plot::Plot& p, u32 region, bool left) {
|
||||||
|
for (const plot::RegionLayout& r : p.layout().regions) {
|
||||||
|
if (r.region != region) continue;
|
||||||
|
return plot::handle_column(left ? r.left_handle : r.right_handle);
|
||||||
|
}
|
||||||
|
return -1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A region whose bounds are stored exactly as written: `Free` snap, so a drag's
|
||||||
|
/// endpoints are the numbers the gesture put there rather than the sample grid
|
||||||
|
/// `add_region()` pulls a snapping region onto.
|
||||||
|
plot::Region free_region(f64 x0, f64 x1) {
|
||||||
|
plot::Region r;
|
||||||
|
r.x0 = x0;
|
||||||
|
r.x1 = x1;
|
||||||
|
r.snap = plot::RegionSnap::Free;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The data x under one device-pixel column.
|
||||||
|
f64 data_at(const plot::Plot& p, u32 axis, f32 px) {
|
||||||
|
return p.x_transform(axis).to_data_x(static_cast<f64>(px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device column a data x is drawn at.
|
||||||
|
f32 column_of(const plot::Plot& p, u32 axis, f64 data) {
|
||||||
|
return static_cast<f32>(p.x_transform(axis).to_screen_x(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
void hover(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
|
||||||
|
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, false, false, false});
|
||||||
|
}
|
||||||
|
void press(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
|
||||||
|
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, true, true, false});
|
||||||
|
}
|
||||||
|
void move_to(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
|
||||||
|
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, true, false, false});
|
||||||
|
}
|
||||||
|
void release(plot::input::Controller& c, f32 px, f32 py = 200.0f, u32 mods = 0) {
|
||||||
|
c.on_pointer({px, py, 0.0f, 0.0f, plot::input::Button::Left, mods, false, false, true});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root-mean-square of the y values of the `y(i) == i` samples in [begin, end]: what
|
||||||
|
/// the band case's measurement has to match exactly.
|
||||||
|
f64 ramp_rms(u32 begin, u32 end) {
|
||||||
|
f64 sq = 0.0;
|
||||||
|
for (u32 i = begin; i <= end; ++i) sq += static_cast<f64>(i) * static_cast<f64>(i);
|
||||||
|
return std::sqrt(sq / static_cast<f64>(end - begin + 1u));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/// A marker is a handle like any other and needs no modifier to be moved: the hit
|
||||||
|
/// test runs before the pan is ever considered, so a press on a cursor is that
|
||||||
|
/// cursor's drag while a press anywhere else still pans. `Ctrl` is not a second
|
||||||
|
/// gesture -- it must not turn a press on a marker into anything else, which is the
|
||||||
|
/// regression to watch for.
|
||||||
|
PLOT_TEST(cursor_is_grabbed_without_a_modifier) {
|
||||||
|
Fixture f;
|
||||||
|
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
||||||
|
const f32 sx = screen_x_of(f.plot, f.ax, 50.0);
|
||||||
|
const f64 range_min = f.plot.x_range(f.ax).min;
|
||||||
|
|
||||||
|
press(f.controller, sx);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
|
||||||
|
// The plot publishes the same answers, for the renderer and for a host that wants
|
||||||
|
// a resize cursor or a status line of its own.
|
||||||
|
PLOT_CHECK(f.plot.hot_handles().cursor == cid);
|
||||||
|
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == cid);
|
||||||
|
move_to(f.controller, sx + 100.0f);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, sx + 100.0f), 1e-9);
|
||||||
|
PLOT_CHECK(f.plot.cursor(cid).x > 50.0);
|
||||||
|
// The view did not pan: the gesture belonged to the marker, not to the ranges.
|
||||||
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, range_min, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
|
||||||
|
release(f.controller, sx + 100.0f);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
|
||||||
|
PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == plot::kInvalid);
|
||||||
|
|
||||||
|
// The same press with ctrl is the same gesture, not a different mode.
|
||||||
|
const f32 moved_to = column_of(f.plot, f.ax, f.plot.cursor(cid).x);
|
||||||
|
const f64 min_before = f.plot.x_range(f.ax).min;
|
||||||
|
press(f.controller, moved_to, 200.0f, kCtrl);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
|
||||||
|
move_to(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, data_at(f.plot, f.ax, moved_to - 40.0f), 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, min_before, 1e-9); // still no pan
|
||||||
|
release(f.controller, moved_to - 40.0f, 200.0f, kCtrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of "a superset of the old gesture": a press that is on no handle
|
||||||
|
/// reaches the pan exactly as it did before, with and without ctrl, and neither the
|
||||||
|
/// cursor nor the region under the view is disturbed.
|
||||||
|
PLOT_TEST(pressing_away_from_a_handle_still_pans) {
|
||||||
|
Fixture f;
|
||||||
|
const u32 cid = f.plot.add_cursor(20.0, plot::Color::hex(0x00FF00));
|
||||||
|
const u32 rid = f.plot.add_region(free_region(40.0, 60.0));
|
||||||
|
|
||||||
|
for (const u32 mods : {0u, kCtrl}) {
|
||||||
|
const f32 px = screen_x_of(f.plot, f.ax, 85.0);
|
||||||
|
const f64 grabbed = data_at(f.plot, f.ax, px);
|
||||||
|
const f64 min_before = f.plot.x_range(f.ax).min;
|
||||||
|
press(f.controller, px, 200.0f, mods);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid);
|
||||||
|
PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid);
|
||||||
|
PLOT_CHECK(f.plot.hot_handles().region == plot::kInvalid);
|
||||||
|
// The pan is driven by the event's own delta, so this motion has to report one:
|
||||||
|
// a host that sends an absolute position with dx = 0 has not moved the drag.
|
||||||
|
f.controller.on_pointer(
|
||||||
|
{px + 70.0f, 200.0f, 70.0f, 0.0f, plot::input::Button::Left, mods, true, false, false});
|
||||||
|
release(f.controller, px + 70.0f, 200.0f, mods);
|
||||||
|
// Grab-and-drag, the invariant the pan has always held: the data under the press
|
||||||
|
// is under the pointer afterwards.
|
||||||
|
PLOT_CHECK_NEAR(data_at(f.plot, f.ax, px + 70.0f), grabbed, 1e-9);
|
||||||
|
PLOT_CHECK(f.plot.x_range(f.ax).min < min_before);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.cursor(cid).x, 20.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 40.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 60.0, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nearest-first resolution: a bound within the grab radius beats the body it sits
|
||||||
|
/// inside, and the body answers only away from both bounds. The bounds are *inside*
|
||||||
|
/// the fill, so a body-first hit test would report `Body` here and a region could
|
||||||
|
/// never be resized.
|
||||||
|
PLOT_TEST(region_edge_wins_over_region_body) {
|
||||||
|
Fixture f;
|
||||||
|
const u32 rid = f.plot.add_region(free_region(30.0, 70.0));
|
||||||
|
const f32 left = bound_x(f.plot, rid, true);
|
||||||
|
const f32 right = bound_x(f.plot, rid, false);
|
||||||
|
PLOT_CHECK(left >= 0.0f && right > left);
|
||||||
|
const f32 mid = 0.5f * (left + right);
|
||||||
|
|
||||||
|
// No pointer seen yet: nothing is on anything.
|
||||||
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::None);
|
||||||
|
|
||||||
|
hover(f.controller, mid);
|
||||||
|
PLOT_CHECK(f.controller.hovered_region() == rid);
|
||||||
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
|
||||||
|
PLOT_CHECK(f.plot.hot_handles().region == rid);
|
||||||
|
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Body);
|
||||||
|
|
||||||
|
hover(f.controller, left);
|
||||||
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
|
||||||
|
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Left);
|
||||||
|
hover(f.controller, right);
|
||||||
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Right);
|
||||||
|
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Right);
|
||||||
|
// A couple of pixels inside the bound is still the bound, not the body.
|
||||||
|
hover(f.controller, left + 2.0f);
|
||||||
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
|
||||||
|
// Just outside the fill nothing is hot, so the `Body` answer above is not an
|
||||||
|
// accident of treating a wide neighbourhood as the region.
|
||||||
|
hover(f.controller, left - 12.0f);
|
||||||
|
PLOT_CHECK(f.controller.hovered_region() == plot::kInvalid);
|
||||||
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::None);
|
||||||
|
|
||||||
|
// Each bound opens the drag that moves that bound; the body opens the one that
|
||||||
|
// moves the interval.
|
||||||
|
press(f.controller, left);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_region() == rid);
|
||||||
|
release(f.controller, left);
|
||||||
|
press(f.controller, right);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionRight);
|
||||||
|
release(f.controller, right);
|
||||||
|
press(f.controller, mid);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
||||||
|
release(f.controller, mid);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid);
|
||||||
|
|
||||||
|
// A cursor marker is hit-tested before any region, so a press on it is still the
|
||||||
|
// cursor's drag.
|
||||||
|
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
||||||
|
const f32 marker = screen_x_of(f.plot, f.ax, 50.0);
|
||||||
|
hover(f.controller, marker);
|
||||||
|
// The region is under the pointer too -- the marker sits inside its interval, so the
|
||||||
|
// region's answer is `Body` -- and the cursor still wins the drag: the two hit tests
|
||||||
|
// are separate, and the cursor's is the one that decides the gesture.
|
||||||
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
|
||||||
|
PLOT_CHECK(f.controller.hovered_region() == rid);
|
||||||
|
press(f.controller, marker);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_cursor() == cid);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_region() == plot::kInvalid);
|
||||||
|
release(f.controller, marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A body drag translates both bounds by the *same* data delta -- the width is
|
||||||
|
/// preserved exactly -- and each motion is a function of the pointer rather than a
|
||||||
|
/// step from the last one, so a pointer that returns to where it started returns the
|
||||||
|
/// region there too even when the host's reported deltas say otherwise.
|
||||||
|
PLOT_TEST(region_body_move_preserves_width) {
|
||||||
|
Fixture f;
|
||||||
|
const u32 rid = f.plot.add_region(free_region(30.0, 70.0));
|
||||||
|
const f64 width = f.plot.region(rid).x1 - f.plot.region(rid).x0;
|
||||||
|
const f32 mid = 0.5f * (bound_x(f.plot, rid, true) + bound_x(f.plot, rid, false));
|
||||||
|
|
||||||
|
press(f.controller, mid);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_region() == rid);
|
||||||
|
move_to(f.controller, mid + 120.0f);
|
||||||
|
const f64 delta = data_at(f.plot, f.ax, mid + 120.0f) - data_at(f.plot, f.ax, mid);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 30.0 + delta, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 70.0 + delta, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1 - f.plot.region(rid).x0, width, 1e-9);
|
||||||
|
PLOT_CHECK(f.plot.region(rid).x0 > 30.0);
|
||||||
|
|
||||||
|
// The deltas here are a lie -- 0, 0 while the pointer moved back -- and the bounds
|
||||||
|
// are rebuilt from the pointer's own coordinate, so nothing accumulates.
|
||||||
|
f.controller.on_pointer({mid, 200.0f, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, false, false});
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 30.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 70.0, 1e-9);
|
||||||
|
release(f.controller, mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bound dragged past its partner *swaps* the interval rather than inverting or
|
||||||
|
/// clamping it: the store keeps (min, max), so the region always has a positive
|
||||||
|
/// width, and the pointer keeps hold of the bound it grabbed -- which, having crossed
|
||||||
|
/// over, is now the other one.
|
||||||
|
PLOT_TEST(region_bounds_stay_ordered_when_dragged_past_each_other) {
|
||||||
|
Fixture f;
|
||||||
|
const u32 rid = f.plot.add_region(free_region(30.0, 70.0));
|
||||||
|
const f32 right = bound_x(f.plot, rid, false);
|
||||||
|
|
||||||
|
// Drag the right bound well past the left one.
|
||||||
|
const f32 to = column_of(f.plot, f.ax, 12.0);
|
||||||
|
const f64 want = data_at(f.plot, f.ax, to);
|
||||||
|
PLOT_CHECK(want < 30.0); // the drag really does cross the other bound
|
||||||
|
press(f.controller, right);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionRight);
|
||||||
|
move_to(f.controller, to);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, want, 1e-9); // the dragged bound, now the lower one
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 30.0, 1e-9); // the one it was dragged past
|
||||||
|
PLOT_CHECK(f.plot.region(rid).x0 < f.plot.region(rid).x1);
|
||||||
|
|
||||||
|
// The grabbed bound is still the one being moved -- it is `x0` now, and the next
|
||||||
|
// motion moves it without the gesture having to be restarted.
|
||||||
|
const f32 back = column_of(f.plot, f.ax, 20.0);
|
||||||
|
move_to(f.controller, back);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, data_at(f.plot, f.ax, back), 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 30.0, 1e-9);
|
||||||
|
release(f.controller, back);
|
||||||
|
|
||||||
|
// And the mirror image: the left bound dragged past the right one.
|
||||||
|
f.plot.set_region(rid, 30.0, 70.0);
|
||||||
|
const f32 left = bound_x(f.plot, rid, true);
|
||||||
|
const f32 far = column_of(f.plot, f.ax, 90.0);
|
||||||
|
const f64 want_far = data_at(f.plot, f.ax, far);
|
||||||
|
press(f.controller, left);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
|
||||||
|
move_to(f.controller, far);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 70.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, want_far, 1e-9);
|
||||||
|
PLOT_CHECK(f.plot.region(rid).x0 < f.plot.region(rid).x1);
|
||||||
|
release(f.controller, far);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A region whose two bounds share a column cannot be grabbed by an edge -- an edge
|
||||||
|
/// would win the hit test every time and the region could never be moved again once
|
||||||
|
/// collapsed -- so it is grabbed by its body, which keeps it movable. The threshold is
|
||||||
|
/// the narrowest region whose bounds a pointer can tell apart: below it the body
|
||||||
|
/// answers, above it the bound does.
|
||||||
|
PLOT_TEST(collapsed_region_cannot_be_grabbed_by_an_edge) {
|
||||||
|
Fixture f;
|
||||||
|
const u32 rid = f.plot.add_region(free_region(50.0, 50.0));
|
||||||
|
const f32 col = bound_x(f.plot, rid, true);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<f64>(col), static_cast<f64>(bound_x(f.plot, rid, false)), 1e-6);
|
||||||
|
hover(f.controller, col);
|
||||||
|
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
|
||||||
|
press(f.controller, col);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
||||||
|
move_to(f.controller, col + 60.0f);
|
||||||
|
const f64 delta = data_at(f.plot, f.ax, col + 60.0f) - data_at(f.plot, f.ax, col);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 50.0 + delta, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 50.0 + delta, 1e-9); // moved, still collapsed
|
||||||
|
release(f.controller, col + 60.0f);
|
||||||
|
|
||||||
|
// Two pixels wide is still narrower than the minimum (3 logical px at scale 1): the
|
||||||
|
// same answer at either bound.
|
||||||
|
const u32 narrow = f.plot.add_region(free_region(data_at(f.plot, f.ax, 400.0f),
|
||||||
|
data_at(f.plot, f.ax, 402.0f)));
|
||||||
|
hover(f.controller, 400.0f);
|
||||||
|
PLOT_CHECK(f.controller.region_grab(narrow) == plot::RegionGrab::Body);
|
||||||
|
hover(f.controller, 401.0f);
|
||||||
|
PLOT_CHECK(f.controller.region_grab(narrow) == plot::RegionGrab::Body);
|
||||||
|
press(f.controller, 401.0f);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
|
||||||
|
release(f.controller, 401.0f);
|
||||||
|
|
||||||
|
// Six pixels is wide enough to tell the bounds apart, and there the bound wins.
|
||||||
|
const u32 wide = f.plot.add_region(free_region(data_at(f.plot, f.ax, 400.0f),
|
||||||
|
data_at(f.plot, f.ax, 406.0f)));
|
||||||
|
hover(f.controller, 400.0f);
|
||||||
|
PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Left);
|
||||||
|
hover(f.controller, 406.0f);
|
||||||
|
PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Right);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shaded measurement band's two edges *are* the cursors that bound it, so
|
||||||
|
/// dragging one is the cursor drag with a different hit target: the grab maps to the
|
||||||
|
/// cursor owning that edge, the other edge stays where it was, and `Plot::measurement`
|
||||||
|
/// -- which the demo and the renderer both read -- follows as it already does.
|
||||||
|
PLOT_TEST(band_edge_drag_moves_the_owning_cursor_and_updates_the_measurement) {
|
||||||
|
constexpr u32 kSamples = 100u;
|
||||||
|
std::vector<f32> samples(kSamples);
|
||||||
|
for (u32 i = 0; i < kSamples; ++i) samples[i] = static_cast<f32>(i);
|
||||||
|
plot::data::ArraySeries s(std::move(samples), 0.0, 1.0);
|
||||||
|
|
||||||
|
Fixture f;
|
||||||
|
f.ay = f.plot.add_y_axis({"v", {0.0, 99.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
const u32 tr = f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay});
|
||||||
|
const u32 a = f.plot.add_cursor(10.0, plot::Color::hex(0x00FF00));
|
||||||
|
const u32 b = f.plot.add_cursor(60.0, plot::Color::hex(0xFF00FF));
|
||||||
|
const u32 m = f.plot.add_measure(a, b, tr);
|
||||||
|
PLOT_CHECK(f.plot.measurement(m).valid);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.measurement(m).mean, 35.0, 1e-9); // samples 10..60
|
||||||
|
PLOT_CHECK_NEAR(f.plot.measurement(m).dx, 50.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.measurement(m).ymax, 60.0, 1e-9);
|
||||||
|
|
||||||
|
// The band's left edge is cursor `a` at its own column, and the drag moves that
|
||||||
|
// cursor -- not the band, not the view. The pointer lands *between* two samples and
|
||||||
|
// the readout is over the nearest sample to each cursor, which is the measurement's
|
||||||
|
// documented rule: a cursor at 20.3 measures from the sample at x == 20.
|
||||||
|
const f32 edge_a = screen_x_of(f.plot, f.ax, 10.0);
|
||||||
|
const f32 to = column_of(f.plot, f.ax, 20.3);
|
||||||
|
press(f.controller, edge_a);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_cursor() == a);
|
||||||
|
move_to(f.controller, to);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.cursor(a).x, 20.3, 1e-3); // the bound followed the pointer
|
||||||
|
PLOT_CHECK_NEAR(f.plot.cursor(b).x, 60.0, 1e-9); // the other edge did not move
|
||||||
|
const plot::Measurement moved = f.plot.measurement(m);
|
||||||
|
PLOT_CHECK(moved.valid);
|
||||||
|
PLOT_CHECK_NEAR(moved.dx, 40.0, 1e-9); // samples 20..60: 41 of them, dx == 1
|
||||||
|
PLOT_CHECK_NEAR(moved.mean, 40.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(moved.ymin, 20.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(moved.ymax, 60.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(moved.rms, ramp_rms(20u, 60u), 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(moved.integral, static_cast<f64>((20 + 60) * 41 / 2), 1e-9);
|
||||||
|
release(f.controller, to);
|
||||||
|
|
||||||
|
// The right edge is cursor `b`, and grabbing it moves `b` alone.
|
||||||
|
const f32 edge_b = screen_x_of(f.plot, f.ax, 60.0);
|
||||||
|
const f32 to_b = column_of(f.plot, f.ax, 50.3);
|
||||||
|
press(f.controller, edge_b);
|
||||||
|
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor);
|
||||||
|
PLOT_CHECK(f.controller.grabbed_cursor() == b);
|
||||||
|
move_to(f.controller, to_b);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.cursor(b).x, 50.3, 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(f.plot.cursor(a).x, 20.3, 1e-3);
|
||||||
|
const plot::Measurement narrowed = f.plot.measurement(m);
|
||||||
|
PLOT_CHECK(narrowed.valid);
|
||||||
|
PLOT_CHECK_NEAR(narrowed.dx, 30.0, 1e-9); // samples 20..50: 31 of them
|
||||||
|
PLOT_CHECK_NEAR(narrowed.mean, 35.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(narrowed.ymin, 20.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(narrowed.ymax, 50.0, 1e-9);
|
||||||
|
release(f.controller, to_b);
|
||||||
|
}
|
||||||
@@ -1411,3 +1411,182 @@ PLOT_TEST(a_degenerate_range_cannot_freeze_a_live_axis) {
|
|||||||
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 5.0, 1e-9);
|
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 5.0, 1e-9);
|
||||||
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9);
|
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- regions: exact interval statistics (spec §5) ------------------------------
|
||||||
|
|
||||||
|
/// A region is an interval a user drags, so it lands *between* samples: the stats
|
||||||
|
/// must be over exactly the samples inside the bounds, which is what
|
||||||
|
/// `Series::reduce/sum/sumsq` answer for and what the pyramid's `*_buckets` forms,
|
||||||
|
/// counting whole buckets, cannot. The grid here is deliberately nothing a caller
|
||||||
|
/// could divide by -- `x0` is not 0 and `dx` is not 1 -- and the interval is not
|
||||||
|
/// bucket-aligned, so a bucket-grained or off-by-one answer changes the count.
|
||||||
|
///
|
||||||
|
/// The brute-force scan below applies the rule `RegionStats` documents -- the samples
|
||||||
|
/// whose x is at or after `x0` and at or before `x1`, both ends included -- so the two
|
||||||
|
/// are comparable at the last bit. Two spikes *outside* the interval are what make the
|
||||||
|
/// extremes an assertion about the interval rather than about the series.
|
||||||
|
PLOT_TEST(region_stats_match_bruteforce_over_an_unaligned_interval) {
|
||||||
|
constexpr f64 kDx = 0.7;
|
||||||
|
constexpr f64 kX0 = 3.0;
|
||||||
|
constexpr f64 kLo = 10.4;
|
||||||
|
constexpr f64 kHi = 47.6;
|
||||||
|
constexpr u64 kCount = 53u; // x = 3.0 + 0.7*i, i = 11..63, both ends inside
|
||||||
|
|
||||||
|
std::vector<f32> ys = ramp(120);
|
||||||
|
ys[20] = -100.0f; // inside the interval: the minimum
|
||||||
|
ys[40] = 200.0f; // inside the interval: the maximum
|
||||||
|
ys[5] = 500.0f; // before it, on a sample
|
||||||
|
ys[70] = -300.0f; // after it, on a sample
|
||||||
|
const std::vector<f32> samples = ys; // the brute-force copy
|
||||||
|
plot::data::ArraySeries s(ys, kX0, kDx); // takes the vector by value
|
||||||
|
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||||
|
const u32 ax = p.add_x_axis({"t", s.x_bounds(), false, plot::AxisPlace::Bottom, false});
|
||||||
|
const u32 ay = p.add_y_axis({"v", {-200.0, 200.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
||||||
|
|
||||||
|
plot::Region reg;
|
||||||
|
reg.x0 = kLo;
|
||||||
|
reg.x1 = kHi;
|
||||||
|
reg.snap = plot::RegionSnap::Free; // stored verbatim: this is the interval under test
|
||||||
|
const u32 rid = p.add_region(reg);
|
||||||
|
|
||||||
|
const plot::RegionStats st = p.region_stats(rid, tr);
|
||||||
|
PLOT_CHECK(st.valid);
|
||||||
|
PLOT_CHECK(st.count == kCount);
|
||||||
|
PLOT_CHECK_NEAR(st.x0, kLo, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(st.x1, kHi, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(st.width, kHi - kLo, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(st.min, -100.0, 1e-9); // the spike inside, never the one outside
|
||||||
|
PLOT_CHECK_NEAR(st.max, 200.0, 1e-9);
|
||||||
|
|
||||||
|
u64 count = 0;
|
||||||
|
f64 sum = 0.0, sq = 0.0, lo = 0.0, hi = 0.0;
|
||||||
|
for (u64 i = 0; i < samples.size(); ++i) {
|
||||||
|
const f64 x = kX0 + kDx * static_cast<f64>(i);
|
||||||
|
if (x < kLo || x > kHi) continue;
|
||||||
|
const f64 v = static_cast<f64>(samples[static_cast<plot::usize>(i)]);
|
||||||
|
if (count == 0u) {
|
||||||
|
lo = v;
|
||||||
|
hi = v;
|
||||||
|
}
|
||||||
|
lo = std::min(lo, v);
|
||||||
|
hi = std::max(hi, v);
|
||||||
|
sum += v;
|
||||||
|
sq += v * v;
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
PLOT_CHECK(count == kCount); // the scan and the query agree on the interval itself
|
||||||
|
PLOT_CHECK_NEAR(st.mean, sum / static_cast<f64>(count), 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(st.rms, std::sqrt(sq / static_cast<f64>(count)), 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(st.min, lo, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(st.max, hi, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(st.integral, sum * kDx, 1e-6); // left-Riemann, the measurement's rule
|
||||||
|
|
||||||
|
// An interval whose bounds *are* sample x's takes both of those samples: the rule is
|
||||||
|
// inclusive at each end, so this is one sample wider at the top than a half-open one.
|
||||||
|
plot::Region exact;
|
||||||
|
exact.x0 = kX0 + kDx * 11.0; // 10.7
|
||||||
|
exact.x1 = kX0 + kDx * 63.0; // 47.1
|
||||||
|
exact.snap = plot::RegionSnap::Free;
|
||||||
|
const u32 rid_exact = p.add_region(exact);
|
||||||
|
PLOT_CHECK(p.region_stats(rid_exact, tr).count == kCount);
|
||||||
|
|
||||||
|
// Between two adjacent samples there is nothing to average, so the answer is
|
||||||
|
// invalid and every field zero rather than a mean of no samples.
|
||||||
|
plot::Region gap;
|
||||||
|
gap.x0 = 10.71;
|
||||||
|
gap.x1 = 10.79;
|
||||||
|
gap.snap = plot::RegionSnap::Free;
|
||||||
|
const u32 rid_gap = p.add_region(gap);
|
||||||
|
const plot::RegionStats none = p.region_stats(rid_gap, tr);
|
||||||
|
PLOT_CHECK(!none.valid);
|
||||||
|
PLOT_CHECK(none.count == 0u && none.x0 == 0.0 && none.x1 == 0.0 && none.mean == 0.0);
|
||||||
|
|
||||||
|
// A trace with no series and an id that names no region are invalid too.
|
||||||
|
const u32 tr_null = p.add_trace({nullptr, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
||||||
|
PLOT_CHECK(!p.region_stats(rid, tr_null).valid);
|
||||||
|
PLOT_CHECK(!p.region_stats(99u, tr).valid);
|
||||||
|
|
||||||
|
// Moving a region moves the interval the statistics are over.
|
||||||
|
p.set_region(rid, 32.0, 47.6);
|
||||||
|
PLOT_CHECK(p.region_stats(rid, tr).count == 22u); // x = 32.4 .. 47.1, i = 42..63
|
||||||
|
PLOT_CHECK_NEAR(p.region_stats(rid, tr).min, 42.0, 1e-9); // both spikes are outside it now
|
||||||
|
PLOT_CHECK_NEAR(p.region_stats(rid, tr).max, 63.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A saturated ring pins `size()` at capacity while `x0()` advances every acquire, so
|
||||||
|
/// a region whose own bounds never move still covers a different set of samples each
|
||||||
|
/// frame. The cache key includes `x0()` for exactly this: with (series, sample count)
|
||||||
|
/// alone the readout would freeze on the first saturated frame, which is the bug the
|
||||||
|
/// measurement cache's third key exists to prevent and this statistic must not
|
||||||
|
/// reintroduce.
|
||||||
|
///
|
||||||
|
/// The signal is a ramp in absolute x, so a sample's value names its x: what the
|
||||||
|
/// statistics report once the window has slid past a cursor is the current window's
|
||||||
|
/// oldest sample, which a frozen cache cannot produce.
|
||||||
|
PLOT_TEST(region_stats_track_a_sliding_streaming_window) {
|
||||||
|
constexpr u64 kCapacity = 64;
|
||||||
|
plot::data::StreamSeries s(kCapacity, 0.0, 1.0);
|
||||||
|
for (u64 i = 0; i < kCapacity; ++i) s.push(static_cast<f32>(i)); // y == absolute x
|
||||||
|
s.acquire();
|
||||||
|
PLOT_CHECK(s.size() == kCapacity); // the published window is full from here on
|
||||||
|
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
const u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
const u32 tr = p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
||||||
|
|
||||||
|
plot::Region reg;
|
||||||
|
reg.x0 = 10.0;
|
||||||
|
reg.x1 = 60.0;
|
||||||
|
reg.snap = plot::RegionSnap::Free; // the region never moves; the window does
|
||||||
|
const u32 rid = p.add_region(reg);
|
||||||
|
plot::Region behind;
|
||||||
|
behind.x0 = 0.0;
|
||||||
|
behind.x1 = 5.0;
|
||||||
|
behind.snap = plot::RegionSnap::Free;
|
||||||
|
const u32 rid_behind = p.add_region(behind);
|
||||||
|
|
||||||
|
// The returned reference aliases the cached slot the next read updates, so every
|
||||||
|
// comparison here keeps a copy of the frame it is about.
|
||||||
|
const plot::RegionStats first = p.region_stats(rid, tr);
|
||||||
|
PLOT_CHECK(first.valid);
|
||||||
|
PLOT_CHECK(first.count == 51u); // absolute x 10..60
|
||||||
|
PLOT_CHECK_NEAR(first.min, 10.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(first.max, 60.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(first.mean, 35.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(first.integral, 35.0 * 51.0, 1e-9);
|
||||||
|
PLOT_CHECK(p.region_stats(rid_behind, tr).valid); // it is inside the window for now
|
||||||
|
|
||||||
|
// Slide the window past the region's lower bound: 40 more samples and an acquire,
|
||||||
|
// with the sample count unchanged and the region untouched.
|
||||||
|
for (u64 i = kCapacity; i < kCapacity + 40u; ++i) s.push(static_cast<f32>(i));
|
||||||
|
s.acquire();
|
||||||
|
PLOT_CHECK(s.size() == kCapacity); // size() cannot be the cache key
|
||||||
|
PLOT_CHECK_NEAR(s.x0(), 40.0, 1e-9); // x0() is what moved under it
|
||||||
|
|
||||||
|
const plot::RegionStats slid = p.region_stats(rid, tr);
|
||||||
|
PLOT_CHECK(slid.valid);
|
||||||
|
PLOT_CHECK(slid.count == 21u); // absolute x 40..60, the part the ring still holds
|
||||||
|
PLOT_CHECK(slid.count != first.count);
|
||||||
|
PLOT_CHECK_NEAR(slid.min, 40.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(slid.max, 60.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(slid.mean, 50.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(slid.integral, 50.0 * 21.0, 1e-9);
|
||||||
|
// The window has walked out from under the other region: no sample is left in it.
|
||||||
|
PLOT_CHECK(!p.region_stats(rid_behind, tr).valid);
|
||||||
|
|
||||||
|
// And it keeps tracking, one acquire at a time, with nothing but x0() moving.
|
||||||
|
for (u32 i = 0; i < 5u; ++i) {
|
||||||
|
s.push(static_cast<f32>(kCapacity + 40u + i));
|
||||||
|
s.acquire();
|
||||||
|
const plot::RegionStats step = p.region_stats(rid, tr);
|
||||||
|
PLOT_CHECK(step.valid);
|
||||||
|
PLOT_CHECK(step.count == 20u - i); // absolute x (41 + i)..60
|
||||||
|
PLOT_CHECK_NEAR(step.min, 41.0 + static_cast<f64>(i), 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(step.mean, 0.5 * (step.min + 60.0), 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
#include <plot/render/buffer.hpp>
|
#include <plot/render/buffer.hpp>
|
||||||
#include <plot/render/detail/ring_stats.hpp>
|
#include <plot/render/detail/ring_stats.hpp>
|
||||||
#include <plot/render/renderer.hpp>
|
#include <plot/render/renderer.hpp>
|
||||||
|
#include <plot/input/controller.hpp>
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -2207,9 +2208,40 @@ plot::u32 exact_pixels(const std::vector<plot::u8>& frame, plot::u32 fb_w, const
|
|||||||
return count;
|
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".
|
/// 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)};
|
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
|
/// 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
|
/// 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
|
/// 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);
|
PLOT_CHECK_NEAR(r.style().device(r.style().grid_minor_width), 2.0f, 1e-6f);
|
||||||
ctx.destroy();
|
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();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user