Three findings reachable by ordinary use: * `Controller::axis_zoom()` divided by `Plot::ui_scale()` directly, though `Style::device()` documents a non-positive scale as host state that must be tolerated. With `ui_scale == 0` and a negative drag delta, `logical_dy` was `-inf`, the factor `+inf`, and the guard `!(factor > 0.0)` did not reject it: `zoom_x_axis`/`zoom_y_axis` computed `anchor + (bound - anchor) * inf`, and `set_y_range()` stored it verbatim and cleared `auto_scale` -- an axis left non-finite until an auto-fit. A negative scale silently inverted the zoom. A scale that is not positive and finite now means no zoom at all. * `draw_annotations()` stages its caption into the frame's single text batch, which `flush_text()` draws *after* `clear_clip()`, so a caption staged while the axes clip was engaged was rasterised with the viewport clip. An annotation anchored outside the visible range kept a caption with no cross, and in a `Figure` -- whose rows tile the figure -- that caption painted into the neighbouring row. A caption whose anchor is outside `axes` is now skipped with its cross, rather than splitting the frame's one text draw call (which would cost a draw call per row and mis-clip the labels staged outside `axes`). * `dump_frame_ppm()` was the one public entry point not `noexcept`, so a `std::bad_alloc` crossed the API boundary. Its buffers are now sized before anything is on disk and a failure is reported as `ErrorCode::OutOfMemory`, exactly as `TrueTypeFont::from_file()` reports it. Tests: `ui_scale` 0 and -1 leave the axis range finite and unchanged with `auto_scale` intact; an annotation anchored outside a bottom figure row's y range leaves no caption ink in the row above, while an in-range one still draws; a `static_assert` pins the dump's `noexcept`.
715 lines
34 KiB
C++
715 lines
34 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;
|
|
/// 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) {
|
|
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. Return before the event can mutate anything.
|
|
if (!std::isfinite(raw.dy)) return;
|
|
|
|
const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
|
|
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, 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);
|
|
}
|
|
}
|
|
// 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_key(const KeyEvent& e) {
|
|
if (!e.down) return; // every action below is press-only
|
|
|
|
const bool zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut;
|
|
const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper;
|
|
if (!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);
|
|
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);
|
|
}
|
|
} else {
|
|
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;
|
|
|
|
// Shift first, and only on the left: it is a modifier gesture of its own, and a
|
|
// selection box is what it means even when it starts over a handle.
|
|
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::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;
|
|
}
|
|
|
|
// --- gestures -------------------------------------------------------------
|
|
|
|
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)});
|
|
}
|
|
}
|
|
|
|
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 either side is a click: no zoom.
|
|
// 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 (sel.w < min_box || 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 (!(r > l) || !(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.
|
|
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)});
|
|
}
|
|
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);
|
|
if (!(factor > 0.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);
|
|
if (!cursor.visible) 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
|