feat(input): host-agnostic events and pan/zoom/cursor controller

This commit is contained in:
Martino Ferrari committed 2026-09-27 22:34:16 +02:00
1 parent 606e40a9f1
commit 5ab854104a
6 files changed
+718

No files matched your search

+313
View File
@@ -0,0 +1,313 @@
#include <plot/input/controller.hpp>
#include <plot/math/transform.hpp>
#include <plot/plot/plot.hpp>
#include <algorithm>
#include <cmath>
#include <utility>
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<f64>(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<f64>(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<f64>(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<f64>(e.dx) / static_cast<f64>(vp.w);
const f64 fy = static_cast<f64>(e.dy) / static_cast<f64>(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<f64>(vp.x);
const f64 vy = static_cast<f64>(vp.y);
const f64 vw = static_cast<f64>(vp.w);
const f64 vh = static_cast<f64>(vp.h);
const f64 vright = static_cast<f64>(vp.right());
const f64 vbottom = static_cast<f64>(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<f64>(sel.x), vx, vright);
const f64 r = std::clamp(static_cast<f64>(sel.right()), vx, vright);
const f64 t = std::clamp(static_cast<f64>(sel.y), vy, vbottom);
const f64 b = std::clamp(static_cast<f64>(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<u32>(drag_cursor_);
const Cursor& cursor = plot_->cursor(id);
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)));
}
void Controller::axis_zoom(const PointerEvent& e) {
const f64 factor = zoom_factor(static_cast<f64>(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<f64>(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<f64>(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<f32>(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