Files
plotter/src/input/controller.cpp
T
Martino Ferrari be1f31a04f feat: an open box zoom draws the frame the release will apply
The controller has always tracked its selection; nothing drew it, so a Ctrl or
Shift drag framed a range with no feedback until the release.

The preview is the area the release will change, not what the pointer swept:
Ctrl frames the x range only, so a band across the plot is what it will zoom,
where the sliver a straight horizontal drag sweeps is only the gesture. The
plot carries the rect a host hands it -- it learns nothing about pointers --
and the renderer draws a low-alpha fill under a light border over everything
else inside the axes.
2026-10-11 11:07:55 +02:00

1008 lines
47 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/input/controller.hpp>
#include <plot/math/transform.hpp>
#include <plot/plot/plot.hpp>
#include <algorithm>
#include <cmath>
#include <numbers>
#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;
/// Zoom per pixel of a middle-drag along an axis strip. A pointer drag reports
/// pixels, a different unit from a wheel notch, so it must not reuse
/// kZoomPerNotch: 200 px of drag is one decade, exp(-200 * ln10/200) == 0.1. The
/// pixels are **logical** ones -- a distance the hand moves, like the tolerances
/// below -- while the deltas that arrive are device pixels, so `axis_zoom()`
/// converts through the plot's scale before applying this.
constexpr f64 kZoomPerPixel = std::numbers::ln10 / 200.0;
/// How close the pointer must be to a cursor to grab it, in **logical** pixels: the
/// listener's aim is a physical distance, so it is converted to the device-pixel
/// radius the comparison needs through `Style::device()` at the plot's own scale
/// (see `cursor_at()`). An unscaled constant would make grabbing physically harder
/// -- half as forgiving -- on a 2x display, which is the defect the scale exists to
/// prevent. Comfortably wider than a marker's 1 px stroke, so a press lands on the
/// handle the hover highlight promised: `cursor_at()` is the *one* value both the
/// hover and the grab read, and a region's edge bands are cut back from this same
/// number, so the affordance the host sees and the gesture it gets agree.
constexpr f32 kCursorHitRadiusLogical = 7.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for
/// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f;
/// Share of an axis' span one horizontal wheel notch (`dx`) pans, in axis space:
/// a notch is a small, deliberate step, a twentieth of the view.
constexpr f64 kPanPerNotch = 0.05;
/// Share of an axis' span one arrow-key press pans, in axis space -- twice the
/// wheel's step, because a key press is a coarser statement than a detent. A held
/// key repeats (the adapters' shared policy), so a hold pans continuously.
constexpr f64 kPanPerPress = 0.10;
/// The double-click window: two presses of the same button within this many
/// seconds -- and within `kDoubleClickPxLogical` of each other -- are a double-click
/// reset, not two gestures. Measured on `PointerEvent::time_s`, the host's clock.
constexpr f64 kDoubleClickSeconds = 0.35;
/// How close together the two presses of a double-click must land, in **logical**
/// pixels: the same hand-distance rule as the other tolerances here, converted
/// through `Style::device()` at the plot's scale.
constexpr f32 kDoubleClickPxLogical = 4.0f;
/// The narrowest *interior* a region's body keeps, in **logical** pixels: the edge bands are
/// cut back until this much of the middle is left, so a region too narrow for the full grab
/// radius on each side is still something a pointer can take hold of and *move*. Without it
/// every interior point of a region narrower than twice the radius (14 logical px) lies
/// within a bound's reach, and the bound branch would always win -- such a region could be
/// resized but never moved. Half of it either side of the midpoint, which is what makes the
/// middle of even the narrowest region with grabbable bounds (3 logical px) reachable.
constexpr f32 kMinRegionBodyLogical = 2.0f;
/// The narrowest region whose bounds are grabbable, in **logical** pixels. Closer
/// together than this, the two bounds are one target the pointer cannot separate:
/// an edge would win the hit test every time and the region could never be moved
/// again, so a *narrower* region is grabbed by its body -- widened to this same
/// minimum around its bounds, which keeps a collapsed region something a pointer can
/// still take hold of rather than a line. Logical like the two tolerances above, and
/// converted through the same one call.
constexpr f32 kMinRegionEdgeGrabLogical = 3.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); }
/// True when a coordinate is one arithmetic can work with. Every event here comes
/// from a host, and a host can hand over anything: a toolkit's "no pointer"
/// sentinel, a NaN from a degenerate mapping of its own, an infinity from a lost
/// device. A non-finite value rides the pan and zoom arithmetic into a range that
/// is no longer a range, and since a gesture that sets a range also clears auto
/// scale, `f` would be the only way back -- so it must not get in.
bool finite(f32 v) noexcept { return std::isfinite(v); }
/// `e`, with an unusable position replaced by the last known one and an unusable
/// delta by no movement. Sanitising rather than rejecting keeps the buttons in the
/// same event alive: a drag whose release arrived with a garbage position must
/// still end.
PointerEvent sanitized(const PointerEvent& e, bool have_pointer, f32 last_x,
f32 last_y) noexcept {
PointerEvent in = e;
if (!finite(in.x) || !finite(in.y)) {
in.x = have_pointer ? last_x : 0.0f;
in.y = have_pointer ? last_y : 0.0f;
in.dx = 0.0f;
in.dy = 0.0f;
return in;
}
if (!finite(in.dx)) in.dx = 0.0f;
if (!finite(in.dy)) in.dy = 0.0f;
return in;
}
/// `e`, with an unusable position replaced by the last known one. The wheel's
/// amount is deliberately left alone: `on_wheel` rejects an unusable one *before*
/// the event is acted on, because a zeroed amount is a zoom of exactly one, and
/// running that over an unchanged range would still clear every axis' auto scale.
WheelEvent sanitized(const WheelEvent& e, bool have_pointer, f32 last_x, f32 last_y) noexcept {
WheelEvent in = e;
if (!finite(in.x) || !finite(in.y)) {
in.x = have_pointer ? last_x : 0.0f;
in.y = have_pointer ? last_y : 0.0f;
}
return in;
}
} // 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();
// A resize moves the axes (and with them the data under a stationary pointer), so the
// crosshair is re-placed against the new rect -- and cleared if the pointer is now outside
// it, which is the same rule as a pointer that left.
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
// The hover the layout is part of: a handle under the pointer may be somewhere
// else now, and the highlight the renderer draws reads what this publishes.
publish_hot();
}
void Controller::on_pointer(const PointerEvent& raw) {
const PointerEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
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) {
if (double_click(e)) {
// The second press of a double-click re-fits every axis and begins no drag:
// a reset is a click-shaped gesture, not the start of one.
fit_all();
} else {
begin_drag(e);
}
} else if (e.released) {
end_drag(e);
} else if (e.down && mode_ != DragMode::None) {
update_drag(e);
}
// The crosshair is placed (or cleared) from the pointer's own position, after the gesture
// above: a press does not clear it -- the pointer is still over the axes, still pointing at
// whatever it just grabbed -- and a drag keeps it re-anchored to the pointer as the data
// moves under it.
update_crosshair(e.x, e.y);
// Every exit above ends here: a hover that moved, a gesture that opened, and a
// gesture that closed all change what is under the pointer, and the publish is
// what makes the renderer's highlight (and a host's resize cursor) follow.
publish_hot();
}
void Controller::on_wheel(const WheelEvent& raw) {
// An amount that is not a number is not a wheel: sanitising it to zero would look
// like a zero-notch zoom -- a factor of exactly one -- and the zoom body would
// still run, calling the range setters with the unchanged range. Those clear
// `auto_scale` unconditionally, so a single garbage wheel would silently disarm
// auto-fit on every axis. `dx` is refused by the same rule: it pans when `dy` is
// zero, and a garbage pan is no more welcome than a garbage zoom. Return before
// the event can mutate anything.
if (!std::isfinite(raw.dy) || !std::isfinite(raw.dx)) return;
const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
plot_->layout();
// A zero-notch wheel -- `dy == 0` and `dx == 0`, which a trackpad or a driver
// happily forwards -- must change nothing at all. Letting it run would call the
// range setters with every range unchanged, and those clear `auto_scale`: one
// empty scroll would silently disarm auto-fit on every axis. There is nothing to
// apply, so there is nothing to lay out or republish either.
if (e.dy == 0.0f && e.dx == 0.0f) return;
if (e.dy != 0.0f) {
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
// The zoom is refused, and the whole event with it, when its factor could not
// be applied honestly: a factor of exactly one changes no range but would
// still clear `auto_scale` on the way through the setters, a non-finite one
// (a huge downward `dy` explodes `exp`) would hand a non-finite span to the
// setters, and a non-positive one is not a zoom at all. "Leaves the plot
// exactly as it was" is the rule for an unusable amount.
if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.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, x_zoom_anchor(i, e.x, e.y), 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);
}
}
}
// A horizontal wheel pans x only: `dx` notches, 5% of the x span each, in axis
// space (so a log axis scrolls multiplicatively, exactly as its transform maps).
// A positive `dx` scrolls right -- the view moves toward larger x, the same
// reading `WheelEvent`'s comment gives the field. On a live axis the range
// setter reads the shifted window as a span and an offset behind the edge, so
// scrolling right runs to the live edge and stops there, exactly as a drag pan
// does.
if (e.dx != 0.0f) shift_x_axes(static_cast<f64>(e.dx) * kPanPerNotch);
// The ranges moved under a pointer that did not, so the geometry under it changed: refresh
// the layout -- the region rects the hit test reads were measured for the range this wheel
// has just replaced -- and republish what is hot. A frame drawn right after a scroll then
// emphasizes the handle the pointer is over *now*, rather than the one it was over before,
// without waiting for the mouse to move again.
plot_->layout();
// The data under the pointer moved with the ranges, so the crosshair -- which is a data
// coordinate -- is re-anchored to the pointer rather than left on the value it was placed
// at: it follows the pointer, and this is the one thing that moves the data without moving
// the pointer.
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::on_gesture(const GestureEvent& raw) {
// The same refusal rule as the wheel: a frame carrying something that is not
// a number is not a gesture, and a frame that could not change anything --
// a unit scale and no movement -- must not reach the range setters, which
// clear `auto_scale` for no visible change. `scale <= 0` is not a zoom.
if (!std::isfinite(raw.x) || !std::isfinite(raw.y) || !std::isfinite(raw.scale) ||
!std::isfinite(raw.dx) || !std::isfinite(raw.dy)) {
return;
}
if (raw.scale <= 0.0f) return;
if (raw.scale == 1.0f && raw.dx == 0.0f && raw.dy == 0.0f) return;
plot_->layout();
const f64 factor = 1.0 / static_cast<f64>(raw.scale); // pinch out = zoom in
if (std::isfinite(factor) && factor > 0.0 && factor != 1.0) {
// A pinch is a two-handed instrument, so it has no modifier semantics: it
// zooms both axes about the focal point, exactly as an unmodified wheel
// notch pair would -- same setters, same live-axis behaviour.
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, raw.x, raw.y), factor);
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(raw.y)), factor);
}
}
// The focal point's movement is a two-finger pan: the same path a one-finger
// drag takes, deltas included, so a pinch-and-drag moves the data under the
// fingers on every axis the drag would move.
if (raw.dx != 0.0f || raw.dy != 0.0f) {
PointerEvent pan_e;
pan_e.x = raw.x;
pan_e.y = raw.y;
pan_e.dx = raw.dx;
pan_e.dy = raw.dy;
pan(pan_e);
}
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::on_key(const KeyEvent& e) {
if (!e.down) return; // every action below is press-only
// Escape cancels the active drag (see cancel_drag). A key with no drag open --
// and any other key -- changes nothing and returns below without touching the
// layout, the crosshair or the published handles.
if (e.key == kKeyEscape) {
if (mode_ != DragMode::None) cancel_drag();
return;
}
// 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 (and per repeat, which is why the
// adapters forward key repeat). An arrow says where to *look*: Left walks the
// window toward smaller x, Up toward larger y -- the screen-y inversion a drag's
// dy carries does not apply, because a drag says where the data goes and a key
// says where the viewer goes.
switch (e.key) {
case kKeyLeft: shift_x_axes(-kPanPerPress); break;
case kKeyRight: shift_x_axes(kPanPerPress); break;
case kKeyUp: shift_y_axes(kPanPerPress); break;
case kKeyDown: shift_y_axes(-kPanPerPress); break;
default:
break;
}
const bool pans = e.key >= kKeyLeft && e.key <= kKeyDown;
const bool zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut;
const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper;
if (!pans && !zooms && !fits) return; // a key this controller has no gesture for
if (zooms) {
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
const f64 factor = zoom_factor(notches);
// Ctrl restricts the zoom to x, Alt to y; bare +/- zooms both. (Shift cannot be
// used: '+' is shift+'=' on common layouts, so key_from() folds them together and
// a bare zoom-in already arrives with Mod::Shift set.) Ctrl+Alt zooms neither,
// which is harmless and needs no special case.
const bool only_x = has_mod(e.mods, Mod::Ctrl);
const bool only_y = has_mod(e.mods, Mod::Alt);
if (!only_y) {
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);
}
}
if (!only_x) {
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);
}
}
} else if (fits) {
plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
}
// A key stroke that moved a range moved the geometry under a stationary pointer, exactly
// as a wheel notch does: refresh the layout and republish what is hot, so the highlight a
// frame draws is the handle the pointer is over now (see on_wheel). A key this controller
// has no gesture for changes nothing and returns above -- and so does not touch the
// crosshair either.
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::update_crosshair(f32 px, f32 py) {
// Inside the data area the crosshair is at the coordinates under the pointer; outside it
// there is no data to read, so there is no crosshair -- the null answer, rather than a
// value clamped to the nearest edge of the window.
const PlotLayout& layout = std::as_const(*plot_).layout();
if (!layout.axes.contains(px, py)) {
plot_->clear_crosshair();
return;
}
// Both coordinates, on the primary axis pair (the same pair the grid and a region's fill
// are placed through): a pointer over the axes knows its x and its y, so a single plot's
// crosshair is the full cross. A `Figure` is driven by `Figure::set_crosshair()` instead,
// which applies one x to every row and draws no horizontal line -- its rows share x and
// not y.
plot_->set_crosshair(plot_->x_transform(0u).to_data_x(static_cast<f64>(px)),
plot_->y_transform(0u).to_data_y(static_cast<f64>(py)), true);
}
u64 Controller::hovered_cursor() const {
if (!has_pointer_) return kInvalid;
return cursor_at(pointer_x_, pointer_y_);
}
u64 Controller::hovered_region() const {
if (!has_pointer_) return kInvalid;
return region_hot(pointer_x_, pointer_y_).region;
}
RegionGrab Controller::region_grab(u32 region) const {
if (!has_pointer_) return RegionGrab::None;
for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) {
if (candidate.region == region) return zone_hit(candidate, pointer_x_, pointer_y_).zone;
}
return RegionGrab::None; // an id that names no visible region
}
Controller::ZoneHit Controller::zone_hit(const RegionLayout& region, f32 px, f32 py) const {
ZoneHit hit;
// Off the data area nothing is hot, whether the pointer is over a label strip or
// off the plot entirely. A bound that has scrolled just outside `axes` is still
// within reach of a pointer inside it -- the slack the cursor hit test has too --
// but the strips are not a place a region can be grabbed from.
if (!std::as_const(*plot_).layout().axes.contains(px, py)) return hit;
const f32 left_x = handle_column(region.left_handle);
const f32 right_x = handle_column(region.right_handle);
const f32 min_w = plot_->style().device(kMinRegionEdgeGrabLogical);
// Grabbable bounds: the bounds are told apart only while the region is wide enough to
// hold a band at each end and an interior between them -- below that the body is the
// only handle it has, which is the collapsed-region rule above -- and each band is cut
// back so that interior always exists. The radius every other handle in this controller
// is hit-tested with is kept wherever a region can afford it: the band is that radius
// while twice it plus the minimum interior still fits, and half the remainder after
// that, so the middle of a 6 px region is a body rather than inside both bounds. One
// tolerance, so what `hovered_cursor()` calls a hit and what a region bound calls a hit
// agree wherever there is room for both.
if (right_x - left_x >= min_w) {
const f32 radius = plot_->style().device(kCursorHitRadiusLogical);
const f32 band = std::min(radius, 0.5f * (right_x - left_x -
plot_->style().device(kMinRegionBodyLogical)));
const f32 dl = std::fabs(px - left_x);
const f32 dr = std::fabs(px - right_x);
const f32 d = std::min(dl, dr);
if (d <= band) {
hit.zone = dl <= dr ? RegionGrab::Left : RegionGrab::Right;
hit.distance = d;
return hit;
}
}
// No bound in range: the body, widened by half the minimum on each side so a
// region too narrow to have grabbable bounds is still something a pointer can take
// hold of rather than a line it cannot hit.
const f32 grow = 0.5f * min_w;
if (px >= left_x - grow && px <= right_x + grow) hit.zone = RegionGrab::Body;
return hit;
}
Controller::RegionHot Controller::region_hot(f32 px, f32 py) const {
RegionHot hot;
u64 body = kInvalid;
f32 nearest = 0.0f;
for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) {
const ZoneHit hit = zone_hit(candidate, px, py);
if (hit.zone == RegionGrab::None) continue;
if (hit.zone == RegionGrab::Body) {
// Remembered rather than chosen: a body only wins when no bound *anywhere* is
// in range, and the last region holding the pointer is the one on top --
// regions are drawn in order, so a later one covers an earlier one's interior.
body = candidate.region;
continue;
}
if (hot.region == kInvalid || hit.distance < nearest) {
hot.region = candidate.region;
hot.zone = hit.zone;
nearest = hit.distance;
}
}
if (hot.region != kInvalid) return hot;
return body == kInvalid ? RegionHot{} : RegionHot{body, RegionGrab::Body};
}
void Controller::publish_hot() {
HotHandles hot;
if (has_pointer_) {
hot.cursor = cursor_at(pointer_x_, pointer_y_);
if (drag_region_ != kInvalid) {
// An open region drag keeps its own zone hot for as long as it runs, so the
// highlight follows the grab rather than the pointer: a body dragged out from
// under the pointer still reads as the region being moved, and a bound keeps
// its emphasis after the pointer has left it.
hot.region = drag_region_;
hot.zone = drag_zone_;
} else {
const RegionHot region = region_hot(pointer_x_, pointer_y_);
hot.region = region.region;
hot.zone = region.zone;
}
}
hot.grabbed_cursor = drag_cursor_;
plot_->set_hot_handles(hot);
}
// --- drag dispatch --------------------------------------------------------
void Controller::begin_drag(const PointerEvent& e) {
origin_x_ = e.x;
origin_y_ = e.y;
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// What Escape will restore if this gesture is cancelled: the axes' state *now*,
// before the drag moves anything (see capture_ranges).
capture_ranges();
// The modifier box zoom, on the left only: a selection box is what a modified
// left press means even when it starts over a handle. The mask is resolved once,
// here, from the modifier table -- Shift is the plain both-axis selection it has
// always been, Ctrl frames the x range only, Alt the y range only, and any other
// combo falls back to both -- and `apply_box_zoom()` reads it on release, per
// axis and for the click threshold alike.
if (e.button == Button::Left) {
const bool shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
const bool alt = has_mod(e.mods, Mod::Alt);
if (shift || ctrl || alt) {
mode_ = DragMode::BoxZoom;
// The masks: Ctrl alone frames the x range only, Alt alone the y range only,
// and every other combo (Shift included) frames both -- the field says whether
// the release *applies* that axis, so the axis named by the mask is the one
// that stays true.
const bool ctrl_only = ctrl && !shift && !alt;
const bool alt_only = alt && !shift && !ctrl;
box_zoom_x_ = !alt_only;
box_zoom_y_ = !ctrl_only;
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
return;
}
}
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;
}
// Everything else -- the left and the right button both, since a host that
// reserves the left one still drives its drags with the right -- grabs the handle
// under the press before it pans. Ctrl needs no special case: a press on a cursor
// does exactly this with or without it, and a ctrl press on nothing still pans.
if (begin_handle_drag(e)) return;
mode_ = DragMode::Pan; // left and right drag both pan
}
bool Controller::begin_handle_drag(const PointerEvent& e) {
// Cursor markers first: they are what a measure's band edges *are* (a band
// between two cursors is resized by dragging those cursors), and a marker is drawn
// over everything else, so it is the thing a press on it means.
const u64 cursor = cursor_at(e.x, e.y);
if (cursor != kInvalid) {
mode_ = DragMode::MoveCursor;
drag_cursor_ = cursor;
return true;
}
const RegionHot hot = region_hot(e.x, e.y);
if (hot.region == kInvalid) return false;
drag_region_ = hot.region;
drag_zone_ = hot.zone;
const Region& region = plot_->region(static_cast<u32>(hot.region));
switch (hot.zone) {
case RegionGrab::Left:
// The other bound is held for the whole gesture: each motion is then the two
// numbers this drag is made of, not a step from the previous one, so a pointer
// that moves back and forth cannot accumulate a drift -- and when it crosses
// the held bound the store orders them, which swaps the interval instead of
// inverting it (see Plot::set_region).
mode_ = DragMode::ResizeRegionLeft;
drag_fixed_x_ = region.x1;
return true;
case RegionGrab::Right:
mode_ = DragMode::ResizeRegionRight;
drag_fixed_x_ = region.x0;
return true;
case RegionGrab::Body:
// A body drag preserves the width exactly by construction: both bounds move by
// the same delta from where they were when the gesture opened.
mode_ = DragMode::MoveRegion;
drag_x0_ = region.x0;
drag_x1_ = region.x1;
drag_grab_x_ = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
return true;
case RegionGrab::None:
break;
}
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
return false;
}
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::MoveRegion: move_region(e); break;
case DragMode::ResizeRegionLeft:
case DragMode::ResizeRegionRight: resize_region(e); break;
case DragMode::AxisZoomX:
case DragMode::AxisZoomY: axis_zoom(e); break;
case DragMode::None: break;
}
}
void Controller::end_drag(const PointerEvent& e) {
// The release carries the authoritative end of the gesture: refresh the
// selection from it first, so a host that reports only a press and a release
// (no motion event in between) still frames the box the release describes
// instead of leaving selection_ at 0x0 and degrading the zoom to a click.
if (mode_ == DragMode::BoxZoom) {
box_zoom_update(e);
apply_box_zoom();
}
// A region's snap is applied exactly once, here, on the gesture's final bounds:
// the drag itself stores them verbatim, so they follow the pointer instead of
// sticking to the sample grid as it moves (see Plot::snap_region()).
if (drag_region_ != kInvalid) plot_->snap_region(static_cast<u32>(drag_region_));
// And the same for a grabbed cursor: `move_cursor()` wrote the free pointer position
// for the whole gesture, so this is where the marker lands on its own snap rule --
// the drop commits the snap, both orientations alike (see Plot::snap_cursor()).
if (drag_cursor_ != kInvalid) plot_->snap_cursor(static_cast<u32>(drag_cursor_));
mode_ = DragMode::None;
selection_ = Rect{};
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// The capture is the cancelled gesture's undo record, and this gesture was not
// cancelled: keeping it would let a later Escape restore a state that ended long
// ago (and `capture_ranges()` only runs at a press).
saved_x_.clear();
saved_y_.clear();
}
// --- gestures -------------------------------------------------------------
void Controller::capture_ranges() {
saved_x_.clear();
saved_y_.clear();
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
saved_x_.push_back(SavedX{a.range, a.offset, a.auto_scale});
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
saved_y_.push_back(SavedY{a.range, a.auto_scale});
}
}
void Controller::cancel_drag() {
// The ranges the axes held at `begin_drag`, through the plot's own setters: an
// `Off` axis gets its range and its auto-scale flag back exactly, and a live one
// gets its span and offset restated the way the setters read them (the offset is
// written back verbatim after the range, since the live edge of *this* moment --
// not of the press -- is what the setter would otherwise derive it from). A
// cancelled gesture is as if it never ran: nothing is applied, no auto-scale is
// disarmed that was not already, and no box is framed.
for (u32 i = 0; i < plot_->x_axis_count() && i < saved_x_.size(); ++i) {
const SavedX& saved = saved_x_[i];
plot_->set_x_range(i, saved.range);
plot_->set_x_offset(i, saved.offset);
plot_->set_x_auto_scale(i, saved.auto_scale);
}
for (u32 i = 0; i < plot_->y_axis_count() && i < saved_y_.size(); ++i) {
const SavedY& saved = saved_y_[i];
plot_->set_y_range(i, saved.range);
plot_->set_y_auto_scale(i, saved.auto_scale);
}
// The gesture itself: no mode, no selection, nothing grabbed -- and the highlight
// the renderer draws follows, through the same tail every event that changes the
// geometry under the pointer runs.
mode_ = DragMode::None;
selection_ = Rect{};
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::fit_all() {
plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
// A fit moved the geometry under a stationary pointer, exactly as a wheel notch
// does -- same tail, same reason (see on_wheel).
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
bool Controller::double_click(const PointerEvent& e) {
const f32 radius = plot_->style().device(kDoubleClickPxLogical);
const f32 dx = e.x - click_x_;
const f32 dy = e.y - click_y_;
// Both presses must carry a host timestamp for the window to mean anything: a
// host that leaves `time_s` at 0 never produces a double-click (the field is
// optional, and two presses "0 s apart" would otherwise fire on any host).
const bool in_time = e.time_s > 0.0 && click_time_s_ > 0.0 &&
e.time_s - click_time_s_ <= kDoubleClickSeconds;
const bool in_space = dx * dx + dy * dy <= radius * radius;
const bool is_double =
has_click_ && click_button_ == static_cast<int>(e.button) && in_time && in_space;
// This press is what a third one would be measured against -- unless the two
// just made a double-click, in which case the record clears: a third press starts
// a fresh gesture rather than re-firing the reset.
has_click_ = !is_double;
click_button_ = static_cast<int>(e.button);
click_time_s_ = e.time_s;
click_x_ = e.x;
click_y_ = e.y;
return is_double;
}
void Controller::shift_x_axes(f64 fraction) {
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 = fraction * (hi - lo); // positive scrolls toward larger x
plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
}
}
void Controller::shift_y_axes(f64 fraction) {
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 = fraction * (hi - lo); // positive scrolls toward larger y
plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
}
}
void Controller::pan(const PointerEvent& e) {
// The transforms map a range onto layout().axes, so the pixel-to-data scale is
// the range span over the axes rect -- not over the whole viewport. Using the
// viewport (56 px of label strip on an 800 px plot) makes dragged data lag the
// pointer by the strip's fraction, breaking grab-and-drag.
const Rect axes = std::as_const(*plot_).layout().axes;
if (!(axes.w > 0.0f) || !(axes.h > 0.0f)) return;
const f64 fx = static_cast<f64>(e.dx) / static_cast<f64>(axes.w);
const f64 fy = static_cast<f64>(e.dy) / static_cast<f64>(axes.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)});
}
}
Rect Controller::selection_preview() const noexcept {
// Nothing to preview off a box zoom: the mask bits outlive the gesture -- `end_drag()` clears the
// selection, not them -- so widening without this guard would hand out a full-band rect for a drag
// that finished minutes ago.
if (mode_ != DragMode::BoxZoom) return Rect{};
// And nothing has been framed until the drag has swept every axis the release will apply: at the
// press instant the selection is an empty rect at the pointer, and the *widened* form of that is a
// band the width of the plot, which is a claim about a range that does not exist yet. Ctrl's other
// axis is deliberately not required: a drag with no vertical movement at all is a band across the
// plot, which is exactly what Ctrl is for.
if ((box_zoom_x_ && !(selection_.w > 0.0f)) || (box_zoom_y_ && !(selection_.h > 0.0f))) return Rect{};
Rect preview = selection_;
if (box_zoom_x_ && box_zoom_y_) return preview;
const Rect& axes = plot_->layout().axes;
if (!box_zoom_x_) {
preview.x = axes.x;
preview.w = axes.w;
}
if (!box_zoom_y_) {
preview.y = axes.y;
preview.h = axes.h;
}
return preview;
}
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 *logical* pixels on the axes the mask will zoom is a
// click: no zoom. The threshold is masked with the gesture -- a Ctrl drag frames
// the x range from a selection however short it is vertically, and vice versa --
// which is what makes a thin horizontal band a legal x-only box rather than a
// click no matter what the y says. The selection is measured in device pixels
// like everything the controller is handed, so the threshold is converted at the
// plot's own scale -- through the one place that conversion lives, so a change to
// its fallback is picked up here too.
const f32 min_box = plot_->style().device(kMinBoxLogical);
if (box_zoom_x_ && sel.w < min_box) return;
if (box_zoom_y_ && sel.h < min_box) return;
// Clamped to the axes rect the transforms are built from, so a drag that left
// the plot area cannot ask for data outside what the box covers on screen.
const Rect axes = std::as_const(*plot_).layout().axes;
const f64 ax = static_cast<f64>(axes.x);
const f64 ar = static_cast<f64>(axes.right());
const f64 ay = static_cast<f64>(axes.y);
const f64 ab = static_cast<f64>(axes.bottom());
const f64 l = std::clamp(static_cast<f64>(sel.x), ax, ar);
const f64 r = std::clamp(static_cast<f64>(sel.right()), ax, ar);
const f64 t = std::clamp(static_cast<f64>(sel.y), ay, ab);
const f64 b = std::clamp(static_cast<f64>(sel.bottom()), ay, ab);
if (box_zoom_x_ && !(r > l)) return;
if (box_zoom_y_ && !(b > t)) return;
// The ranges come straight from the transforms, so the box frames exactly the
// data under it; the selection's bottom edge is the range minimum on y. Masked
// axes are left exactly as they were -- the release applies only what the press
// promised.
if (box_zoom_x_) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Transform tx = plot_->x_transform(i);
plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)});
}
}
if (box_zoom_y_) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Transform ty = plot_->y_transform(i);
plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)});
}
}
}
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);
// A grabbed cursor mutates the coordinate its own orientation owns and nothing else, and
// each half names the one setter for it -- so a ruler dragged along y can never disturb a
// marker's x, and the dead coordinate of either is untouched by construction.
//
// Both writes are `CursorPlacement::Free`: the marker follows the pointer for the whole
// gesture and lands on its grid only when the drag ends (`end_drag()` calls
// `Plot::snap_cursor()`), so a snapping cursor tracks the hand instead of hopping between
// samples -- the defect reported from use. A caller reading `cursor(id).x` / `.y` while
// the drag runs reads that free position.
if (cursor.is_horizontal()) {
// Clamped to the ruler's own y axis: a pointer that has left the axes has no data under
// it, and the coordinate it extrapolates to is past the end of the axis the ruler reads
// values off -- a position where the ruler would either vanish (the renderer skips a row
// outside `axes`) or, worse, have to be dragged back by a zoom nobody asked for. Clamping
// keeps it readable at the edge it was dragged to instead. `set_cursor_y()` itself stays
// verbatim, so a *caller* that means to park a ruler off range still can.
const Axis& axis = plot_->y_axis(cursor.axis_y);
const f64 y = plot_->y_transform(cursor.axis_y).to_data_y(static_cast<f64>(e.y));
// `std::clamp` needs an ordered pair and a y range may be inverted -- descending depth,
// row index or time is ordinary -- because `set_y_range()` stores it verbatim and only
// the transform widens it. Order the bounds rather than assuming `min <= max`, or an
// inverted axis would collapse the dragged ruler onto one end (and the call is UB).
plot_->set_cursor_y(id, std::clamp(y, std::min(axis.range.min, axis.range.max),
std::max(axis.range.min, axis.range.max)),
CursorPlacement::Free);
return;
}
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)),
CursorPlacement::Free);
}
void Controller::move_region(const PointerEvent& e) {
if (drag_region_ == kInvalid) return;
// The pointer's own data coordinate, not the event's delta: the delta a host
// reports for one motion is not always the difference between the positions it
// reported, and a region's two bounds must move by exactly the same amount or the
// width changes. Both come from the bounds the gesture opened with plus this one
// offset, so nothing accumulates over a long drag.
const f64 grabbed = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
const f64 delta = grabbed - drag_grab_x_;
plot_->set_region(static_cast<u32>(drag_region_), drag_x0_ + delta, drag_x1_ + delta);
}
void Controller::resize_region(const PointerEvent& e) {
if (drag_region_ == kInvalid) return;
const f64 x = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
// The bound the gesture did not grab stays where it was for the whole drag, and
// the grabbed one is wherever the pointer says. When the two cross, the *store*
// orders them (`set_region` keeps min and max), so the interval swaps rather than
// inverting -- and the pointer keeps hold of its bound: the next motion is still
// "the other end plus this pointer", with no state to get stuck in.
if (mode_ == DragMode::ResizeRegionLeft) {
plot_->set_region(static_cast<u32>(drag_region_), x, drag_fixed_x_);
} else {
plot_->set_region(static_cast<u32>(drag_region_), drag_fixed_x_, x);
}
}
void Controller::axis_zoom(const PointerEvent& e) {
// A pointer drag reports pixels, not wheel notches, so it uses its own
// sensitivity: 200 px is one decade. The gain is a physical distance the hand
// moves, so the device-pixel delta is converted to logical pixels first -- the
// same drag must zoom the same amount on a 2x display.
//
// A scale that is not a positive number is not a scale -- a host that has not
// measured its display, a field nobody filled in -- the same state
// `Style::device()` tolerates. Here it means *no zoom*: dividing by it would make
// `logical_dy` infinite and the factor infinite (or NaN), and that factor reaches
// `set_x_range()`/`set_y_range()`, which store it verbatim and clear `auto_scale`
// -- an axis left non-finite until an auto-fit. A negative scale would silently
// invert the zoom direction too, so the whole class is refused here.
const f64 scale = static_cast<f64>(plot_->ui_scale());
if (!(scale > 0.0) || !std::isfinite(scale)) return;
const f64 logical_dy = static_cast<f64>(e.dy) / scale;
const f64 factor = std::exp(-logical_dy * kZoomPerPixel);
// The same degenerate factors the wheel refuses: a factor of exactly one (a
// motion that reports no dy -- running it would still clear `auto_scale` on its
// way through the range setters), a non-finite one (an enormous dy explodes
// `exp` into a span the setters cannot store), and a non-positive one, which is
// not a zoom at all.
if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return;
if (mode_ == DragMode::AxisZoomX) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), 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 --------------------------------------------------------------
f64 Controller::x_zoom_anchor(u32 axis, f32 px, f32 py) const {
// The pointer's own coordinate while it is over the data area -- the anchor a
// zoom has always had, and the reason it feels attached to what is under the
// cursor. Outside `axes` there is no data under the pointer at all, so the
// coordinate it extrapolates to is a number the user cannot see; a live axis has
// a better answer, its live edge, which makes a zoom from the label strip or from
// off the plot keep the window ending at the newest sample instead of drifting
// away from it. Any other axis keeps the extrapolated coordinate, and `Off` is
// the default, so nothing changes for a plot that is not following.
//
// A figure row's axis is `Off` even while its window follows, because a link group
// owns the shared window *and* the follow state (`Figure::link_x()` refuses a `Live`
// member) -- so the axis' own follow flag is not the whole answer. A following group
// publishes its edge to every member (`Plot::x_live_owner()`), and that edge is read
// second: it is the group's -- the union of its members' newest x -- which is the
// coordinate the window actually ends at, and the member's own series would be a
// different answer in a figure whose rows are fed at different rates.
if (!std::as_const(*plot_).layout().axes.contains(px, py)) {
f64 live = 0.0;
if (plot_->x_follow(axis) == XFollow::Live && plot_->x_live_edge(axis, live)) return live;
if (plot_->x_live_owner(axis, live)) return live;
}
return plot_->x_transform(axis).to_data_x(static_cast<f64>(px));
}
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;
const f32 radius = plot_->style().device(kCursorHitRadiusLogical);
u64 best = kInvalid;
f32 best_d = 0.0f;
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
const Cursor& cursor = plot_->cursor(i);
// A readout is drawn but not offered to the pointer: skipping it here is what makes it
// undraggable, rather than fighting the drag a frame at a time.
if (!cursor.visible || !cursor.selectable) continue;
// One hit test for both orientations, because a cursor *is* one line across the axes:
// what the pointer has to be near is that line's own coordinate -- the column of a
// vertical marker, the row of a horizontal ruler -- measured with the same radius
// either way, which is what keeps hover and grab agreeing about a ruler exactly as
// they do about a marker. A point within reach of one of each resolves to the nearer,
// so neither orientation widens the other's target.
const bool horizontal = cursor.is_horizontal();
const f32 d = std::fabs(plot_->cursor_screen_coordinate(i) - (horizontal ? py : px));
if (d > radius) 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