#include "harness.hpp" #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_moves_the_nearest_cursor) { plot::Plot p; p.set_viewport({0, 0, 800, 400}); const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false}); const u32 cid = p.add_cursor(20.0, plot::Color::hex(0xFF0000)); plot::input::Controller c(p); c.set_viewport({0, 0, 800, 400}); const f32 sx = screen_x_of(p, ax, 20.0); c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor); c.on_pointer({sx + 100, 200, 100, 0, plot::input::Button::Left, kCtrl, true, false, false}); c.on_pointer({sx + 100, 200, 0, 0, plot::input::Button::Left, kCtrl, false, false, true}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); PLOT_CHECK(p.cursor(cid).x > 20.0); PLOT_CHECK_NEAR(p.cursor(cid).x, p.x_transform(ax).to_data_x(sx + 100), 1e-9); } PLOT_TEST(middle_drag_on_x_strip_zooms_x_only) { Fixture f; f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false}); const plot::PlotLayout& l = f.plot.layout(); const f32 strip_x = l.axes.x + 0.5f * l.axes.w; const f32 strip_y = l.x_label_area.y + 0.5f * l.x_label_area.h; f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomX); // Same sign convention as the wheel: a positive dy zooms in. f.controller.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false}); f.controller.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true}); // 20 px of drag is a tenth of a decade: the span is 10^(-20/200) of its // former self. A pixel is not a wheel notch, so this pins the magnitude. PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0, 1e-9); PLOT_CHECK(!f.plot.x_axis(f.ax).auto_scale); PLOT_CHECK(f.plot.y_axis(f.ay).auto_scale); // the untouched y axis keeps auto scale } PLOT_TEST(middle_drag_on_y_strip_zooms_y_only) { Fixture f; f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); const plot::PlotLayout& l = f.plot.layout(); const f32 strip_x = l.y_label_area.x + 0.5f * l.y_label_area.w; const f32 strip_y = l.axes.y + 0.5f * l.axes.h; f.controller.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false}); PLOT_CHECK(f.controller.drag_mode() == plot::input::DragMode::AxisZoomY); f.controller.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false}); f.controller.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true}); PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), 100.0, 1e-9); PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), 2.0 * std::pow(10.0, -20.0 / 200.0), 1e-6); } PLOT_TEST(key_plus_zooms_in_about_the_centre) { Fixture f; const f64 mid_before = 0.5 * (f.plot.x_range(f.ax).min + f.plot.x_range(f.ax).max); f.controller.on_key({plot::input::kKeyZoomIn, 0, true}); PLOT_CHECK(f.plot.x_range(f.ax).span() < 100.0); PLOT_CHECK_NEAR(0.5 * (f.plot.x_range(f.ax).min + f.plot.x_range(f.ax).max), mid_before, 1e-9); const f64 span_after = f.plot.x_range(f.ax).span(); f.controller.on_key({plot::input::kKeyZoomIn, 0, false}); // key-up is ignored PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), span_after, 1e-12); } PLOT_TEST(key_minus_zooms_out) { Fixture f; f.controller.on_key({plot::input::kKeyZoomOut, 0, true}); PLOT_CHECK(f.plot.x_range(f.ax).span() > 100.0); } /// A host can hand the controller a coordinate that is not a number: ImGui reports /// (-FLT_MAX, -FLT_MAX) for "no mouse position", and any host can produce a NaN out /// of a degenerate mapping of its own. The pan arithmetic runs through the axis /// transforms, so such a value would leave a range that is not a range -- and /// because a gesture that sets a range also clears auto scale, panning and zooming /// could not bring it back. PLOT_TEST(non_finite_pointer_does_not_move_a_range) { plot::Plot p; p.set_viewport({0, 0, 800, 400}); const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, true, plot::AxisPlace::Bottom, false}); const u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false}); plot::input::Controller c(p); c.set_viewport({0, 0, 800, 400}); const plot::Range xr = p.x_range(ax); const plot::Range yr = p.y_range(ay); c.on_pointer({100, 100, 0, 0, plot::input::Button::Left, 0, true, true, false}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::Pan); const float inf = std::numeric_limits::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 5 px radius) from the cursor at both scales, /// so the same press grabs -- and the same press just outside does not. PLOT_TEST(grab_radius_scales_with_dpi) { for (const f32 scale : {1.0f, 2.0f}) { plot::Plot p; p.set_ui_scale(scale); p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale}); const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false}); const u32 cid = p.add_cursor(50.0, plot::Color::hex(0xFF0000)); plot::input::Controller c(p); c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale}); PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6); const f32 sx = screen_x_of(p, ax, 50.0); const f32 inside = 0.9f * 5.0f * scale; // logical: within the grab radius const f32 outside = 1.1f * 5.0f * scale; // logical: just past it c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false, false}); PLOT_CHECK(c.hovered_cursor() == cid); c.on_pointer({sx + outside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false, false}); PLOT_CHECK(c.hovered_cursor() == plot::kInvalid); // ...and a ctrl-drag at that same logical distance opens a cursor move, which is // the gesture the radius actually gates. c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, kCtrl, true, true, false}); PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor); } } /// The box-zoom click threshold is a hand movement too: a selection the user /// perceives as a few pixels is a click at any DPI, so both dimensions are compared /// against `logical * Plot::ui_scale()`. PLOT_TEST(box_zoom_click_threshold_scales_with_dpi) { for (const f32 scale : {1.0f, 2.0f}) { plot::Plot p; p.set_ui_scale(scale); p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale}); const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false}); plot::input::Controller c(p); c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale}); const plot::Range before = p.x_range(ax); // 3 logical px on each side: a click, at both scales. const f32 x0 = 100.0f * scale; const f32 y0 = 50.0f * scale; const f32 small = 3.0f * scale; c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false}); c.on_pointer({x0 + small, y0 + small, small, small, plot::input::Button::Left, kShift, true, false, false}); c.on_pointer({x0 + small, y0 + small, 0, 0, plot::input::Button::Left, kShift, false, false, true}); PLOT_CHECK_NEAR(p.x_range(ax).min, before.min, 1e-9); PLOT_CHECK_NEAR(p.x_range(ax).max, before.max, 1e-9); // 5 logical px on each side: a box, at both scales. const f32 large = 5.0f * scale; c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false}); c.on_pointer({x0 + large, y0 + large, large, large, plot::input::Button::Left, kShift, true, false, false}); c.on_pointer({x0 + large, y0 + large, 0, 0, plot::input::Button::Left, kShift, false, false, true}); PLOT_CHECK(p.x_range(ax).min > before.min); PLOT_CHECK(p.x_range(ax).max < before.max); } } // --------------------------------------------- live x follow (spec §12) // // The gestures on an axis that is following live data. Every path below already // existed; what is new is that none of them disarms following, and that the ranges // they set are read by `Plot::set_x_range` as a span and an offset behind the live // edge rather than as absolute positions. namespace { /// A streaming plot under the controller: one `StreamSeries` on one x axis, 800x400, /// with `push` samples already produced and acquired. The axis starts 10 units wide; /// a case arms following when it needs it, so "Off is the default" is the state every /// case here starts from. struct LiveFixture { plot::data::StreamSeries series; plot::Plot graph; plot::input::Controller controller{graph}; u32 ax = 0; u32 ay = 0; explicit LiveFixture(u64 retain, u64 push) : series(retain, 0.0, 1.0) { graph.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); ax = graph.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false}); ay = graph.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); graph.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay}); feed(push); controller.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); } void feed(u64 n) { for (u64 i = 0; i < n; ++i) series.push(0.0f); series.acquire(); } void live() { graph.set_x_follow(ax, plot::XFollow::Live); } f64 newest() const { return series.x_bounds().max; } plot::Range xr() const { return graph.x_range(ax); } }; /// The centre of the x label strip: outside `axes`, which is where a zoom's anchor /// falls back to the live edge, and where the middle-drag x zoom is aimed anyway. plot::input::WheelEvent strip_wheel(const plot::Plot& p, f32 notches) { const plot::Rect strip = std::as_const(p).layout().x_label_area; return {strip.x + 0.5f * strip.w, strip.y + 0.5f * strip.h, 0.0f, notches, 0}; } /// One left drag of `dx` device pixels across the axes, through the controller. void drag(plot::input::Controller& c, const plot::Rect& axes, f32 dx) { const f32 x = axes.x + 0.5f * axes.w; const f32 y = axes.y + 0.5f * axes.h; c.on_pointer({x, y, 0.0f, 0.0f, plot::input::Button::Left, 0, true, true, false}); c.on_pointer({x + dx, y, dx, 0.0f, plot::input::Button::Left, 0, true, false, false}); c.on_pointer({x + dx, y, 0.0f, 0.0f, plot::input::Button::Left, 0, false, false, true}); } } // namespace /// The regression §12 exists for. Today a wheel zoom clears the x axis' auto scale, /// and a live plot then stops following the newest sample for good: the window stays /// where the zoom left it while the stream walks out of it. Under `Live` the span /// shrinks, following stays armed, and the window keeps ending where it ended -- /// which is asserted by *both* halves below, because the edge is the newest sample /// only for a zoom anchored on it. PLOT_TEST(wheel_zoom_keeps_following) { LiveFixture f(200, 40); f.graph.set_x_auto_scale(f.ax, true); // the only way to follow before this feature f.graph.layout(); f.live(); const f64 span_before = f.xr().span(); const plot::Rect axes = std::as_const(f.graph).layout().axes; // A notch from outside `axes` anchors on the live edge, so the window still ends // at the newest sample afterwards -- and goes on doing so as data arrives. f.controller.on_wheel(strip_wheel(f.graph, 1.0f)); PLOT_CHECK(f.xr().span() < span_before); PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9); PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); f.feed(20); f.graph.layout(); PLOT_CHECK_NEAR(f.xr().max, f.newest(), 1e-9); const f64 zoomed = f.xr().span(); // A notch from inside `axes` anchors on the data under the pointer, which pushes // the window back from the edge by what the zoom chose -- and it still follows: // that distance is constant as the stream advances, so the newest sample is the // same distance off the right edge on every frame instead of drifting away. f.controller.on_wheel({axes.x + 0.5f * axes.w, axes.y + 0.5f * axes.h, 0.0f, 1.0f, 0}); PLOT_CHECK_NEAR(f.xr().span(), zoomed * std::exp(-0.15), 1e-9); PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); const f64 behind = f.newest() - f.xr().max; PLOT_CHECK(behind > 0.0); f.feed(25); f.graph.layout(); PLOT_CHECK_NEAR(f.newest() - f.xr().max, behind, 1e-9); PLOT_CHECK(f.graph.x_follow(f.ax) == plot::XFollow::Live); } /// Two half-notches are one full notch, because the wheel *multiplies* the span: an /// additive step would give `2 * (span - d/2) != span - d`. The two fixtures have /// identical state, so the comparison is exact -- and the pinned value says which /// arithmetic produced it. PLOT_TEST(wheel_zoom_is_relative) { LiveFixture a(200, 40); LiveFixture b(200, 40); a.live(); b.live(); PLOT_CHECK_NEAR(a.xr().span(), b.xr().span(), 1e-12); // same state to start from a.controller.on_wheel(strip_wheel(a.graph, 0.5f)); a.controller.on_wheel(strip_wheel(a.graph, 0.5f)); b.controller.on_wheel(strip_wheel(b.graph, 1.0f)); PLOT_CHECK_NEAR(a.xr().span(), b.xr().span(), 1e-9); PLOT_CHECK_NEAR(b.xr().span(), 10.0 * std::exp(-0.15), 1e-9); PLOT_CHECK(a.xr().span() < 10.0); // Each window still ends at its own live edge, so the relative zoom kept following. PLOT_CHECK_NEAR(a.xr().max, a.newest(), 1e-9); PLOT_CHECK_NEAR(b.xr().max, b.newest(), 1e-9); PLOT_CHECK(a.graph.x_follow(a.ax) == plot::XFollow::Live); PLOT_CHECK(b.graph.x_follow(b.ax) == plot::XFollow::Live); } /// Panning a live plot moves the window, not the scale: the offset grows by the /// drag's data delta, the span is untouched, and following is still armed. Dragging /// right reveals earlier data, which for a live window means moving back from the /// edge -- and the window then keeps that distance as the stream advances. PLOT_TEST(mouse_pan_shifts_the_offset_and_preserves_the_span) { LiveFixture f(200, 40); f.live(); const f64 span = f.xr().span(); const plot::Rect axes = std::as_const(f.graph).layout().axes; const f32 dx = 60.0f; drag(f.controller, axes, dx); const f64 want = static_cast(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); } }