From 5ab854104a75fbe868122e36666cce96c3e2e34e Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 27 Sep 2026 22:34:16 +0200 Subject: [PATCH] feat(input): host-agnostic events and pan/zoom/cursor controller --- CMakeLists.txt | 1 + include/plot/input/controller.hpp | 101 ++++++++++ include/plot/input/events.hpp | 65 +++++++ src/input/controller.cpp | 313 ++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_controller.cpp | 237 ++++++++++++++++++++++ 6 files changed, 718 insertions(+) create mode 100644 include/plot/input/controller.hpp create mode 100644 include/plot/input/events.hpp create mode 100644 src/input/controller.cpp create mode 100644 tests/test_controller.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 375a982..948f7b0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(plot_core src/plot/grid.cpp src/plot/measure.cpp src/plot/plot.cpp + src/input/controller.cpp ) add_library(plot::core ALIAS plot_core) diff --git a/include/plot/input/controller.hpp b/include/plot/input/controller.hpp new file mode 100644 index 0000000..adb887a --- /dev/null +++ b/include/plot/input/controller.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include + +namespace plot { +class Plot; +} + +namespace plot::input { + +/// What a drag in progress is doing. +enum class DragMode { + None, + Pan, ///< translate every range (left or right drag) + BoxZoom, ///< a shift+left selection is open; the ranges change on release + MoveCursor, ///< ctrl+left drag is moving `hovered_cursor()` + AxisZoomX, ///< middle drag inside the x label strip; zooms x only + AxisZoomY, ///< middle drag inside the y label strip; zooms y only +}; + +/// Turns host events into plot gestures, knowing nothing about Qt, GTK, GLFW or +/// ImGui -- Tasks 8-11 translate their native events into the value types above. +/// +/// It is pure CPU and needs no GL context and no font: everything it reads comes +/// from `Plot`'s layout, ranges and transforms. The controller owns no viewport +/// of its own: set_viewport() forwards to `Plot::set_viewport` (and refreshes the +/// layout) so the two can never disagree, and every gesture runs through +/// `Plot`'s range setters, which clear `auto_scale` in one place. +/// +/// Gesture table (grab-and-drag: the data follows the cursor): +/// | gesture | mode | effect | +/// |---|---|---| +/// | left drag | `Pan` | translate both ranges | +/// | right drag | `Pan` | same, for hosts that reserve left | +/// | shift + left drag | `BoxZoom` | both ranges from the selection rect | +/// | ctrl + left drag near a cursor | `MoveCursor` | move that cursor | +/// | middle drag on the x label strip | `AxisZoomX` | x-only zoom | +/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom | +/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer | +/// | `+` / `-` | -- | zoom about the plot centre | +/// | `f` | -- | auto-fit every axis | +/// +/// Both axis-zoom drags read the vertical `dy` with the wheel's sign convention +/// (dragging down zooms in), anchored at the pointer's data coordinate on the +/// axis they zoom. +/// +/// A drag that changes no range is harmless: a box-zoom selection under 4 px on +/// either side is a click, and leaves the ranges alone. +class Controller { + public: + explicit Controller(Plot& plot) noexcept : plot_(&plot) {} + + /// Sets the plot's viewport (the controller keeps none of its own) and + /// refreshes the layout, so gesture geometry and the host agree. + void set_viewport(Rect px); + + void on_pointer(const PointerEvent& e); + void on_wheel(const WheelEvent& e); + void on_key(const KeyEvent& e); + + DragMode drag_mode() const noexcept { return mode_; } + /// The current box-zoom selection in pixels; meaningful while BoxZoom. + Rect selection_rect() const noexcept { return selection_; } + /// The cursor under the pointer, or `kInvalid` when the pointer is outside the + /// plot area or no cursor is within a few pixels. + u64 hovered_cursor() const; + + private: + void begin_drag(const PointerEvent& e); + void update_drag(const PointerEvent& e); + void end_drag(); + + void pan(const PointerEvent& e); + void box_zoom_update(const PointerEvent& e); + void apply_box_zoom(); + void move_cursor(const PointerEvent& e); + void axis_zoom(const PointerEvent& e); + + /// The nearest visible cursor within the hit radius of (px, py), or kInvalid. + /// kInvalid when (px, py) is outside the plot axes. + u64 cursor_at(f32 px, f32 py) const; + + /// Zoom one axis about `anchor` (a data coordinate) by `factor`, recomputing + /// the range so the anchor keeps its screen position. Leaves `auto_scale` false. + void zoom_x_axis(u32 axis, f64 anchor, f64 factor); + void zoom_y_axis(u32 axis, f64 anchor, f64 factor); + + Plot* plot_ = nullptr; + DragMode mode_ = DragMode::None; + Rect selection_{}; + f32 origin_x_ = 0.0f; + f32 origin_y_ = 0.0f; + u64 drag_cursor_ = kInvalid; + bool has_pointer_ = false; + f32 pointer_x_ = 0.0f; + f32 pointer_y_ = 0.0f; +}; + +} // namespace plot::input diff --git a/include/plot/input/events.hpp b/include/plot/input/events.hpp new file mode 100644 index 0000000..f5ea31c --- /dev/null +++ b/include/plot/input/events.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include + +namespace plot::input { + +/// Mouse buttons a host can report. The controller never assumes a fixed +/// mapping: right-drag pans as well as left, for hosts that reserve left-drag. +enum class Button { Left, Middle, Right }; + +/// Keyboard modifier bits for `PointerEvent::mods` / `WheelEvent::mods`. +enum class Mod { None = 0, Shift = 1, Ctrl = 2, Alt = 4, Super = 8 }; + +/// True when `mods` holds `m`. The single place modifier bits are tested, so a +/// call site never hand-rolls a mask. +constexpr bool has_mod(u32 mods, Mod m) noexcept { + return (mods & static_cast(m)) != 0u; +} + +/// A pointer event in viewport pixels (origin top-left, the same space as +/// `Rect`). `dx`/`dy` are the movement since the previous event; the controller +/// reads them for panning and the absolute `x`/`y` for anchoring and hit tests. +/// `pressed` and `released` are the transitions; `down` is the state after the +/// event. A plain move has all three false/true as appropriate (down only). +struct PointerEvent { + f32 x = 0.0f; + f32 y = 0.0f; + f32 dx = 0.0f; + f32 dy = 0.0f; + Button button = Button::Left; + u32 mods = 0; + bool down = false; ///< the button is held after this event + bool pressed = false; ///< a press transition happened on this event + bool released = false; ///< a release transition happened on this event +}; + +/// A wheel/scroll event at a pixel position. A positive `dy` (scroll up) zooms +/// in; a positive `dx` scrolls right. Only `dy` drives the controller's zoom. +struct WheelEvent { + f32 x = 0.0f; + f32 y = 0.0f; + f32 dx = 0.0f; + f32 dy = 0.0f; + u32 mods = 0; +}; + +/// A key event. `key` is an ASCII codepoint for printable keys; hosts that only +/// have scancodes translate them before calling. `down` distinguishes the press +/// from the release -- the controller's actions are press-only. +struct KeyEvent { + int key = 0; + u32 mods = 0; + bool down = false; +}; + +/// The printable keys the controller reacts to, named so no call site spells a +/// bare character literal. `kKeyZoomIn`/`kKeyZoomOut` zoom about the plot centre, +/// `kKeyFit` re-fits every axis to its traces. +inline constexpr int kKeyZoomIn = '+'; +inline constexpr int kKeyZoomOut = '-'; +inline constexpr int kKeyFit = 'f'; +/// Hosts that report shifted keys uppercase can pass this unchanged. +inline constexpr int kKeyFitUpper = 'F'; + +} // namespace plot::input diff --git a/src/input/controller.cpp b/src/input/controller.cpp new file mode 100644 index 0000000..7166088 --- /dev/null +++ b/src/input/controller.cpp @@ -0,0 +1,313 @@ +#include + +#include +#include + +#include +#include +#include + +namespace plot::input { +namespace { + +/// Zoom step for one wheel notch (dy == 1). A notch changes the span by +/// `exp(-0.15)` (~14%), a step that feels like one detent on a normal mouse. +constexpr f64 kZoomPerNotch = 0.15; +/// A keyboard zoom press is one wheel notch. +constexpr f64 kKeyZoomNotches = 1.0; +/// How close (in pixels) the pointer must be to a cursor to grab it. +constexpr f32 kCursorHitRadiusPx = 5.0f; +/// A box-zoom selection narrower than this on either side is a click. +constexpr f32 kMinBoxPixels = 4.0f; + +/// An axis endpoint in transform space: log10 when the axis is logarithmic, the +/// value itself otherwise. Pan and zoom arithmetic runs in this space so a log +/// axis translates and scales multiplicatively, exactly as its transform maps. +f64 to_space(f64 v, bool log) noexcept { + return log ? Transform::log10_positive(v) : v; +} +f64 from_space(f64 v, bool log) noexcept { + return log ? std::pow(10.0, v) : v; +} + +/// Zoom factor for a number of wheel notches: positive notches (scroll up, `+` +/// key) zoom in, so the span shrinks by `exp(-notches * kZoomPerNotch)`. +f64 zoom_factor(f64 notches) noexcept { return std::exp(-notches * kZoomPerNotch); } + +} // namespace + +void Controller::set_viewport(Rect px) { + // The controller owns no viewport: it forwards the caller's rect to the plot + // (so the two can never disagree) and refreshes the layout the hit tests and + // transforms read. + plot_->set_viewport(px); + plot_->layout(); +} + +void Controller::on_pointer(const PointerEvent& e) { + has_pointer_ = true; + pointer_x_ = e.x; + pointer_y_ = e.y; + plot_->layout(); // keep the chrome geometry the gestures map through current + + if (e.pressed && e.down) { + begin_drag(e); + return; + } + if (e.released) { + end_drag(); + return; + } + if (e.down && mode_ != DragMode::None) update_drag(e); +} + +void Controller::on_wheel(const WheelEvent& e) { + plot_->layout(); + const f64 factor = zoom_factor(static_cast(e.dy)); + if (!(factor > 0.0)) return; + + const bool shift = has_mod(e.mods, Mod::Shift); + const bool ctrl = has_mod(e.mods, Mod::Ctrl); + // Shift alone restricts the zoom to x, ctrl alone to y; neither (or both) + // zooms both axes. The data coordinate under the pointer stays put. + const bool zoom_x = !(ctrl && !shift); + const bool zoom_y = !(shift && !ctrl); + + if (zoom_x) { + for (u32 i = 0; i < plot_->x_axis_count(); ++i) { + zoom_x_axis(i, plot_->x_transform(i).to_data_x(static_cast(e.x)), factor); + } + } + if (zoom_y) { + for (u32 i = 0; i < plot_->y_axis_count(); ++i) { + zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast(e.y)), factor); + } + } +} + +void Controller::on_key(const KeyEvent& e) { + if (!e.down) return; // every action below is press-only + + if (e.key == kKeyZoomIn || e.key == kKeyZoomOut) { + const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches; + const f64 factor = zoom_factor(notches); + for (u32 i = 0; i < plot_->x_axis_count(); ++i) { + const Axis& a = plot_->x_axis(i); + const f64 lo = to_space(a.range.min, a.log); + const f64 hi = to_space(a.range.max, a.log); + zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor); + } + for (u32 i = 0; i < plot_->y_axis_count(); ++i) { + const Axis& a = plot_->y_axis(i); + const f64 lo = to_space(a.range.min, a.log); + const f64 hi = to_space(a.range.max, a.log); + zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor); + } + return; + } + + if (e.key == kKeyFit || e.key == kKeyFitUpper) { + plot_->auto_fit_x(); + for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i); + } +} + +u64 Controller::hovered_cursor() const { + if (!has_pointer_) return kInvalid; + return cursor_at(pointer_x_, pointer_y_); +} + +// --- drag dispatch -------------------------------------------------------- + +void Controller::begin_drag(const PointerEvent& e) { + origin_x_ = e.x; + origin_y_ = e.y; + drag_cursor_ = kInvalid; + + if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) { + mode_ = DragMode::BoxZoom; + selection_ = Rect{e.x, e.y, 0.0f, 0.0f}; + return; + } + if (e.button == Button::Left && has_mod(e.mods, Mod::Ctrl)) { + const u64 hit = cursor_at(e.x, e.y); + if (hit != kInvalid) { + mode_ = DragMode::MoveCursor; + drag_cursor_ = hit; + return; + } + // Ctrl far from any cursor falls through to pan, like an unmodified drag. + } + if (e.button == Button::Middle) { + const PlotLayout& lay = std::as_const(*plot_).layout(); + if (lay.x_label_area.contains(e.x, e.y)) { + mode_ = DragMode::AxisZoomX; + } else if (lay.y_label_area.contains(e.x, e.y)) { + mode_ = DragMode::AxisZoomY; + } else { + mode_ = DragMode::None; // a middle drag off the label strips does nothing + } + return; + } + mode_ = DragMode::Pan; // left and right drag both pan +} + +void Controller::update_drag(const PointerEvent& e) { + switch (mode_) { + case DragMode::Pan: pan(e); break; + case DragMode::BoxZoom: box_zoom_update(e); break; + case DragMode::MoveCursor: move_cursor(e); break; + case DragMode::AxisZoomX: + case DragMode::AxisZoomY: axis_zoom(e); break; + case DragMode::None: break; + } +} + +void Controller::end_drag() { + if (mode_ == DragMode::BoxZoom) apply_box_zoom(); + mode_ = DragMode::None; + selection_ = Rect{}; + drag_cursor_ = kInvalid; +} + +// --- gestures ------------------------------------------------------------- + +void Controller::pan(const PointerEvent& e) { + const Rect vp = plot_->viewport(); + if (!(vp.w > 0.0f) || !(vp.h > 0.0f)) return; + const f64 fx = static_cast(e.dx) / static_cast(vp.w); + const f64 fy = static_cast(e.dy) / static_cast(vp.h); + + for (u32 i = 0; i < plot_->x_axis_count(); ++i) { + const Axis& a = plot_->x_axis(i); + const f64 lo = to_space(a.range.min, a.log); + const f64 hi = to_space(a.range.max, a.log); + const f64 d = -fx * (hi - lo); // drag right reveals earlier (smaller) data + plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)}); + } + for (u32 i = 0; i < plot_->y_axis_count(); ++i) { + const Axis& a = plot_->y_axis(i); + const f64 lo = to_space(a.range.min, a.log); + const f64 hi = to_space(a.range.max, a.log); + const f64 d = fy * (hi - lo); // screen y is inverted: dragging down raises y + plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)}); + } +} + +void Controller::box_zoom_update(const PointerEvent& e) { + const f32 x = std::min(origin_x_, e.x); + const f32 y = std::min(origin_y_, e.y); + selection_ = Rect{x, y, std::fabs(e.x - origin_x_), std::fabs(e.y - origin_y_)}; +} + +void Controller::apply_box_zoom() { + const Rect sel = selection_; + // A selection under a few pixels on either side is a click: no zoom. + if (sel.w < kMinBoxPixels || sel.h < kMinBoxPixels) return; + + const Rect vp = plot_->viewport(); + if (!(vp.w > 0.0f) || !(vp.h > 0.0f)) return; + const f64 vx = static_cast(vp.x); + const f64 vy = static_cast(vp.y); + const f64 vw = static_cast(vp.w); + const f64 vh = static_cast(vp.h); + const f64 vright = static_cast(vp.right()); + const f64 vbottom = static_cast(vp.bottom()); + + // Clamped to the viewport so a drag that left it cannot ask for a range + // outside anything the caller can see. + const f64 l = std::clamp(static_cast(sel.x), vx, vright); + const f64 r = std::clamp(static_cast(sel.right()), vx, vright); + const f64 t = std::clamp(static_cast(sel.y), vy, vbottom); + const f64 b = std::clamp(static_cast(sel.bottom()), vy, vbottom); + if (!(r > l) || !(b > t)) return; + + const f64 fx0 = (l - vx) / vw; + const f64 fx1 = (r - vx) / vw; + const f64 fy0 = (vbottom - b) / vh; // the selection's bottom edge is range min + const f64 fy1 = (vbottom - t) / vh; + + for (u32 i = 0; i < plot_->x_axis_count(); ++i) { + const Axis& a = plot_->x_axis(i); + const f64 lo = to_space(a.range.min, a.log); + const f64 hi = to_space(a.range.max, a.log); + plot_->set_x_range(i, Range{from_space(lo + fx0 * (hi - lo), a.log), + from_space(lo + fx1 * (hi - lo), a.log)}); + } + for (u32 i = 0; i < plot_->y_axis_count(); ++i) { + const Axis& a = plot_->y_axis(i); + const f64 lo = to_space(a.range.min, a.log); + const f64 hi = to_space(a.range.max, a.log); + plot_->set_y_range(i, Range{from_space(lo + fy0 * (hi - lo), a.log), + from_space(lo + fy1 * (hi - lo), a.log)}); + } +} + +void Controller::move_cursor(const PointerEvent& e) { + if (drag_cursor_ == kInvalid) return; + const u32 id = static_cast(drag_cursor_); + const Cursor& cursor = plot_->cursor(id); + plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast(e.x))); +} + +void Controller::axis_zoom(const PointerEvent& e) { + const f64 factor = zoom_factor(static_cast(e.dy)); + if (!(factor > 0.0)) return; + if (mode_ == DragMode::AxisZoomX) { + for (u32 i = 0; i < plot_->x_axis_count(); ++i) { + zoom_x_axis(i, plot_->x_transform(i).to_data_x(static_cast(e.x)), factor); + } + } else { + for (u32 i = 0; i < plot_->y_axis_count(); ++i) { + zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast(e.y)), factor); + } + } +} + +// --- helpers -------------------------------------------------------------- + +u64 Controller::cursor_at(f32 px, f32 py) const { + const PlotLayout& lay = std::as_const(*plot_).layout(); + if (!lay.axes.contains(px, py)) return kInvalid; + + u64 best = kInvalid; + f32 best_d = 0.0f; + for (u32 i = 0; i < plot_->cursor_count(); ++i) { + const Cursor& cursor = plot_->cursor(i); + if (!cursor.visible) continue; + const f32 sx = static_cast(plot_->x_transform(cursor.axis_x).to_screen_x(cursor.x)); + const f32 d = std::fabs(sx - px); + if (d > kCursorHitRadiusPx) continue; + if (best == kInvalid || d < best_d) { + best = i; + best_d = d; + } + } + return best; +} + +void Controller::zoom_x_axis(u32 axis, f64 anchor, f64 factor) { + const Axis& a = plot_->x_axis(axis); + const Range r = a.range; + if (!(r.span() > 0.0) || !(factor > 0.0)) return; + const f64 lo = to_space(r.min, a.log); + const f64 hi = to_space(r.max, a.log); + const f64 aa = to_space(anchor, a.log); + // Recomputing the range with the anchor held fixed keeps the anchor's screen + // position invariant, for linear and log axes alike. + plot_->set_x_range(axis, Range{from_space(aa + (lo - aa) * factor, a.log), + from_space(aa + (hi - aa) * factor, a.log)}); +} + +void Controller::zoom_y_axis(u32 axis, f64 anchor, f64 factor) { + const Axis& a = plot_->y_axis(axis); + const Range r = a.range; + if (!(r.span() > 0.0) || !(factor > 0.0)) return; + const f64 lo = to_space(r.min, a.log); + const f64 hi = to_space(r.max, a.log); + const f64 aa = to_space(anchor, a.log); + plot_->set_y_range(axis, Range{from_space(aa + (lo - aa) * factor, a.log), + from_space(aa + (hi - aa) * factor, a.log)}); +} + +} // namespace plot::input diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 02dcaeb..50bd7d8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -35,6 +35,7 @@ add_executable(plot_tests test_ticks.cpp test_measure.cpp test_plot.cpp + test_controller.cpp egl_headless.cpp ) target_link_libraries(plot_tests PRIVATE plot::core ${plot_egl_library} Threads::Threads) diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp new file mode 100644 index 0000000..3e87473 --- /dev/null +++ b/tests/test_controller.cpp @@ -0,0 +1,237 @@ +#include "harness.hpp" + +#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(); + 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 + // 80 px right-drag on an 800 px plot showing [0, 100] reveals 10 units of + // earlier data: the data follows the cursor. (The brief printed +10.0 here.) + PLOT_CHECK_NEAR(p.x_range(ax).min, -10.0, 1e-6); + PLOT_CHECK(p.y_range(ay).max > 1.0); // moving down raises y range +} + +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 + 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}); + PLOT_CHECK_NEAR(p.x_range(ax).min, 100.0, 1e-6); + PLOT_CHECK_NEAR(p.x_range(ax).max, 300.0, 1e-6); +} + +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}); + 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_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(f.plot.y_range(f.ay).span() < 2.0); +} + +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(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); +}