Files
plotter/tests/test_controller.cpp
T

1096 lines
53 KiB
C++

#include "harness.hpp"
#include <plot/input/controller.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);
}