// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "harness.hpp" #include #include #include #include #include #include #include #include #include namespace { using plot::f32; using plot::f64; using plot::u32; constexpr u32 kShift = static_cast(plot::input::Mod::Shift); constexpr u32 kCtrl = static_cast(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(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_box_zooms_x_only_even_over_a_cursor) { // The modifier table: Ctrl+Left is the x-only box zoom now -- it wins over the // cursor grab a plain press would open, and the release frames the x range from // the selection while the y range (and the cursor under the press) stay put. 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}); const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); const u32 cid = p.add_cursor(200.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, 200.0); const f64 want_lo = p.x_transform(ax).to_data_x(sx); const f64 want_hi = p.x_transform(ax).to_data_x(sx + 100.0f); c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::BoxZoom); c.on_pointer({sx + 100, 260, 100, 60, plot::input::Button::Left, kCtrl, true, false, false}); c.on_pointer({sx + 100, 260, 0, 0, plot::input::Button::Left, kCtrl, 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_NEAR(p.y_range(ay).min, -1.0, 1e-9); // the masked axis is untouched PLOT_CHECK_NEAR(p.y_range(ay).max, 1.0, 1e-9); PLOT_CHECK_NEAR(p.cursor(cid).x, 200.0, 1e-9); // and no cursor was grabbed } 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); } PLOT_TEST(ctrl_plus_zooms_only_x) { Fixture f; f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); const f64 x_before = f.plot.x_range(f.ax).span(); const f64 y_before = f.plot.y_range(f.ay).span(); f.controller.on_key({plot::input::kKeyZoomIn, kCtrl, true}); PLOT_CHECK(f.plot.x_range(f.ax).span() < x_before); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), y_before, 1e-12); } PLOT_TEST(alt_plus_zooms_only_y) { Fixture f; f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); constexpr plot::u32 kAlt = static_cast(plot::input::Mod::Alt); const f64 x_before = f.plot.x_range(f.ax).span(); const f64 y_before = f.plot.y_range(f.ay).span(); f.controller.on_key({plot::input::kKeyZoomIn, kAlt, true}); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), x_before, 1e-12); PLOT_CHECK(f.plot.y_range(f.ay).span() < y_before); } /// 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::infinity(); const float nan = std::numeric_limits::quiet_NaN(); const float sentinel = -std::numeric_limits::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::infinity(); const float nan = std::numeric_limits::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 ys(51); for (u64 i = 0; i < ys.size(); ++i) ys[i] = static_cast(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 7 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 * 7.0f * scale; // logical: within the grab radius const f32 outside = 1.1f * 7.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 press at that same logical distance opens a cursor move, which is // the gesture the radius actually gates. (A plain press: Ctrl is the x-only // box zoom's mask now, not a cursor modifier.) c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, 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(dx) / static_cast(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); } } /// `Style::device()` documents a non-positive `ui_scale` as host state that must be /// tolerated, but `axis_zoom()` divided by it directly. With `ui_scale == 0` and a /// negative drag delta, `logical_dy` was `-inf`, the factor `+inf`, and the /// `!(factor > 0.0)` guard did not reject it: `zoom_x_axis`/`zoom_y_axis` computed /// `anchor + (bound - anchor) * inf`, and `set_y_range()` stored `inf`/`NaN` verbatim and /// cleared `auto_scale` -- an axis left non-finite until an auto-fit. A negative scale /// silently inverted the zoom direction. Both now mean no zoom at all, exactly the state /// `device()` documents for a scale that is not one. PLOT_TEST(axis_zoom_refuses_a_non_positive_ui_scale) { for (const f32 scale : {0.0f, -1.0f}) { plot::Plot p; p.set_ui_scale(scale); p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); // `auto_scale` on, and no trace bound: `layout()` has nothing to fit, so the range is // the one below and clearing `auto_scale` is a change this case can see. 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", {0.0, 100.0}, true, plot::AxisPlace::Left, false}); plot::input::Controller c(p); c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); const plot::Range xb = p.x_range(ax); const plot::Range yb = p.y_range(ay); const plot::PlotLayout& l = p.layout(); // A middle drag on the x label strip, dragged *up* (a negative dy): the sign that // made the factor infinite before. 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; 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 - 30.0f, 0, -30.0f, plot::input::Button::Middle, 0, true, false, false}); c.on_pointer({strip_x, strip_y - 30.0f, 0, 0, plot::input::Button::Middle, 0, false, false, true}); PLOT_CHECK(std::isfinite(p.x_range(ax).min)); PLOT_CHECK(std::isfinite(p.x_range(ax).max)); PLOT_CHECK(p.x_range(ax).min == xb.min); PLOT_CHECK(p.x_range(ax).max == xb.max); // And the y strip, where the infinite factor was stored verbatim. const f32 y_strip_x = l.y_label_area.x + 0.5f * l.y_label_area.w; const f32 y_strip_y = l.axes.y + 0.5f * l.axes.h; c.on_pointer({y_strip_x, y_strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::AxisZoomY); c.on_pointer({y_strip_x, y_strip_y - 30.0f, 0, -30.0f, plot::input::Button::Middle, 0, true, false, false}); c.on_pointer({y_strip_x, y_strip_y - 30.0f, 0, 0, plot::input::Button::Middle, 0, false, false, true}); PLOT_CHECK(std::isfinite(p.y_range(ay).min)); PLOT_CHECK(std::isfinite(p.y_range(ay).max)); PLOT_CHECK(p.y_range(ay).min == yb.min); PLOT_CHECK(p.y_range(ay).max == yb.max); // The range setters never ran, so the axes' own auto scale stands untouched. PLOT_CHECK(p.x_axis(ax).auto_scale); PLOT_CHECK(p.y_axis(ay).auto_scale); } } /// 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(px)); } /// The device column a data x is drawn at. f32 column_of(const plot::Plot& p, u32 axis, f64 data) { return static_cast(p.x_transform(axis).to_screen_x(data)); } /// The y twins of `data_at()`/`column_of()`: what a horizontal ruler is placed by and hit in. f64 data_at_row(const plot::Plot& p, u32 axis, f32 row) { return p.y_transform(axis).to_data_y(static_cast(row)); } f32 row_of(const plot::Plot& p, u32 axis, f64 data) { return static_cast(p.y_transform(axis).to_screen_y(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(i) * static_cast(i); return std::sqrt(sq / static_cast(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 grab is the *plain* press's gesture: Ctrl+Left is the x-only box zoom now // (see ctrl_drag_box_zooms_x_only_even_over_a_cursor), so there is no modifier // variant of a cursor grab to pin here. } /// A snapping cursor follows the pointer for the whole drag: the drag writes the free /// pointer position (`CursorPlacement::Free`) and the snap is committed on the drop, so on a /// *sparse* row -- where the samples are far apart -- the marker tracks the hand instead of /// hopping between sample columns. This is the reported defect ("they stick to data in a /// wired way") measured rather than asserted: every step is within a pixel of the pointer. PLOT_TEST(cursor_follows_the_pointer_freely_while_dragging) { Fixture f; f.ay = f.plot.add_y_axis({"v", {0.0, 4.0}, false, plot::AxisPlace::Left, false}); std::vector ys{0.0f, 1.0f, 2.0f, 3.0f, 4.0f}; plot::data::ArraySeries s(std::move(ys), 0.0, 25.0); // 5 samples over [0, 100] f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay}); const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00)); f.plot.set_cursor_snap(cid, plot::SnapMode::NearestSample); f.plot.layout(); const f32 start = screen_x_of(f.plot, f.ax, 50.0); const f32 pitch = screen_x_of(f.plot, f.ax, 75.0) - start; PLOT_CHECK(pitch > 100.0f); // the row really is sparse: one sample hop is over 100 px press(f.controller, start); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); for (f32 d = 7.0f; d <= 0.9f * pitch; d += 7.0f) { const f32 px = start + d; move_to(f.controller, px); const f32 cursor_px = column_of(f.plot, f.ax, f.plot.cursor(cid).x); PLOT_CHECK(std::fabs(cursor_px - px) <= 1.0f); // tracks the pointer, not a sample column } release(f.controller, start + 0.9f * pitch); } /// The drop commits the snap: during the drag the stored x is the free pointer position and /// equal to no sample of the grid, and after the release it is the nearest sample. Both /// halves in one case, so the moment the snap runs is pinned rather than inferred. PLOT_TEST(cursor_snaps_when_the_drag_ends) { Fixture f; f.ay = f.plot.add_y_axis({"v", {0.0, 4.0}, false, plot::AxisPlace::Left, false}); std::vector ys{0.0f, 1.0f, 2.0f, 3.0f, 4.0f}; plot::data::ArraySeries s(std::move(ys), 0.0, 25.0); // samples at x = 0,25,50,75,100 f.plot.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, f.ax, f.ay}); const u32 cid = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00)); f.plot.set_cursor_snap(cid, plot::SnapMode::NearestSample); f.plot.layout(); const f32 start = screen_x_of(f.plot, f.ax, 50.0); const f32 pitch = screen_x_of(f.plot, f.ax, 75.0) - start; const f32 to = start + 0.55f * pitch; // between the 50 and 75 samples, nearer 75 press(f.controller, start); move_to(f.controller, to); const f64 mid = f.plot.cursor(cid).x; PLOT_CHECK_NEAR(mid, data_at(f.plot, f.ax, to), 1e-9); // the free pointer position PLOT_CHECK(std::fabs(mid - 50.0) > 1.0 && std::fabs(mid - 75.0) > 1.0); // on no sample release(f.controller, to); PLOT_CHECK_NEAR(f.plot.cursor(cid).x, 75.0, 1e-9); // the drop landed on the grid } /// A cursor within the grab radius takes the press from a region's edge at the same point: /// the hit test runs cursor-first, and both are hot, so what a press means is the cursor. /// With the cursor clear of the bound the region wins its own edge back -- the precedence is /// a *proximity* rule, not a blanket one that would brick the region. PLOT_TEST(cursor_wins_the_grab_over_a_nearby_region_handle) { Fixture f; 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); // A region whose left bound sits 6 px from the marker -- inside the 7 px grab radius. const f32 edge = marker + 6.0f; const u32 rid = f.plot.add_region( free_region(data_at(f.plot, f.ax, edge), data_at(f.plot, f.ax, edge + 80.0f))); hover(f.controller, edge); PLOT_CHECK(f.controller.hovered_cursor() == cid); PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left); // both are hot press(f.controller, edge); 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, edge); // Move the cursor well clear: now no cursor is close, and the region's own edge wins. f.plot.set_cursor_x(cid, data_at(f.plot, f.ax, marker - 200.0f)); hover(f.controller, edge); PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid); PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left); press(f.controller, edge); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::ResizeRegionLeft); PLOT_CHECK(f.controller.grabbed_region() == rid); release(f.controller, edge); } /// The other half of the precedence rule: a region with no cursor near it is as draggable as /// it ever was -- each zone still opens its own gesture, the edge is still an edge, and a /// body drag still preserves the width exactly. PLOT_TEST(a_region_without_a_cursor_nearby_is_still_draggable) { Fixture f; const u32 cid = f.plot.add_cursor(10.0, plot::Color::hex(0x00FF00)); // far from the region const u32 rid = f.plot.add_region(free_region(40.0, 60.0)); const f32 left = bound_x(f.plot, rid, true); const f32 right = bound_x(f.plot, rid, false); const f32 mid = 0.5f * (left + right); PLOT_CHECK(right > left); hover(f.controller, left); PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid); PLOT_CHECK(f.controller.region_grab(rid) == plot::RegionGrab::Left); 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); const f64 width = f.plot.region(rid).x1 - f.plot.region(rid).x0; press(f.controller, mid); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveRegion); move_to(f.controller, mid + 40.0f); PLOT_CHECK_NEAR(f.plot.region(rid).x1 - f.plot.region(rid).x0, width, 1e-9); // moved, same width release(f.controller, mid + 40.0f); (void)cid; } /// The affordance is honest: the furthest press that *hovers* the cursor is exactly the /// furthest that *grabs* it, because hover and grab read the one radius (`cursor_at()`). /// One pixel past it neither does -- so there is no band where the highlight promises a grab /// the press then refuses. PLOT_TEST(hover_and_grab_use_the_same_radius) { Fixture f; 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); f32 radius = -1.0f; for (f32 d = 0.0f; d <= 20.0f; d += 1.0f) { hover(f.controller, marker + d); if (f.controller.hovered_cursor() != cid) break; radius = d; } PLOT_CHECK(radius >= 6.0f && radius <= 8.0f); // the reliable-grab range the brief asks for press(f.controller, marker + radius); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); PLOT_CHECK(f.controller.grabbed_cursor() == cid); release(f.controller, marker + radius); hover(f.controller, marker + radius + 1.0f); PLOT_CHECK(f.controller.hovered_cursor() == plot::kInvalid); press(f.controller, marker + radius + 1.0f); PLOT_CHECK(f.controller.grabbed_cursor() == plot::kInvalid); release(f.controller, marker + radius + 1.0f); } /// Spec §14 on the real pointer path: a horizontal ruler is grabbed by the same hit test as a /// marker -- no modifier, the same radius -- and the drag writes the coordinate its own /// orientation owns. The ruler's y follows the pointer's row; its x stays the dead field it is; /// a vertical cursor elsewhere on the plot is not disturbed; and the view does not pan. PLOT_TEST(horizontal_ruler_is_grabbed_and_dragged_along_y) { Fixture f; f.ay = f.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); const u32 marker = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00)); const u32 ruler = f.plot.add_h_ruler(40.0, plot::Color::hex(0x35D0A8)); f.plot.layout(); const f32 column = screen_x_of(f.plot, f.ax, 70.0); // clear of the marker's own column const f32 row = row_of(f.plot, f.ay, 40.0); // Hover first: what the renderer highlights and what a press grabs are one answer. hover(f.controller, column, row); PLOT_CHECK(f.controller.hovered_cursor() == ruler); PLOT_CHECK(f.plot.hot_handles().cursor == ruler); press(f.controller, column, row); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); PLOT_CHECK(f.controller.grabbed_cursor() == ruler); PLOT_CHECK(f.plot.hot_handles().grabbed_cursor == ruler); // Up is more y: the ruler follows the pointer's row through its own y axis. move_to(f.controller, column, row - 60.0f); PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, data_at_row(f.plot, f.ay, row - 60.0f), 1e-9); PLOT_CHECK(f.plot.cursor(ruler).y > 40.0); // The coordinate the ruler does not own, and the marker that owns it, are both untouched. PLOT_CHECK_NEAR(f.plot.cursor(ruler).x, 0.0, 1e-12); PLOT_CHECK_NEAR(f.plot.cursor(marker).x, 50.0, 1e-12); // The gesture belonged to the ruler, not to the view: no range moved. PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, 0.0, 1e-9); release(f.controller, column, row - 60.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); // And it stays where the drag left it. PLOT_CHECK(f.plot.cursor(ruler).y > 40.0); } /// An inverted (descending) y range is ordinary -- depth, row index, descending time -- because /// `set_y_range()` stores the range verbatim and only the transform widens it. The drag's clamp /// must therefore order the bounds: `std::clamp` requires `min <= max`, so a raw /// `clamp(y, range.min, range.max)` collapses the ruler onto one end on such an axis (and is /// undefined behaviour besides). The ruler has to follow the pointer here, and stay inside the /// range. PLOT_TEST(horizontal_ruler_follows_the_pointer_on_an_inverted_y_range) { Fixture f; // Descending: the layout maps `range.min` (100) to the axes' bottom and `range.max` (0) to its // top, so a pointer further down is a *larger* y. f.ay = f.plot.add_y_axis({"v", {100.0, 0.0}, false, plot::AxisPlace::Left, false}); const u32 ruler = f.plot.add_h_ruler(50.0, plot::Color::hex(0x35D0A8)); f.plot.layout(); const f32 column = screen_x_of(f.plot, f.ax, 50.0); const f32 row = row_of(f.plot, f.ay, 50.0); hover(f.controller, column, row); PLOT_CHECK(f.controller.hovered_cursor() == ruler); press(f.controller, column, row); PLOT_CHECK(f.controller.grabbed_cursor() == ruler); const f32 moved = row + 60.0f; move_to(f.controller, column, moved); // Exactly the data under the pointer, not a collapsed bound, and inside the range. PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, data_at_row(f.plot, f.ay, moved), 1e-9); PLOT_CHECK(f.plot.cursor(ruler).y > 50.0); PLOT_CHECK(f.plot.cursor(ruler).y < 100.0); release(f.controller, column, moved); PLOT_CHECK(f.plot.cursor(ruler).y > 50.0); } /// The horizontal half of `cursor_snaps_when_the_drag_ends`: an `HRulerSnap::NearestTick` ruler /// dragged through the controller writes the free pointer row for the whole gesture and lands on /// a y tick only on release -- the snap-on-drop a marker now uses, which a ruler did *not* before /// (it snapped continuously inside `set_cursor_y`). This is the case that catches a regression /// to that continuous behaviour: mid-drag the stored y is on no tick and equals the pointer's row, /// and only the drop moves it to the grid. PLOT_TEST(a_dragged_nearest_tick_ruler_snaps_only_when_the_drag_ends) { Fixture f; f.ay = f.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); const u32 ruler = f.plot.add_h_ruler(40.0, plot::Color::hex(0x35D0A8)); f.plot.set_cursor_h_snap(ruler, plot::HRulerSnap::NearestTick); f.plot.layout(); const std::vector& ticks = f.plot.y_axis(f.ay).ticks; PLOT_CHECK(ticks.size() >= 3u); // A row between two adjacent ticks, nearer the lower-valued one (the larger screen row). const f64 lo = ticks[1].value; const f64 hi = ticks[2].value; PLOT_CHECK(lo < hi); const f64 between = lo + 0.4 * (hi - lo); // equal to no tick, nearer `lo` const auto is_tick = [&ticks](f64 y) { for (const plot::Tick& t : ticks) { if (std::fabs(t.value - y) < 1e-9) return true; } return false; }; PLOT_CHECK(!is_tick(between)); const f32 column = screen_x_of(f.plot, f.ax, 70.0); const f32 to = static_cast(row_of(f.plot, f.ay, between)); const f32 from = static_cast(row_of(f.plot, f.ay, f.plot.cursor(ruler).y)); press(f.controller, column, from); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); PLOT_CHECK(f.controller.grabbed_cursor() == ruler); move_to(f.controller, column, to); // Mid-drag the stored y is the free pointer row and on no tick: the snap has not run. PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, data_at_row(f.plot, f.ay, to), 1e-9); PLOT_CHECK(!is_tick(f.plot.cursor(ruler).y)); // The drop commits the snap: the stored y is the nearest tick and it was not before. release(f.controller, column, to); PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, lo, 1e-9); PLOT_CHECK(is_tick(f.plot.cursor(ruler).y)); } /// Nearest-first resolution *across* orientations: a pointer within reach of both a marker and a /// ruler takes the nearer of the two, so neither widens the other's target -- which is what lets /// one hit test serve two instruments. Each press below is 1 px from its winner and 4 px from the /// other, both inside the logical grab radius. What the winner's drag writes is its own /// coordinate and nothing else. PLOT_TEST(vertical_and_horizontal_cursors_do_not_confuse_each_other) { Fixture f; f.ay = f.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); const u32 marker = f.plot.add_cursor(50.0, plot::Color::hex(0x00FF00)); const u32 ruler = f.plot.add_h_ruler(40.0, plot::Color::hex(0x35D0A8)); f.plot.layout(); const f32 marker_x = screen_x_of(f.plot, f.ax, 50.0); const f32 ruler_y = row_of(f.plot, f.ay, 40.0); const f32 near = 1.0f; const f32 far = 4.0f; // Nearer the marker: the point is on both, and the marker is what hovers and what a press // grabs -- the ruler's y does not move with the drag that follows. const f32 marker_side_x = marker_x + near; const f32 ruler_side_y = ruler_y + far; hover(f.controller, marker_side_x, ruler_side_y); PLOT_CHECK(f.controller.hovered_cursor() == marker); press(f.controller, marker_side_x, ruler_side_y); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); PLOT_CHECK(f.controller.grabbed_cursor() == marker); move_to(f.controller, marker_side_x + 40.0f, ruler_side_y); PLOT_CHECK_NEAR(f.plot.cursor(marker).x, data_at(f.plot, f.ax, marker_side_x + 40.0f), 1e-9); PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, 40.0, 1e-12); PLOT_CHECK_NEAR(f.plot.cursor(ruler).x, 0.0, 1e-12); release(f.controller, marker_side_x + 40.0f, ruler_side_y); // The symmetric case: the marker put back under the pointer, and the press 1 px from the ruler // and 4 px from the marker. The ruler takes it, and its y alone moves. f.plot.set_cursor_x(marker, data_at(f.plot, f.ax, marker_x)); const f32 near_ruler_x = marker_x + far; const f32 near_ruler_y = ruler_y - near; hover(f.controller, near_ruler_x, near_ruler_y); PLOT_CHECK(f.controller.hovered_cursor() == ruler); press(f.controller, near_ruler_x, near_ruler_y); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::MoveCursor); PLOT_CHECK(f.controller.grabbed_cursor() == ruler); const f64 marker_before = f.plot.cursor(marker).x; move_to(f.controller, near_ruler_x, near_ruler_y + 30.0f); PLOT_CHECK_NEAR(f.plot.cursor(ruler).y, data_at_row(f.plot, f.ay, near_ruler_y + 30.0f), 1e-9); PLOT_CHECK_NEAR(f.plot.cursor(marker).x, marker_before, 1e-12); release(f.controller, near_ruler_x, near_ruler_y + 30.0f); PLOT_CHECK(f.plot.cursor(ruler).y < 40.0); } /// 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}) { // the plain press: Ctrl+Left is the box zoom now 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(col), static_cast(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 samples(kSamples); for (u32 i = 0; i < kSamples; ++i) samples[i] = static_cast(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((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 samples(kSamples); for (u32 i = 0; i < kSamples; ++i) samples[i] = static_cast(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 (14 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(7.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(7.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 9.8 with six notches -- so the marker leaves without the pointer moving at // all. Six rather than three: the grab radius is 7 px, and the point is a marker *out* of // reach, so the zoom has to clear it comfortably. f.controller.on_wheel({px, 200.0f, 0.0f, 6.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: the matching 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, -6.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. /// A following group's window stays **on the data**: the offset bound is `retained - span`, not /// `retained`. With the loose bound, scrolling back put the window's *right* edge on the oldest /// retained sample, which leaves the window's whole span to its left over data the scope never /// had -- an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge. /// That is the "sometimes zooming in, the left or right margin becomes empty" report, and it is /// why the bound has to be about the window and not about one edge of it. PLOT_TEST(a_following_group_keeps_its_whole_window_on_the_data) { plot::data::StreamSeries series(1u << 12, 0.0, 1.0 / 1000.0); plot::f32 samples[512]; for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast(i % 7u); series.push(samples, 512u); series.acquire(); plot::Figure figure(1u); figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f}); figure.plot(0u).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); figure.plot(0u).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); figure.plot(0u).add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"}); const u32 group = figure.add_x_link(); PLOT_CHECK(figure.link_x(0u, 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 plot::Range data = series.x_bounds(); const f64 span = figure.x_link_range(group).span(); PLOT_CHECK(span > 0.0); // Ask to scroll far past the oldest sample, the way a drag or a zoom-in near the left edge does. PLOT_CHECK(figure.set_x_link_range(group, {data.min - 10.0 * span, data.min - 9.0 * span})); figure.layout(); const plot::Range after = figure.x_link_range(group); std::printf("[follow-bound] data=[%.4f..%.4f] span=%.4f after=[%.4f..%.4f]\n", data.min, data.max, span, after.min, after.max); // The window is still a window, and its *left* edge is on the data: that is the assertion the // loose bound failed, where `after.min` came out a whole span below `data.min`. PLOT_CHECK_NEAR(after.span(), span, 1e-9 * span); PLOT_CHECK(after.min >= data.min - 1e-9); PLOT_CHECK(after.max <= data.max + 1e-9); } 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(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)); } // ------------------------------------------------- the crosshair (spec §15) /// Spec §15, the wiring: a pointer over the data area places the crosshair at the data /// coordinates under it, and a pointer that leaves the axes clears it. A press neither clears it /// nor moves it away from the pointer -- it follows the pointer, and a pointer still over the /// axes is still pointing at what it grabbed -- while a wheel, which moves the data without /// moving the pointer, re-anchors it to the same pointer position rather than leaving it on the /// value it was placed at. PLOT_TEST(crosshair_tracks_motion_and_clears_on_leave) { Fixture fx; fx.plot.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false}); fx.plot.layout(); const plot::Rect axes = fx.plot.layout().axes; const f32 cx = axes.x + 0.37f * axes.w; const f32 cy = axes.y + 0.61f * axes.h; // No pointer has been seen, so there is no crosshair: it is a hover, and a hover needs a // pointer. PLOT_CHECK(!fx.plot.crosshair().active); // A motion inside the data area places it at the coordinates under the pointer, through the // same transforms every gesture uses -- so the line, the value and the pointer cannot drift. fx.controller.on_pointer({cx, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, /*down=*/false, false}); PLOT_CHECK(fx.plot.crosshair().active); PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9); PLOT_CHECK_NEAR(fx.plot.crosshair().y, fx.plot.y_transform(0u).to_data_y(cy), 1e-9); // Both lines: a pointer over the axes knows its x and its y. PLOT_CHECK(fx.plot.crosshair().vertical); PLOT_CHECK(fx.plot.crosshair().horizontal); // Moving re-anchors it, and a press keeps it: the pointer did not leave the axes. const f32 moved_x = axes.x + 0.52f * axes.w; fx.controller.on_pointer({moved_x, cy, moved_x - cx, 0.0f, plot::input::Button::Left, 0u, true, true, false}); PLOT_CHECK(fx.plot.crosshair().active); PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(moved_x), 1e-9); fx.controller.on_pointer({moved_x, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true}); PLOT_CHECK(fx.plot.crosshair().active); // A pointer that leaves the data area clears it -- into the y label strip, where no data is // under the pointer at all. fx.controller.on_pointer({axes.x - 4.0f, cy, -4.0f, 0.0f, plot::input::Button::Left, 0u, false, false, false}); PLOT_CHECK(fx.plot.layout().y_label_area.contains(axes.x - 4.0f, cy)); // the premise PLOT_CHECK(!fx.plot.crosshair().active); // ... and back inside re-places it, with the coordinate of the new position. fx.controller.on_pointer({cx, cy, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, false}); PLOT_CHECK(fx.plot.crosshair().active); PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9); // A wheel moved the data under a stationary pointer, so the crosshair is re-anchored: the // value it reports is the one under the pointer *now*, not the one it was placed at. const f64 before = fx.plot.crosshair().x; fx.controller.on_wheel({cx, cy, 0.0f, 1.0f, 0u}); PLOT_CHECK(fx.plot.crosshair().active); PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9); PLOT_CHECK(fx.plot.crosshair().x != before); // The same for an axis zoom from the keyboard: the ranges move, the pointer does not. const f64 after_wheel = fx.plot.crosshair().x; fx.controller.on_key({plot::input::kKeyZoomOut, 0u, true}); PLOT_CHECK(fx.plot.crosshair().active); PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(cx), 1e-9); PLOT_CHECK(fx.plot.crosshair().x != after_wheel); // A resize moves the axes under a stationary pointer, so the crosshair is re-placed against // the new rect -- and a resize that leaves the pointer outside the new axes clears it, the // same rule as a pointer that left. const f32 far_x = axes.right() - 2.0f; fx.controller.on_pointer({far_x, cy, far_x - cx, 0.0f, plot::input::Button::Left, 0u, false, false, false}); PLOT_CHECK(fx.plot.crosshair().active); fx.controller.set_viewport({0.0f, 0.0f, 800.0f, 120.0f}); // the pointer is now below it PLOT_CHECK(!fx.plot.layout().axes.contains(far_x, cy)); // the premise: the pointer is out PLOT_CHECK(!fx.plot.crosshair().active); // Restoring the rect puts the pointer back over the data, and the crosshair with it. fx.controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); PLOT_CHECK(fx.plot.layout().axes.contains(far_x, cy)); PLOT_CHECK(fx.plot.crosshair().active); PLOT_CHECK_NEAR(fx.plot.crosshair().x, fx.plot.x_transform(0u).to_data_x(far_x), 1e-9); } // --- hardening wave: B5 zero-notch wheel and dx pan, and the new gestures --- namespace { /// A plot with an x and a y axis, both auto-scaled, so every "did the gesture /// disarm the fit" question has an armed state to start from. struct ArmedFixture { plot::Plot plot; u32 ax = 0; u32 ay = 0; plot::input::Controller controller{plot}; ArmedFixture() { plot.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); ax = plot.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false}); ay = plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false}); controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); } }; } // namespace /// B5: a zero-notch wheel (`dy == 0 && dx == 0`, which hosts and trackpads really /// forward) changes nothing at all -- most importantly it does not run the range /// setters, which clear `auto_scale`, so one empty scroll must not disarm /// auto-fit. PLOT_TEST(zero_notch_wheel_disarms_nothing) { ArmedFixture f; f.controller.on_wheel({400.0f, 200.0f, 0.0f, 0.0f, 0}); PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9); } /// B5: `dy == 0` with `dx != 0` pans x only -- 5% of the x span per notch, in axis /// space (log-aware), positive `dx` scrolling right -- and leaves the y axis, /// including its armed auto scale, exactly as it was. PLOT_TEST(horizontal_wheel_pans_x_only) { ArmedFixture f; f.controller.on_wheel({400.0f, 200.0f, 2.0f, 0.0f, 0}); // two notches right PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9); // 2 * 5% of 100 PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9); PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale); // a pan is a user choice PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched axis keeps its fit PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9); // Log-aware: the pan runs in axis space, so one notch on a log axis is a // fraction of a *decade*, not of a linear width. plot::Plot p; p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, true, plot::AxisPlace::Bottom, true}); plot::input::Controller c(p); c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); c.on_wheel({400.0f, 200.0f, 1.0f, 0.0f, 0}); const plot::Range r = p.x_range(lg); PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9); // decades kept PLOT_CHECK_NEAR(std::log10(r.max), 2.1, 1e-9); // one notch = 5% of the 2-decade span } /// B5: a zoom whose factor could not be applied honestly -- non-finite (a huge /// downward `dy` explodes `exp`) -- is refused, and leaves the ranges *and* the /// armed auto scale exactly as they were. PLOT_TEST(wheel_zoom_refuses_a_non_finite_factor) { ArmedFixture f; f.controller.on_wheel({400.0f, 200.0f, 0.0f, -1e38f, 0}); PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9); } /// B5: the middle-drag axis zoom carries the same guards. A motion that reports no /// `dy` is a factor of exactly one -- refused, so it cannot clear `auto_scale` on /// its way through the range setters -- and one that explodes the factor is /// refused too. PLOT_TEST(axis_zoom_refuses_degenerate_factors) { ArmedFixture f; 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); // A stationary motion: no zoom, and the fits stay armed. f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, false, false}); PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9); // An exploding motion: refused, nothing stored, nothing disarmed. f.controller.on_pointer({strip_x, strip_y, 0, 1e38f, plot::input::Button::Middle, 0, true, false, false}); PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9); f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, false, false, true}); } /// The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y /// axis, 10% of that axis' own span per press, in axis space (log-aware). An arrow /// says where to look: Left walks the window toward smaller x, Up toward larger y. PLOT_TEST(arrow_keys_pan_a_tenth_of_the_span) { ArmedFixture f; f.plot.set_x_auto_scale(f.ax, false); f.controller.on_key({plot::input::kKeyRight, 0, true}); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 10.0, 1e-9); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 110.0, 1e-9); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // x-only f.controller.on_key({plot::input::kKeyLeft, 0, true}); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9); f.controller.on_key({plot::input::kKeyUp, 0, true}); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -0.8, 1e-9); // +10% of the 2 span PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.2, 1e-9); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // y-only f.controller.on_key({plot::input::kKeyDown, 0, true}); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9); // Log-aware: an arrow on a log axis moves decades, not units. plot::Plot p; p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); const u32 lg = p.add_x_axis({"f", {1.0, 100.0}, false, plot::AxisPlace::Bottom, true}); p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); plot::input::Controller c(p); c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); c.on_key({plot::input::kKeyRight, 0, true}); const plot::Range r = p.x_range(lg); PLOT_CHECK_NEAR(std::log10(r.max) - std::log10(r.min), 2.0, 1e-9); PLOT_CHECK_NEAR(std::log10(r.max), 2.2, 1e-9); // +10% of the 2-decade span // A key with no gesture changes nothing. c.on_key({'q', 0, true}); PLOT_CHECK_NEAR(std::log10(p.x_range(lg).max), 2.2, 1e-9); } /// Escape cancels the active drag: the ranges (and auto-scale flags) the axes held /// at `begin_drag` are restored, and the drag is closed -- the release that /// follows applies nothing. PLOT_TEST(escape_cancels_a_pan_and_restores_the_captured_ranges) { ArmedFixture f; const f64 x_min = f.plot.x_range(f.ax).min; const f64 x_max = f.plot.x_range(f.ax).max; const f64 y_min = f.plot.y_range(f.ay).min; const f64 y_max = f.plot.y_range(f.ay).max; f.controller.on_pointer({100.0f, 100.0f, 0, 0, plot::input::Button::Left, 0, true, true, false}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan); f.controller.on_pointer({300.0f, 200.0f, 200.0f, 100.0f, plot::input::Button::Left, 0, true, false, false}); PLOT_CHECK(f.plot.x_range(f.ax).min < x_min); // the gesture did move things PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale); f.controller.on_key({plot::input::kKeyEscape, 0, true}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, y_min, 1e-9); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, y_max, 1e-9); PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // the fit is armed again PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // The release arrives after the cancel; there is no gesture left to end. f.controller.on_pointer({300.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, x_min, 1e-9); } /// Escape drops a box zoom without applying it: the selection is discarded and the /// ranges the axes held when the drag began are still there at the end. PLOT_TEST(escape_drops_a_box_zoom_without_applying) { ArmedFixture f; const f64 x_max = f.plot.x_range(f.ax).max; f.controller.on_pointer({100.0f, 50.0f, 0, 0, plot::input::Button::Left, kShift, true, true, false}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom); f.controller.on_pointer({300.0f, 150.0f, 200.0f, 100.0f, plot::input::Button::Left, kShift, true, false, false}); PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); // nothing applies before the release f.controller.on_key({plot::input::kKeyEscape, 0, true}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::None); PLOT_CHECK_NEAR(f.controller.selection_rect().w, 0.0, 1e-9); // the box is gone f.controller.on_pointer({300.0f, 150.0f, 0, 0, plot::input::Button::Left, kShift, false, false, true}); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, x_max, 1e-9); // and nothing was framed PLOT_CHECK(f.plot.x_axis(f.ax).auto_scale); } /// The box-zoom masks: Alt+Left frames the y range only -- from a selection too /// thin horizontally to be a two-axis box, which is what makes the threshold /// masked with the gesture. PLOT_TEST(alt_drag_box_zooms_y_only) { ArmedFixture f; constexpr u32 kAlt = static_cast(plot::input::Mod::Alt); const plot::Rect axes = f.plot.layout().axes; const f64 want_lo = f.plot.y_transform(f.ay).to_data_y(axes.y + 150.0f); const f64 want_hi = f.plot.y_transform(f.ay).to_data_y(axes.y + 50.0f); // Two logical px wide: an unmasked box would call this a click. f.controller.on_pointer({200.0f, axes.y + 50.0f, 0, 0, plot::input::Button::Left, kAlt, true, true, false}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom); f.controller.on_pointer({202.0f, axes.y + 150.0f, 2, 100, plot::input::Button::Left, kAlt, true, false, false}); f.controller.on_pointer({202.0f, axes.y + 150.0f, 0, 0, plot::input::Button::Left, kAlt, false, false, true}); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, want_lo, 1e-6); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, want_hi, 1e-6); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, 0.0, 1e-9); // x untouched PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, 100.0, 1e-9); } /// The Ctrl mask, applied at release from a selection that only makes sense /// horizontally: the x range is framed from the box while the y range is left /// alone, and a selection thin *vertically* is still a box under the mask. PLOT_TEST(ctrl_mask_frames_x_from_a_thin_horizontal_band) { ArmedFixture f; const plot::Rect axes = f.plot.layout().axes; const f64 want_lo = f.plot.x_transform(f.ax).to_data_x(axes.x + 100.0f); const f64 want_hi = f.plot.x_transform(f.ax).to_data_x(axes.x + 300.0f); f.controller.on_pointer({axes.x + 100.0f, 200.0f, 0, 0, plot::input::Button::Left, kCtrl, true, true, false}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::BoxZoom); // Two logical px tall: an unmasked box would call this a click. f.controller.on_pointer({axes.x + 300.0f, 202.0f, 200, 2, plot::input::Button::Left, kCtrl, true, false, false}); f.controller.on_pointer({axes.x + 300.0f, 202.0f, 0, 0, plot::input::Button::Left, kCtrl, false, false, true}); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).min, want_lo, 1e-6); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).max, want_hi, 1e-6); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).min, -1.0, 1e-9); // y untouched PLOT_CHECK_NEAR(f.plot.y_range(f.ay).max, 1.0, 1e-9); } /// The double-click reset: two presses of the same button within 0.35 s and 4 px /// (on `PointerEvent::time_s`) re-fit every axis -- auto scale re-armed with the /// fit -- and the second press begins no drag. PLOT_TEST(double_click_refits_every_axis_and_starts_no_drag) { // A plot that has something to fit: the reset re-fits every axis to its traces, // so this fixture carries one (the ArmedFixture above is deliberately empty -- // its tests are about auto-scale *state*, and an axis with no trace has nothing // to fit). The axes start disarmed so the initial layout cannot pre-fit them. plot::Plot p; p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); const u32 ax = p.add_x_axis({"t", {5.0, 60.0}, false, plot::AxisPlace::Bottom, false}); const u32 ay = p.add_y_axis({"v", {-0.5, 0.5}, false, plot::AxisPlace::Left, false}); std::vector ys(101); for (u64 i = 0; i <= 100u; ++i) ys[static_cast(i)] = (i % 2u == 0u) ? -1.0f : 1.0f; plot::data::ArraySeries series(std::move(ys), 0.0, 1.0); // x = 0..100, y in [-1, 1] p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay}); plot::input::Controller c(p); c.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); // Zoom in first, so the window is somewhere a reset has to leave. c.on_wheel({400.0f, 200.0f, 0.0f, 1.0f, 0}); PLOT_CHECK(p.x_range(ax).span() < 60.0); PLOT_CHECK(p.y_range(ay).span() < 1.0); c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, /*time_s=*/1.0}); c.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.0}); c.on_pointer({402.0f, 201.0f, 2, 1, plot::input::Button::Left, 0, true, true, false, 1.2}); // 0.2 s and ~2.2 px later PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); // no drag began PLOT_CHECK(p.x_axis(ax).auto_scale); // the reset fit and re-armed PLOT_CHECK(p.y_axis(ay).auto_scale); // Both axes frame their traces' bounds (the 2% pad keeps the data inside them), // which is what a fit means here. PLOT_CHECK(p.x_range(ax).min < 0.0); PLOT_CHECK(p.x_range(ax).max > 100.0); PLOT_CHECK(p.y_range(ay).min < -1.0); PLOT_CHECK(p.y_range(ay).max > 1.0); // And the re-armed fit survives a layout: the frame the host draws next does not // move the window the reset just chose. const plot::Range xr = p.x_range(ax); p.layout(); PLOT_CHECK_NEAR(p.x_range(ax).min, xr.min, 1e-9); PLOT_CHECK_NEAR(p.x_range(ax).max, xr.max, 1e-9); // A third press starts a fresh gesture (a pan), not a second reset. c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 1.4}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan); c.on_pointer({402.0f, 201.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 1.4}); } /// A double-click needs the host's clock: presses that carry `time_s == 0` are /// never one, whatever their spacing, and presses outside the window (too slow or /// too far apart) are two ordinary gestures -- the second one a drag. PLOT_TEST(double_click_needs_both_timestamps_and_the_window) { ArmedFixture f; f.plot.set_x_auto_scale(f.ax, false); // No timestamps: two presses at the same spot do not reset. f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 0.0}); f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 0.0}); f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 0.0}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan); f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 0.0}); // Too slow: 0.4 s apart is two gestures. f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 10.0}); f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 10.0}); f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 10.4}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan); f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 10.4}); // Too far: 10 px apart is two gestures. f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, true, true, false, 20.0}); f.controller.on_pointer({400.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 20.0}); f.controller.on_pointer({410.0f, 200.0f, 10, 0, plot::input::Button::Left, 0, true, true, false, 20.1}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::Pan); f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false, true, 20.1}); } // A readout cursor is drawn but not offered to the pointer: the host that keeps it is showing // a computed value, and a drag on it would be a drag on a number rather than on an instrument. // The hit test is where that is enforced -- re-placing the marker every frame would only fight // the drag, which is the shape this replaces. PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) { 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 readout = p.add_cursor(50.0, plot::Color::hex(0x00FF00)); p.set_cursor_selectable(readout, false); PLOT_CHECK(p.cursor(readout).visible); // drawn all the same PLOT_CHECK(!p.cursor(readout).selectable); // A normal cursor right beside it stays grabbable, so the flag is a property of one cursor // and not a switch on the plot. const u32 normal = 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() == normal); // the readout is not in the running // Press and move: the pointer grabbed the *normal* cursor (the two are at the same x, so // the readout would otherwise be the nearer hit), and the readout's own x never moves. const f64 before = p.cursor(readout).x; c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, true, true, false}); PLOT_CHECK(c.grabbed_cursor() == normal); c.on_pointer({sx + 40, 200, 40, 0, plot::input::Button::Left, 0, true, false, false}); PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12); PLOT_CHECK(c.grabbed_cursor() == normal); } PLOT_TEST(a_ctrl_box_zoom_previews_the_band_it_will_zoom) { // What a host draws for an open box zoom is not always the area the pointer swept. Ctrl frames the // x range only, so what its release will change is a *band* across the plot -- and a pointer dragged // straight across sweeps a sliver a couple of pixels tall, which would be the frame showing the // gesture instead of its effect. Shift frames both axes and gets the swept box itself; Alt is the // other single-axis mask, and gets the other frame. plot::Plot p; p.set_viewport({0, 0, 800, 400}); p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false}); const plot::Rect axes = p.layout().axes; constexpr plot::u32 kAlt = static_cast(plot::input::Mod::Alt); plot::input::Controller c(p); c.set_viewport({0, 0, 800, 400}); c.on_pointer({100, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false}); // Nothing framed until something was swept: the press alone is an empty rect at the pointer, and a // full-width band derived from it would claim a range that does not exist yet. PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6); c.on_pointer({300, 202, 200, 2, plot::input::Button::Left, kCtrl, true, false, false}); const plot::Rect ctrl = c.selection_preview(); PLOT_CHECK_NEAR(ctrl.x, 100.0, 1e-6); PLOT_CHECK_NEAR(ctrl.w, 200.0, 1e-6); PLOT_CHECK_NEAR(ctrl.y, axes.y, 1e-6); PLOT_CHECK_NEAR(ctrl.h, axes.h, 1e-6); // The gesture's own geometry is untouched: the widening happens in the preview, so the range the // release computes is still the one the pointer described. PLOT_CHECK_NEAR(c.selection_rect().h, 2.0, 1e-6); c.on_pointer({300, 202, 0, 0, plot::input::Button::Left, kCtrl, false, false, true}); c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false}); c.on_pointer({400, 150, 300, 100, plot::input::Button::Left, kShift, true, false, false}); const plot::Rect both = c.selection_preview(); PLOT_CHECK_NEAR(both.x, 100.0, 1e-6); PLOT_CHECK_NEAR(both.y, 50.0, 1e-6); PLOT_CHECK_NEAR(both.w, 300.0, 1e-6); PLOT_CHECK_NEAR(both.h, 100.0, 1e-6); c.on_pointer({400, 150, 0, 0, plot::input::Button::Left, kShift, false, false, true}); c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kAlt, true, true, false}); c.on_pointer({110, 150, 10, 100, plot::input::Button::Left, kAlt, true, false, false}); const plot::Rect alt = c.selection_preview(); PLOT_CHECK_NEAR(alt.x, axes.x, 1e-6); PLOT_CHECK_NEAR(alt.w, axes.w, 1e-6); PLOT_CHECK_NEAR(alt.y, 50.0, 1e-6); PLOT_CHECK_NEAR(alt.h, 100.0, 1e-6); c.on_pointer({110, 150, 0, 0, plot::input::Button::Left, kAlt, false, false, true}); // Closed: a frame on a finished gesture would claim the old selection is still open. PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6); }