Files
plotter/tests/test_controller.cpp
T
Martino Ferrari daff60e3f0 fix(input,examples): a linked figure's wheel anchor uses the group's live edge, and every demo verdict reaches its exit status
Review fix pass 1 on the task 7 work; four findings, all closed.

1. The Qt demo's region sat across the middle of the window, which is exactly
   where its own scripted input check pans: after the check's box zoom the press
   opened DragMode::MoveRegion, the x range never moved, and the check printed
   'input check pan FAIL' -- under an exit 0, because the verdict was printed and
   not returned. The region now rides the left sixth (as the GLFW demo's does),
   both checks pass, and the verdict is wired to the exit status. The same audit
   found two more printed-only verdicts -- the GTK demo's scripted-resize layout
   check and its screenshot write (and Qt's) -- now wired too. What stays
   printed-only is the screenshot line of the GLFW host and the ImGui widget,
   where the dump happens inside the adapter's frame loop.

2. A follow-ing link group was invisible to the input layer: a member's own axis
   must be XFollow::Off (link_x() refuses a Live member), so Controller's
   x_zoom_anchor() extrapolated a coordinate from the pointer instead of using
   the live edge and a wheel over a row's axis strip walked the window back by
   19% of its width (behind 0.000000 -> 0.285809 s). The group now publishes its
   own edge to every member (Axis::live_owner + Plot::set_x_live_owner, written
   by Figure::push_link, cleared when the group stops following or the row
   unlinks), and x_zoom_anchor() reads it. Public-header addition, called out.
   Measured after: behind 0.012992 s and the window's right edge bit-identical to
   the edge the group published at the gesture.

3. The multiplot summary printed x_link_count() -- a number of groups -- under
   'rows in it'. It now reports the members and the group count separately, and
   every row line carries its own group id.

4. Host::controller_at() promised to clamp a pixel in a row gap to the nearest
   row and only clamped below the last one, so a gap pixel went to row 0. It now
   selects by distance, and tests/test_glfw_host.cpp pins it with a 40 px gap --
   the first coverage of the host's figure routing, which needs no window.

New cases: tests/test_controller.cpp x2 (the anchor and the hint's lifecycle),
tests/test_glfw_host.cpp x3 (routing, the gap, the single-plot fallback): 229
cases, 0 failed, ASan clean. All five demos exit 0, the three self-checking
modes exit 0, and the amended assertions were confirmed failing against the
pre-fix behaviour.
2026-09-28 15:52:08 +02:00

1512 lines
75 KiB
C++

#include "harness.hpp"
#include <plot/input/controller.hpp>
#include <plot/plot/figure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/data/array_series.hpp>
#include <plot/data/stream_series.hpp>
#include <cmath>
#include <limits>
#include <utility>
#include <vector>
namespace {
using plot::f32;
using plot::f64;
using plot::u32;
constexpr u32 kShift = static_cast<u32>(plot::input::Mod::Shift);
constexpr u32 kCtrl = static_cast<u32>(plot::input::Mod::Ctrl);
constexpr u32 kNoAxis = ~u32{0};
/// A 800x400 plot with one x axis spanning [0, 100] and the controller attached.
struct Fixture {
plot::Plot plot;
u32 ax = kNoAxis;
u32 ay = kNoAxis;
plot::input::Controller controller{plot};
Fixture() {
plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = plot.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
}
};
f32 screen_x_of(const plot::Plot& p, u32 axis, f64 data) {
return static_cast<f32>(p.x_transform(axis).to_screen_x(data));
}
} // namespace
PLOT_TEST(wheel_zoom_keeps_data_under_cursor_fixed) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const f32 cursor_x = 200.0f;
const f64 before = p.x_transform(ax).to_data_x(cursor_x);
plot::input::WheelEvent w{cursor_x, 100.0f, 0.0f, 1.0f, 0};
c.on_wheel(w);
const f64 after = p.x_transform(ax).to_data_x(cursor_x);
PLOT_CHECK_NEAR(before, after, 1e-6);
PLOT_CHECK(plot::Range{p.x_range(ax)}.span() < 100.0); // zoomed in
}
PLOT_TEST(left_drag_pans_without_changing_span) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const double span_before = p.x_range(ax).span();
const f64 grabbed = p.x_transform(ax).to_data_x(100.0f); // data under the press point
c.on_pointer({100, 100, 0, 0, plot::input::Button::Left, 0, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
c.on_pointer({180, 130, 80, 30, plot::input::Button::Left, 0, true, false, false});
c.on_pointer({180, 130, 0, 0, plot::input::Button::Left, 0, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(p.x_range(ax).span(), span_before, 1e-9); // span unchanged
// Assert the INVARIANT, not a pixel fraction. Grab-and-drag means the data
// grabbed at the press point is under the pointer afterwards. Pixels map to
// data through layout().axes, NOT the whole viewport: the viewport model makes
// dragged data lag the cursor by the label-strip fraction, a defect no
// pixel-fraction assertion can see because it certifies its own arithmetic.
PLOT_CHECK_NEAR(p.x_transform(ax).to_data_x(180.0f), grabbed, 1e-9);
PLOT_CHECK(p.y_range(ay).max > 1.0); // moving down raises y range
PLOT_CHECK(!p.x_axis(ax).auto_scale); // a pan is a user choice, not a fit
}
PLOT_TEST(shift_drag_box_zooms_to_selection) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const u32 e_shift = 1u << 0; // Mod::Shift
const f64 want_lo = p.x_transform(ax).to_data_x(100.0f);
const f64 want_hi = p.x_transform(ax).to_data_x(300.0f);
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, e_shift, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
c.on_pointer({300, 150, 200, 100, plot::input::Button::Left, e_shift, true, false, false});
PLOT_CHECK_NEAR(c.selection_rect().x, 100.0, 1e-6);
PLOT_CHECK_NEAR(c.selection_rect().w, 200.0, 1e-6);
c.on_pointer({300, 150, 0, 0, plot::input::Button::Left, e_shift, false, false, true});
// The box frames exactly the data it encloses: the ranges come from the axes
// transform, and the selection is clamped to layout().axes, not the viewport.
PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 1e-6);
}
PLOT_TEST(box_zoom_uses_release_position_without_a_move) {
// A host that reports only a press and a release -- no motion event between
// them -- MUST still frame the box the release describes: the release position
// is authoritative for the end of the gesture.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const f64 want_lo = p.x_transform(ax).to_data_x(100.0f);
const f64 want_hi = p.x_transform(ax).to_data_x(300.0f);
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom);
// No move: selection_ is still 0x0 until the release supplies the far corner.
c.on_pointer({300, 150, 0, 0, plot::input::Button::Left, kShift, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(p.x_range(ax).min, want_lo, 1e-6);
PLOT_CHECK_NEAR(p.x_range(ax).max, want_hi, 1e-6);
PLOT_CHECK(!p.x_axis(ax).auto_scale); // a box zoom is a user choice, not a fit
}
PLOT_TEST(auto_scale_disabled_by_user_interaction) {
// A wheel zoom or pan MUST clear auto_scale on the affected axis so the range
// the user chose is not snapped back by the next autoscale.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0});
PLOT_CHECK(!p.x_axis(ax).auto_scale);
}
// --- resolutions of the brief's ambiguity, pinned ------------------------
PLOT_TEST(box_zoom_below_threshold_is_a_click) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, false, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({102, 52, 2, 2, plot::input::Button::Left, kShift, true, false, false});
c.on_pointer({102, 52, 0, 0, plot::input::Button::Left, kShift, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(p.x_range(ax).min, 0.0, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, 800.0, 1e-9);
}
PLOT_TEST(shift_wheel_zooms_x_only) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, kShift});
PLOT_CHECK(f.plot.x_range(f.ax).span() < 100.0);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0, 1e-9);
}
PLOT_TEST(ctrl_wheel_zooms_y_only) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
f.controller.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, kCtrl});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
PLOT_CHECK(f.plot.y_range(f.ay).span() < 2.0);
}
PLOT_TEST(hovered_cursor_only_inside_plot_area) {
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 cid = 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() == cid);
// The left label strip is outside the plot area.
c.on_pointer({10, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
PLOT_CHECK(c.hovered_cursor() == cid);
}
PLOT_TEST(ctrl_drag_moves_the_nearest_cursor) {
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 cid = p.add_cursor(20.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, 20.0);
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
c.on_pointer({sx + 100, 200, 100, 0, plot::input::Button::Left, kCtrl, true, false, false});
c.on_pointer({sx + 100, 200, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(p.cursor(cid).x > 20.0);
PLOT_CHECK_NEAR(p.cursor(cid).x, p.x_transform(ax).to_data_x(sx + 100), 1e-9);
}
PLOT_TEST(middle_drag_on_x_strip_zooms_x_only) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
const plot::PlotLayout& l = f.plot.layout();
const f32 strip_x = l.axes.x + 0.5f * l.axes.w;
const f32 strip_y = l.x_label_area.y + 0.5f * l.x_label_area.h;
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomX);
// Same sign convention as the wheel: a positive dy zooms in.
f.controller.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
f.controller.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
// 20 px of drag is a tenth of a decade: the span is 10^(-20/200) of its
// former self. A pixel is not a wheel notch, so this pins the magnitude.
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0, 1e-9);
PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale);
PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched y axis keeps auto scale
}
PLOT_TEST(middle_drag_on_y_strip_zooms_y_only) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
const plot::PlotLayout& l = f.plot.layout();
const f32 strip_x = l.y_label_area.x + 0.5f * l.y_label_area.w;
const f32 strip_y = l.axes.y + 0.5f * l.axes.h;
f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomY);
f.controller.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
f.controller.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
}
PLOT_TEST(key_plus_zooms_in_about_the_centre) {
Fixture f;
const f64 mid_before = 0.5 * (f.plot.x_range(f.ax).min + f.plot.x_range(f.ax).max);
f.controller.on_key({plot::input::kKeyZoomIn, 0, true});
PLOT_CHECK(f.plot.x_range(f.ax).span() < 100.0);
PLOT_CHECK_NEAR(0.5 * (f.plot.x_range(f.ax).min + f.plot.x_range(f.ax).max), mid_before, 1e-9);
const f64 span_after = f.plot.x_range(f.ax).span();
f.controller.on_key({plot::input::kKeyZoomIn, 0, false}); // key-up is ignored
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), span_after, 1e-12);
}
PLOT_TEST(key_minus_zooms_out) {
Fixture f;
f.controller.on_key({plot::input::kKeyZoomOut, 0, true});
PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0);
}
/// A host can hand the controller a coordinate that is not a number: ImGui reports
/// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out
/// of a degenerate mapping of its own. The pan arithmetic runs through the axis
/// transforms, so such a value would leave a range that is not a range -- and
/// because a gesture that sets a range also clears auto scale, panning and zooming
/// could not bring it back.
PLOT_TEST(non_finite_pointer_does_not_move_a_range) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const plot::Range xr = p.x_range(ax);
const plot::Range yr = p.y_range(ay);
c.on_pointer({100, 100, 0, 0, plot::input::Button::Left, 0, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan);
const float inf = std::numeric_limits<float>::infinity();
const float nan = std::numeric_limits<float>::quiet_NaN();
const float sentinel = -std::numeric_limits<float>::max(); // ImGui's "no pointer"
// The pointer-left-the-window sentinel arrives at the unrepresentable end of the
// axis, so scaling and differencing it is a 1e35 pan. It must read as no
// movement at all.
c.on_pointer({sentinel, sentinel, inf, -inf, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK(std::isfinite(p.x_range(ax).min) && std::isfinite(p.x_range(ax).max));
PLOT_CHECK(std::isfinite(p.y_range(ay).min) && std::isfinite(p.y_range(ay).max));
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-9);
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan); // still the same drag
// A usable position with an unusable delta is no movement either, and a later
// usable position is not differenced against the sentinel.
c.on_pointer({180, 130, inf, nan, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9);
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-9);
c.on_pointer({220, 160, 40, 30, plot::input::Button::Left, 0, true, false, false});
PLOT_CHECK(std::isfinite(p.x_range(ax).min)); // a real move still pans
PLOT_CHECK(p.x_range(ax).min < xr.min);
// The buttons in a sanitised event still act: the release ends the drag.
c.on_pointer({220, 160, 0, 0, plot::input::Button::Left, 0, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
}
PLOT_TEST(non_finite_wheel_does_not_break_a_range) {
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
const float inf = std::numeric_limits<float>::infinity();
const float nan = std::numeric_limits<float>::quiet_NaN();
// A zoom is anchored at the data coordinate under the pointer, so a position that
// is not a number must not become the anchor: the axis would be re-centred on
// NaN, which no gesture could undo. The fallback is a real coordinate and the
// zoom still happens. (A *finite* sentinel -- ImGui's `-FLT_MAX` for "no pointer"
// -- is not the controller's to interpret: finite coordinates are legal input.
// The adapter translates that one; see tests/test_imgui_widget.cpp.)
const double span_before = p.x_range(ax).span();
c.on_wheel({nan, nan, 0.0f, 1.0f, 0});
PLOT_CHECK(std::isfinite(p.x_range(ax).min) && std::isfinite(p.x_range(ax).max));
PLOT_CHECK(std::isfinite(p.y_range(ay).min) && std::isfinite(p.y_range(ay).max));
PLOT_CHECK(std::fabs(p.x_range(ax).min) < 1e6 && std::fabs(p.x_range(ax).max) < 1e6);
PLOT_CHECK(p.x_range(ax).span() < span_before);
// A non-finite amount is no zoom at all -- and, just as important, it must not
// touch the axes. A *zeroed* amount would be a zero-notch zoom (a factor of
// exactly one): the range would survive, but the range setters it runs through
// clear auto scale, so one garbage wheel would quietly disarm auto-fit.
p.set_x_auto_scale(ax, true);
p.set_y_auto_scale(ay, true);
const plot::Range xr = p.x_range(ax);
const plot::Range yr = p.y_range(ay);
c.on_wheel({400.0f, 200.0f, 0.0f, nan, 0});
c.on_wheel({400.0f, 200.0f, 0.0f, inf, 0});
PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-12);
PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-12);
PLOT_CHECK_NEAR(p.y_range(ay).min, yr.min, 1e-12);
PLOT_CHECK(p.x_axis(ax).auto_scale);
PLOT_CHECK(p.y_axis(ay).auto_scale);
}
PLOT_TEST(key_f_auto_fits_traces) {
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 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
std::vector<f32> ys(51);
for (u64 i = 0; i < ys.size(); ++i) ys[i] = static_cast<f32>(i) * 0.1f; // 0.0 .. 5.0
plot::data::ArraySeries s(std::move(ys), 0.0, 1.0); // x = 0 .. 50
p.add_trace({&s, plot::Color::hex(0x4FA3FF), 1.5f, 0, 0});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
c.on_key({plot::input::kKeyFit, 0, true});
PLOT_CHECK(p.x_range(ax).min < 0.0);
PLOT_CHECK(p.x_range(ax).max < 100.0); // fitted to the series' 0..50
PLOT_CHECK(p.y_range(ay).min < 0.0);
PLOT_CHECK(p.y_range(ay).max > 1.0); // fitted to the series' 0..5
PLOT_CHECK(p.x_axis(ax).auto_scale); // a fit re-arms auto scale
PLOT_CHECK(p.y_axis(ay).auto_scale);
}
// --- tolerances are logical, coordinates are device pixels -------------------
/// The grab radius is a distance the user aims with, so it must be the same
/// *physical* distance at any DPI: the controller compares device-pixel coordinates
/// against `logical * Plot::ui_scale()`. The press positions below are the same
/// logical distance (0.9 and 1.1 of the 5 px radius) from the cursor at both scales,
/// so the same press grabs -- and the same press just outside does not.
PLOT_TEST(grab_radius_scales_with_dpi) {
for (const f32 scale : {1.0f, 2.0f}) {
plot::Plot p;
p.set_ui_scale(scale);
p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 cid = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
plot::input::Controller c(p);
c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6);
const f32 sx = screen_x_of(p, ax, 50.0);
const f32 inside = 0.9f * 5.0f * scale; // logical: within the grab radius
const f32 outside = 1.1f * 5.0f * scale; // logical: just past it
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false});
PLOT_CHECK(c.hovered_cursor() == cid);
c.on_pointer({sx + outside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false});
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
// ...and a ctrl-drag at that same logical distance opens a cursor move, which is
// the gesture the radius actually gates.
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, kCtrl, true, true,
false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
}
}
/// The box-zoom click threshold is a hand movement too: a selection the user
/// perceives as a few pixels is a click at any DPI, so both dimensions are compared
/// against `logical * Plot::ui_scale()`.
PLOT_TEST(box_zoom_click_threshold_scales_with_dpi) {
for (const f32 scale : {1.0f, 2.0f}) {
plot::Plot p;
p.set_ui_scale(scale);
p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
const plot::Range before = p.x_range(ax);
// 3 logical px on each side: a click, at both scales.
const f32 x0 = 100.0f * scale;
const f32 y0 = 50.0f * scale;
const f32 small = 3.0f * scale;
c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({x0 + small, y0 + small, small, small, plot::input::Button::Left, kShift, true,
false, false});
c.on_pointer({x0 + small, y0 + small, 0, 0, plot::input::Button::Left, kShift, false, false,
true});
PLOT_CHECK_NEAR(p.x_range(ax).min, before.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, before.max, 1e-9);
// 5 logical px on each side: a box, at both scales.
const f32 large = 5.0f * scale;
c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({x0 + large, y0 + large, large, large, plot::input::Button::Left, kShift, true,
false, false});
c.on_pointer({x0 + large, y0 + large, 0, 0, plot::input::Button::Left, kShift, false, false,
true});
PLOT_CHECK(p.x_range(ax).min > before.min);
PLOT_CHECK(p.x_range(ax).max < before.max);
}
}
// --------------------------------------------- live x follow (spec §12)
//
// The gestures on an axis that is following live data. Every path below already
// existed; what is new is that none of them disarms following, and that the ranges
// they set are read by `Plot::set_x_range` as a span and an offset behind the live
// edge rather than as absolute positions.
namespace {
/// A streaming plot under the controller: one `StreamSeries` on one x axis, 800x400,
/// with `push` samples already produced and acquired. The axis starts 10 units wide;
/// a case arms following when it needs it, so "Off is the default" is the state every
/// case here starts from.
struct LiveFixture {
plot::data::StreamSeries series;
plot::Plot graph;
plot::input::Controller controller{graph};
u32 ax = 0;
u32 ay = 0;
explicit LiveFixture(u64 retain, u64 push) : series(retain, 0.0, 1.0) {
graph.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
ax = graph.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
ay = graph.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
graph.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay});
feed(push);
controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
}
void feed(u64 n) {
for (u64 i = 0; i < n; ++i) series.push(0.0f);
series.acquire();
}
void live() { graph.set_x_follow(ax, plot::XFollow::Live); }
f64 newest() const { return series.x_bounds().max; }
plot::Range xr() const { return graph.x_range(ax); }
};
/// The centre of the x label strip: outside `axes`, which is where a zoom's anchor
/// falls back to the live edge, and where the middle-drag x zoom is aimed anyway.
plot::input::WheelEvent strip_wheel(const plot::Plot& p, f32 notches) {
const plot::Rect strip = std::as_const(p).layout().x_label_area;
return {strip.x + 0.5f * strip.w, strip.y + 0.5f * strip.h, 0.0f, notches, 0};
}
/// One left drag of `dx` device pixels across the axes, through the controller.
void drag(plot::input::Controller& c, const plot::Rect& axes, f32 dx) {
const f32 x = axes.x + 0.5f * axes.w;
const f32 y = axes.y + 0.5f * axes.h;
c.on_pointer({x, y, 0.0f, 0.0f, plot::input::Button::Left, 0, true, true, false});
c.on_pointer({x + dx, y, dx, 0.0f, plot::input::Button::Left, 0, true, false, false});
c.on_pointer({x + dx, y, 0.0f, 0.0f, plot::input::Button::Left, 0, false, false, true});
}
} // namespace
/// The regression §12 exists for. Today a wheel zoom clears the x axis' auto scale,
/// and a live plot then stops following the newest sample for good: the window stays
/// where the zoom left it while the stream walks out of it. Under `Live` the span
/// shrinks, following stays armed, and the window keeps ending where it ended --
/// which is asserted by *both* halves below, because the edge is the newest sample
/// only for a zoom anchored on it.
PLOT_TEST(wheel_zoom_keeps_following) {
LiveFixture f(200, 40);
f.graph.set_x_auto_scale(f.ax, true); // the only way to follow before this feature
f.graph.layout();
f.live();
const f64 span_before = f.xr().span();
const plot::Rect axes = std::as_const(f.graph).layout().axes;
// A notch from outside `axes` anchors on the live edge, so the window still ends
// at the newest sample afterwards -- and goes on doing so as data arrives.
f.controller.on_wheel(strip_wheel(f.graph, 1.0f));
PLOT_CHECK(f.xr().span() < span_before);
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
f.feed(20);
f.graph.layout();
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
const f64 zoomed = f.xr().span();
// A notch from inside `axes` anchors on the data under the pointer, which pushes
// the window back from the edge by what the zoom chose -- and it still follows:
// that distance is constant as the stream advances, so the newest sample is the
// same distance off the right edge on every frame instead of drifting away.
f.controller.on_wheel({axes.x + 0.5f * axes.w, axes.y + 0.5f * axes.h, 0.0f, 1.0f, 0});
PLOT_CHECK_NEAR(f.xr().span(), zoomed * std::exp(-0.15), 1e-9);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
const f64 behind = f.newest() - f.xr().max;
PLOT_CHECK(behind > 0.0);
f.feed(25);
f.graph.layout();
PLOT_CHECK_NEAR(f.newest() - f.xr().max, behind, 1e-9);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
}
/// Two half-notches are one full notch, because the wheel *multiplies* the span: an
/// additive step would give `2 * (span - d/2) != span - d`. The two fixtures have
/// identical state, so the comparison is exact -- and the pinned value says which
/// arithmetic produced it.
PLOT_TEST(wheel_zoom_is_relative) {
LiveFixture a(200, 40);
LiveFixture b(200, 40);
a.live();
b.live();
PLOT_CHECK_NEAR(a.xr().span(), b.xr().span(), 1e-12); // same state to start from
a.controller.on_wheel(strip_wheel(a.graph, 0.5f));
a.controller.on_wheel(strip_wheel(a.graph, 0.5f));
b.controller.on_wheel(strip_wheel(b.graph, 1.0f));
PLOT_CHECK_NEAR(a.xr().span(), b.xr().span(), 1e-9);
PLOT_CHECK_NEAR(b.xr().span(), 10.0 * std::exp(-0.15), 1e-9);
PLOT_CHECK(a.xr().span() < 10.0);
// Each window still ends at its own live edge, so the relative zoom kept following.
PLOT_CHECK_NEAR(a.xr().max, a.newest(), 1e-9);
PLOT_CHECK_NEAR(b.xr().max, b.newest(), 1e-9);
PLOT_CHECK(a.graph.x_follow(a.ax) == plot::XFollow::Live);
PLOT_CHECK(b.graph.x_follow(b.ax) == plot::XFollow::Live);
}
/// Panning a live plot moves the window, not the scale: the offset grows by the
/// drag's data delta, the span is untouched, and following is still armed. Dragging
/// right reveals earlier data, which for a live window means moving back from the
/// edge -- and the window then keeps that distance as the stream advances.
PLOT_TEST(mouse_pan_shifts_the_offset_and_preserves_the_span) {
LiveFixture f(200, 40);
f.live();
const f64 span = f.xr().span();
const plot::Rect axes = std::as_const(f.graph).layout().axes;
const f32 dx = 60.0f;
drag(f.controller, axes, dx);
const f64 want = static_cast<f64>(dx) / static_cast<f64>(axes.w) * span;
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), want, 1e-9);
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
PLOT_CHECK_NEAR(f.xr().max, f.newest() - want, 1e-9);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None);
f.feed(20);
f.graph.layout();
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), want, 1e-9);
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
PLOT_CHECK_NEAR(f.xr().max, f.newest() - want, 1e-9);
}
/// Dragging toward the live edge stops at it: the offset clamps at 0, so the window
/// ends at the newest sample instead of turning inside out -- the failure an
/// unclamped offset would produce, and one no gesture could undo.
PLOT_TEST(pan_cannot_pass_the_live_edge) {
LiveFixture f(200, 40);
f.live();
const plot::Rect axes = std::as_const(f.graph).layout().axes;
drag(f.controller, axes, 100.0f); // back from the edge
PLOT_CHECK(f.graph.x_offset(f.ax) > 0.0);
const f64 span = f.xr().span();
drag(f.controller, axes, -300.0f); // hard toward it, past it
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 0.0, 1e-9);
PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9);
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
PLOT_CHECK(f.xr().min < f.xr().max); // a real window, not an inverted one
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
}
/// The point of an offset: a user who scrolled back keeps seeing what they scrolled
/// to. The window advances by exactly what the stream advanced, so the sample at its
/// right edge is the same one frame after frame, and the span is unchanged.
PLOT_TEST(offset_survives_new_samples) {
LiveFixture f(200, 48);
f.live();
f.graph.set_x_offset(f.ax, 10.0);
const f64 span = f.xr().span();
const f64 newest0 = f.newest();
const f64 edge_sample = f.xr().max;
PLOT_CHECK_NEAR(edge_sample, newest0 - 10.0, 1e-9);
for (int i = 0; i < 4; ++i) {
f.feed(5);
f.graph.layout();
PLOT_CHECK_NEAR(f.graph.x_offset(f.ax), 10.0, 1e-9);
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
PLOT_CHECK_NEAR(f.xr().max - edge_sample, f.newest() - newest0, 1e-9);
// ...and it lands on a sample, so the same sample is at the right edge rather
// than the window having crept between two of them.
PLOT_CHECK_NEAR((f.xr().max - newest0) / f.series.dx(),
std::round((f.xr().max - newest0) / f.series.dx()), 1e-9);
}
PLOT_CHECK_NEAR(f.xr().max, f.newest() - 10.0, 1e-9);
}
/// The pre-existing behaviour, pinned so the mode is provably opt-in: on an `Off`
/// axis a gesture clears auto scale and the window then stays exactly where the
/// gesture left it, however much data arrives. That is what following replaces, and
/// it is still what an untouched plot does -- the six other controller cases above
/// are about this path.
PLOT_TEST(off_plot_zoom_and_pan_are_unchanged) {
LiveFixture f(200, 40);
f.graph.set_x_auto_scale(f.ax, true);
f.graph.layout(); // today's implicit "follow": fit the retained window
const plot::Range fitted = f.xr();
const plot::Rect axes = std::as_const(f.graph).layout().axes;
const f32 px = axes.x + 0.3f * axes.w;
const f64 under = f.graph.x_transform(f.ax).to_data_x(px);
f.controller.on_wheel({px, axes.y + 0.5f * axes.h, 0.0f, 1.0f, 0});
PLOT_CHECK_NEAR(f.graph.x_transform(f.ax).to_data_x(px), under, 1e-9); // anchor still
PLOT_CHECK(f.xr().span() < fitted.span());
PLOT_CHECK(!f.graph.x_axis(f.ax).auto_scale); // a gesture is still a user choice
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Off); // and arms nothing
const f64 span = f.xr().span();
const f64 grabbed = f.graph.x_transform(f.ax).to_data_x(axes.x + 0.25f * axes.w);
drag(f.controller, axes, 60.0f);
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
PLOT_CHECK_NEAR(f.graph.x_transform(f.ax).to_data_x(axes.x + 0.25f * axes.w + 60.0f), grabbed,
1e-9);
// Frozen: the stream walks on and the window does not move. This is the pre-fix
// behaviour, and the reason `Live` exists.
const plot::Range frozen = f.xr();
f.feed(30);
f.graph.layout();
PLOT_CHECK_NEAR(f.xr().min, frozen.min, 1e-9);
PLOT_CHECK_NEAR(f.xr().max, frozen.max, 1e-9);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Off);
}
/// The deferred DPI item from the UI-scale task: the middle-drag axis zoom's gain was
/// per *device* pixel, so the same physical drag zoomed twice as much at 2x. The gain
/// is now logical -- 200 logical px is one decade -- and the drag delta is converted
/// through the plot's scale, so the same physical drag zooms the same amount at either
/// scale. The wheel is in notches, a unit that is already device-independent, so it
/// must *not* be converted: one notch scales the span by the same factor at both.
PLOT_TEST(drag_zoom_is_logical_the_wheel_is_not_scaled) {
for (const f32 scale : {1.0f, 2.0f}) {
plot::Plot p;
p.set_ui_scale(scale);
p.set_viewport({0.0f, 0.0f, 800.0f * scale, 400.0f * scale});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0.0f, 0.0f, 800.0f * scale, 400.0f * scale});
const plot::PlotLayout& l = p.layout();
const f32 strip_x = l.axes.x + 0.5f * l.axes.w;
const f32 strip_y = l.x_label_area.y + 0.5f * l.x_label_area.h;
const f32 drag_px = 20.0f * scale; // 20 *logical* px at both scales
c.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::AxisZoomX);
c.on_pointer({strip_x, strip_y + drag_px, 0, drag_px, plot::input::Button::Middle, 0, true,
false, false});
c.on_pointer({strip_x, strip_y + drag_px, 0, 0, plot::input::Button::Middle, 0, false, false,
true});
PLOT_CHECK_NEAR(p.x_range(ax).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
const f64 span_before = p.x_range(ax).span();
c.on_wheel({400.0f * scale, 200.0f * scale, 0.0f, 1.0f, 0});
PLOT_CHECK_NEAR(p.x_range(ax).span(), span_before * std::exp(-0.15), 1e-9);
}
}
/// The consequences of a degenerate range on a live axis, from the gesture side: the
/// wheel, the pan and the axis zoom all refuse a span that is not a span, so the
/// frozen axis a stored zero-span left behind was one no interaction could move --
/// while `x_follow()` said `Live`. With the guard in `set_x_range` the slips a caller
/// can actually make leave the gestures working.
PLOT_TEST(live_gestures_survive_a_degenerate_range) {
LiveFixture f(200, 40);
f.live();
const plot::Rect axes = std::as_const(f.graph).layout().axes;
const f64 span = f.xr().span();
const f64 newest = f.newest();
const plot::input::WheelEvent wheel = strip_wheel(f.graph, 1.0f);
f.graph.set_x_range(f.ax, {newest, newest}); // the programmatic slip
PLOT_CHECK_NEAR(f.xr().span(), span, 1e-9);
f.graph.set_x_range(f.ax, {newest - 5.0, newest - 15.0}); // and an inverted one
PLOT_CHECK_NEAR(f.xr().span(), 10.0, 1e-9); // normalised, not stored inverted
f.controller.on_wheel(wheel);
PLOT_CHECK_NEAR(f.xr().span(), 10.0 * std::exp(-0.15), 1e-9); // the wheel still zooms
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
const f64 span_after_wheel = f.xr().span();
drag(f.controller, axes, 60.0f); // a left drag: the pan gesture
PLOT_CHECK(f.graph.x_offset(f.ax) > 0.0); // the pan still pans
PLOT_CHECK_NEAR(f.xr().span(), span_after_wheel, 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);
}
// --- 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);
}
/// A region is geometry in *data* coordinates, and `XFollow::Live` recomputes the x window
/// on every layout -- so the two claims that matter under a live axis are that the region
/// stays exactly where it was put in data space while the view scrolls under it, and that
/// dragging one moves the region rather than being read as a change to the range. The
/// live path owns the range, so both halves are things it could plausibly break: a region
/// measured in pixels would drift with the window, and a drag that reached the range
/// setters would move the view *and* clear nothing good.
PLOT_TEST(region_holds_in_data_space_under_live_follow) {
LiveFixture f(40, 200); // a saturated ring: the window slides, its width does not
f.live();
f.graph.layout();
const f64 window_span = f.xr().span();
const f64 x0 = f.xr().min + 0.25 * window_span;
const f64 x1 = f.xr().min + 0.65 * window_span;
const u32 rid = f.graph.add_region(free_region(x0, x1));
PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0, 1e-12);
PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1, 1e-12);
const f32 left_before = bound_x(f.graph, rid, true);
const f32 right_before = bound_x(f.graph, rid, false);
PLOT_CHECK(left_before >= 0.0f && right_before > left_before);
// The window advances on its own, which is what the follow path is for.
const f64 newest_before = f.newest();
f.feed(3);
f.graph.layout();
PLOT_CHECK(f.xr().max > newest_before);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
// The region's own interval is untouched, to the last bit ...
PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0, 1e-12);
PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1, 1e-12);
// ... while the pixels it is drawn in moved: the view scrolled under a region that
// stayed in data space.
PLOT_CHECK(bound_x(f.graph, rid, true) < left_before);
PLOT_CHECK(bound_x(f.graph, rid, false) < right_before);
// A body drag moves the region and nothing else: not the window, not the follow state.
const plot::Range held = f.xr();
const f32 mid = 0.5f * (bound_x(f.graph, rid, true) + bound_x(f.graph, rid, false));
const f32 to = mid + 40.0f;
const f64 delta = data_at(f.graph, f.ax, to) - data_at(f.graph, f.ax, mid);
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, to);
PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0 + delta, 1e-9);
PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1 + delta, 1e-9);
PLOT_CHECK_NEAR(f.xr().min, held.min, 1e-9); // the range the follow path owns is untouched
PLOT_CHECK_NEAR(f.xr().max, held.max, 1e-9);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
release(f.controller, to);
PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live);
PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0 + delta, 1e-9);
PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1 + delta, 1e-9);
// And the region is still where the pointer left it once more samples arrive: the
// window moves, the region does not.
f.feed(2);
f.graph.layout();
PLOT_CHECK(f.xr().max > held.max);
PLOT_CHECK_NEAR(f.graph.region(rid).x0, x0 + delta, 1e-12);
PLOT_CHECK_NEAR(f.graph.region(rid).x1, x1 + delta, 1e-12);
}
/// `RegionSnap::NearestSample` is applied **once**, on release -- not on every motion. A
/// body dragged a fraction of a sample would otherwise snap both ends back onto the
/// samples they started on, and the region would never move at all while the pointer did;
/// a region that never snapped would instead end its drag between two samples, which is
/// what the snap exists to prevent. Both halves are observable, and one drag shows both.
PLOT_TEST(region_snap_is_applied_once_on_release) {
constexpr u32 kSamples = 120u;
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); // the sample grid is the integers
Fixture f;
f.ay = f.plot.add_y_axis({"v", {0.0, 120.0}, false, plot::AxisPlace::Left, false});
f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay});
plot::Region snapping; // the default: NearestSample
snapping.x0 = 10.4;
snapping.x1 = 20.6;
const u32 rid = f.plot.add_region(snapping);
// On the way in, both bounds land on the grid the traces define.
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 10.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 21.0, 1e-9);
// The contrast: a Free region keeps what it was given. It is placed clear of the snapped
// one, because two bodies under the pointer resolve to the region on top -- the later
// one -- and this case is about the drag of *this* region.
plot::Region kept = snapping;
kept.x0 = 40.4;
kept.x1 = 50.6;
kept.snap = plot::RegionSnap::Free;
const u32 free_id = f.plot.add_region(kept);
PLOT_CHECK_NEAR(f.plot.region(free_id).x0, 40.4, 1e-12);
PLOT_CHECK_NEAR(f.plot.region(free_id).x1, 50.6, 1e-12);
// Drag the snapped region's body by six tenths of a sample.
const f32 mid = 0.5f * (bound_x(f.plot, rid, true) + bound_x(f.plot, rid, false));
const f32 to = column_of(f.plot, f.ax, data_at(f.plot, f.ax, mid) + 0.6);
press(f.controller, mid);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
move_to(f.controller, to);
// Mid-drag the bounds are exactly where the pointer put them: no snapping yet.
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 10.6, 1e-3);
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 21.6, 1e-3);
release(f.controller, to);
// On release they are pulled onto the grid, once, and both ends moved.
PLOT_CHECK_NEAR(f.plot.region(rid).x0, 11.0, 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x1, 22.0, 1e-9);
PLOT_CHECK(f.plot.region(rid).x1 > f.plot.region(rid).x0); // the interval survived both ends moving
}
/// A region the brief promises a pointer can still *move*: one whose bounds sit closer
/// together than the fixed grab radius allows on both sides. With that radius kept on each
/// bound, every point of a region narrower than twice it (10 logical px) lies within a
/// bound's reach, so the edge branch always wins and the interior is unreachable -- such a
/// region could be resized but never moved, and the collapsed-region rescue only engages
/// below 3 px. The bands are therefore cut back until the middle is left, while a region
/// wide enough for two full bands and an interior keeps the radius it always had.
PLOT_TEST(narrow_region_stays_movable_by_its_body) {
Fixture f;
const f32 radius = f.plot.style().device(5.0f); // the controller's own grab radius, logical
f32 column = 300.0f; // one strip of columns per region below
for (const f32 width_px : {4.0f, 6.0f, 9.0f}) {
const f64 a = data_at(f.plot, f.ax, column);
const f64 b = data_at(f.plot, f.ax, column + width_px);
const u32 rid = f.plot.add_region(free_region(a, b));
const f32 middle = column + 0.5f * width_px;
// Both bounds are grabbable: the edge still wins where the pointer is on it ...
hover(f.controller, column);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left);
hover(f.controller, column + width_px);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Right);
// ... and the middle is the body, so the region can be moved as well as resized.
hover(f.controller, middle);
PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Body);
press(f.controller, middle);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion);
PLOT_CHECK(f.controller.grabbed_region() == rid);
move_to(f.controller, middle + 30.0f);
const f64 delta = data_at(f.plot, f.ax, middle + 30.0f) - data_at(f.plot, f.ax, middle);
PLOT_CHECK_NEAR(f.plot.region(rid).x0, a + delta, 1e-9);
PLOT_CHECK_NEAR(f.plot.region(rid).x1, b + delta, 1e-9); // moved, width intact
release(f.controller, middle + 30.0f);
column += 80.0f;
}
// Where there is room the radius is unchanged: a press a few pixels inside a wide
// region's bound is that bound and not the body.
const u32 wide = f.plot.add_region(
free_region(data_at(f.plot, f.ax, 600.0f), data_at(f.plot, f.ax, 700.0f)));
hover(f.controller, 600.0f + radius - 1.0f);
PLOT_CHECK(f.controller.region_grab(wide) == plot::RegionGrab::Left);
press(f.controller, 600.0f + radius - 1.0f);
PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft);
release(f.controller, 600.0f + radius - 1.0f);
}
/// `publish_hot()` is the single writer of `HotHandles`, and the wheel and the keyboard
/// change the geometry under a *stationary* pointer. Without a publish of their own, a
/// frame drawn right after a scroll would highlight a handle the pointer has left -- or
/// miss one it has just landed on -- until the next mouse move, which is exactly the
/// stale-highlight defect the publish exists to prevent. The layout has to be refreshed
/// with it: the region rects the hit test reads were measured for the range the zoom has
/// just replaced.
PLOT_TEST(hot_handles_follow_a_gesture_that_moves_the_geometry) {
Fixture f;
const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00));
const u32 off_centre = f.plot.add_cursor(75.0, plot::Color::hex(0xFF00FF));
const u32 rid = f.plot.add_region(free_region(70.0, 90.0));
const f32 radius = f.plot.style().device(5.0f);
// The pointer sits four pixels to the right of the marker: on it, and published as such.
const f32 px = screen_x_of(f.plot, f.ax, 50.0) + 4.0f;
hover(f.controller, px);
PLOT_CHECK(f.plot.hot_handles().cursor == cid);
// A zoom *in* about the pointer grows the marker's offset from it by the zoom factor --
// 4 px becomes 6.3 -- so the marker leaves without the pointer moving at all.
f.controller.on_wheel({px, 200.0f, 0.0f, 3.0f, 0u});
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) > radius);
PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid);
PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid);
// And the other way round: a zoom out about the same column brings it back under the
// stationary pointer, where the published state has to be the marker again.
f.controller.on_wheel({px, 200.0f, 0.0f, -3.0f, 0u});
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 50.0) - px) <= radius);
PLOT_CHECK(f.controller.hovered_cursor() == cid);
PLOT_CHECK(f.plot.hot_handles().cursor == cid);
// The keyboard path: a zoom about the plot centre moves the *other* marker, which the
// pointer is on, out from under it.
const f32 on_marker = screen_x_of(f.plot, f.ax, 75.0);
hover(f.controller, on_marker);
PLOT_CHECK(f.plot.hot_handles().cursor == off_centre);
f.controller.on_key({plot::input::kKeyZoomIn, 0u, true});
PLOT_CHECK(std::fabs(screen_x_of(f.plot, f.ax, 75.0) - on_marker) > radius);
PLOT_CHECK(f.plot.hot_handles().cursor == plot::kInvalid);
// And a region bound: the zone that is published after the gesture is the zone the
// geometry now gives, not the one the bound had before the zoom.
const f32 bound = bound_x(f.plot, rid, true);
hover(f.controller, bound);
PLOT_CHECK(f.plot.hot_handles().region == rid);
PLOT_CHECK(f.plot.hot_handles().zone == plot::RegionGrab::Left);
f.controller.on_key({plot::input::kKeyZoomIn, 0u, true});
PLOT_CHECK(std::fabs(bound_x(f.plot, rid, true) - bound) > radius); // the bound moved away
PLOT_CHECK(f.plot.hot_handles().zone == f.controller.region_grab(rid));
PLOT_CHECK(f.plot.hot_handles().zone != plot::RegionGrab::Left);
}
/// A `Figure`'s shared x axis is the **bottom row's own**, so the axis zoom is
/// reachable there through the one hit test the controller has always used: the press
/// that lands in the bottom row's x label strip starts `AxisZoomX` exactly as it does on
/// a stand-alone plot, and because the rows share one x link the zoom moves all of them.
///
/// A figure that put its shared axis *outside* every row would leave this gesture
/// unreachable on every row at once: `x_label_area` would be zero-height, `contains()`
/// would never be true, and a middle drag would fall through to `DragMode::None`.
///
/// The other half is what makes that a statement about *which* row: the same drag on an
/// interior row -- at the point a label strip would land if that row had one -- is
/// deliberately not the gesture. The row reserved no x chrome, so there is no strip for
/// the press to be inside, and the zoom the bottom row's strip can start cannot be
/// started from a row that has none. If that were not so the shared axis would be one
/// strip among several, each of which could zoom the one range they all share.
PLOT_TEST(a_middle_drag_on_a_figures_bottom_row_zooms_the_shared_x_axis) {
plot::Figure figure(3u);
figure.set_viewport({0, 0, 800, 600});
for (u32 row = 0; row < 3u; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
const u32 group = figure.add_x_link();
for (u32 row = 0; row < 3u; ++row) PLOT_CHECK(figure.link_x(row, group));
const plot::Range before = figure.x_link_range(group);
figure.layout();
const plot::PlotLayout& bottom = figure.plot(2u).layout();
PLOT_CHECK(bottom.x_label_area.h > 0.0f);
const f32 strip_x = bottom.axes.x + 0.5f * bottom.axes.w;
const f32 strip_y = bottom.x_label_area.y + 0.5f * bottom.x_label_area.h;
PLOT_CHECK(bottom.x_label_area.contains(strip_x, strip_y));
plot::input::Controller c(figure.plot(2u));
c.set_viewport(figure.viewport());
c.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::AxisZoomX);
// Same sign and magnitude as on a single plot: a positive dy zooms in, 20 px being a
// tenth of a decade.
c.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
c.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
// The gesture reached the row's own axis...
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6);
// ... and, through the link, every other row with it: one x axis, zoomed once.
figure.layout();
for (u32 row = 0; row < 3u; ++row) {
PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).span(), figure.x_link_range(group).span(), 1e-9);
PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).span(), 100.0 * std::pow(10.0, -20.0 / 200.0),
1e-6);
}
PLOT_CHECK(figure.x_link_range(group).span() < before.span());
// An interior row is not the same gesture. A press at the point a label strip *would*
// land in -- the band this row's chrome would occupy if the figure gave it one -- is
// not inside anything: the row reserved no x chrome, so its `x_label_area` is
// zero-height and no press can be inside it. The middle drag therefore falls through
// to `DragMode::None`, and because `update_drag()` does nothing in that mode the
// group's window -- the one range every row shares -- is untouched by it.
const plot::PlotLayout& interior = figure.plot(0u).layout();
PLOT_CHECK(interior.x_label_area.h == 0.0f);
const f32 interior_x = interior.axes.x + 0.5f * interior.axes.w;
const f32 interior_y = figure.layout().rows[0].bottom() - 0.5f * bottom.x_label_area.h;
PLOT_CHECK(!interior.x_label_area.contains(interior_x, interior_y));
const plot::Range shared_before = figure.x_link_range(group);
plot::input::Controller interior_controller(figure.plot(0u));
interior_controller.on_pointer(
{interior_x, interior_y, 0, 0, plot::input::Button::Middle, 0, true, true, false});
PLOT_CHECK(interior_controller.drag_mode() == plot::input::DragMode::None);
interior_controller.on_pointer(
{interior_x, interior_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false});
interior_controller.on_pointer(
{interior_x, interior_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true});
PLOT_CHECK(interior_controller.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).span(), shared_before.span(), 1e-12);
PLOT_CHECK_NEAR(figure.x_link_range(group).span(), shared_before.span(), 1e-12);
}
// The live edge a *link group* follows, seen by the input layer.
//
// A figure row's x axis is `XFollow::Off` by construction -- the group owns the shared
// window and its follow state, and `link_x()` refuses a `Live` member -- so before a
// following group published its edge, `Controller::x_zoom_anchor()` could not see that the
// axis was following at all. A wheel over the row's axis strip then anchored on the
// coordinate the pointer's x extrapolates to (the middle of the axes for a strip-centre
// pointer) rather than on the live edge, and the window walked back off the newest sample
// by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps
// the edge, and the hint that makes it possible exists exactly while a group follows.
PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
constexpr u32 kFigureRows = 3u;
constexpr f64 kWheelDx = 1.0 / 1000.0;
plot::data::StreamSeries series(1u << 12, 0.0, kWheelDx);
plot::f32 samples[512];
for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast<plot::f32>(i % 7u);
series.push(samples, 512u);
series.acquire();
plot::Figure figure(kFigureRows);
figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f});
for (u32 row = 0; row < kFigureRows; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
PLOT_CHECK(p.trace_count() == 1u);
}
const u32 group = figure.add_x_link();
for (u32 row = 0; row < kFigureRows; ++row) PLOT_CHECK(figure.link_x(row, group));
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.25}));
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
figure.layout();
const f64 newest = series.x_bounds().max;
PLOT_CHECK_NEAR(figure.x_link_range(group).max, newest, 1e-12);
for (u32 row = 0; row < kFigureRows; ++row) {
f64 edge = 0.0;
PLOT_CHECK(figure.plot(row).x_live_owner(0u, edge));
PLOT_CHECK_NEAR(edge, newest, 1e-12);
// The member's own axis is still Off: the group is what follows.
PLOT_CHECK(figure.plot(row).x_follow(0u) == plot::XFollow::Off);
}
const plot::PlotLayout& bottom = figure.plot(kFigureRows - 1u).layout();
const f32 strip_x = bottom.axes.x + 0.5f * bottom.axes.w;
const f32 strip_y = bottom.x_label_area.y + 0.5f * bottom.x_label_area.h;
PLOT_CHECK(bottom.x_label_area.contains(strip_x, strip_y));
const plot::Range before = figure.x_link_range(group);
plot::input::Controller c(figure.plot(kFigureRows - 1u));
c.set_viewport(figure.viewport());
const plot::input::WheelEvent wheel{strip_x, strip_y, 0.0f, 1.0f, 0u};
for (u32 i = 0; i < 3u; ++i) c.on_wheel(wheel);
figure.layout();
const plot::Range after = figure.x_link_range(group);
// The zoom reached the group's one window and narrowed it...
PLOT_CHECK(after.span() < before.span() * 0.99);
// ... and left it ending on the live edge, which is the anchor a gesture outside `axes`
// must use. Pre-fix this is `newest - 0.2 span` or so: the extrapolated anchor.
PLOT_CHECK_NEAR(after.max, newest, 1e-12);
for (u32 row = 0; row < kFigureRows; ++row) {
PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).max, after.max, 1e-12);
PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).min, after.min, 1e-12);
}
}
PLOT_TEST(a_figures_live_edge_hint_is_published_only_while_the_group_follows) {
constexpr u32 kFigureRows = 2u;
plot::data::StreamSeries series(1u << 10, 0.0, 1.0 / 100.0);
plot::f32 samples[64];
for (u32 i = 0; i < 64u; ++i) samples[i] = 1.0f;
series.push(samples, 64u);
series.acquire();
plot::Figure figure(kFigureRows);
figure.set_viewport({0.0f, 0.0f, 400.0f, 400.0f});
for (u32 row = 0; row < kFigureRows; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
}
const u32 group = figure.add_x_link();
for (u32 row = 0; row < kFigureRows; ++row) PLOT_CHECK(figure.link_x(row, group));
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.5}));
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
figure.layout();
f64 edge = 0.0;
PLOT_CHECK(figure.plot(0u).x_live_owner(0u, edge));
PLOT_CHECK_NEAR(edge, series.x_bounds().max, 1e-12);
// Disarming takes the edge away at once -- nothing may anchor on an edge no group is
// following -- and leaves the range exactly where it was.
const plot::Range held = figure.x_link_range(group);
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Off));
PLOT_CHECK(!figure.plot(0u).x_live_owner(0u, edge));
PLOT_CHECK_NEAR(figure.x_link_range(group).max, held.max, 1e-12);
// Arming publishes it again, and a row that leaves the group loses it.
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
figure.layout();
PLOT_CHECK(figure.plot(1u).x_live_owner(0u, edge));
PLOT_CHECK(figure.link_x(1u, 0u)); // back to the unlinked group
PLOT_CHECK(!figure.plot(1u).x_live_owner(0u, edge));
PLOT_CHECK(figure.plot(0u).x_live_owner(0u, edge)); // row 0 is still in the group
// A plot no figure owns is never claimed, `Live` or not: the hint is an owner's, and a
// single plot anchors on its own edge through `x_live_edge()`, which is unchanged.
plot::Plot solo;
solo.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
solo.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
solo.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
solo.set_x_follow(0u, plot::XFollow::Live);
solo.layout();
PLOT_CHECK(solo.x_follow(0u) == plot::XFollow::Live);
PLOT_CHECK(!solo.x_live_owner(0u, edge));
}