Files
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

429 lines
24 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <plot/plot/region.hpp>
namespace plot {
class Plot;
}
namespace plot::input {
/// What a drag in progress is doing.
enum class DragMode {
None,
Pan, ///< translate every range (left or right drag)
BoxZoom, ///< a shift+left selection is open; the ranges change on release
MoveCursor, ///< a drag is moving the cursor marker it grabbed
MoveRegion, ///< a drag is translating a whole region, width preserved
ResizeRegionLeft, ///< a drag is moving the bound that was `x0` when it opened
ResizeRegionRight, ///< a drag is moving the bound that was `x1` when it opened
AxisZoomX, ///< middle drag inside the x label strip; zooms x only
AxisZoomY, ///< middle drag inside the y label strip; zooms y only
};
/// The grab zone of a region, named with the primitive it describes
/// (`plot/plot/region.hpp`, which this header imports it from): the input layer
/// reports it and the renderer colours by it -- through `Plot::hot_handles()`,
/// which carries the zone the pointer is on -- so both layers speak of one type
/// rather than of two that would have to be kept in step.
using plot::RegionGrab;
/// Turns host events into plot gestures, knowing nothing about Qt, GTK, GLFW or
/// ImGui -- Tasks 8-11 translate their native events into the value types above.
///
/// It is pure CPU and needs no GL context and no font: everything it reads comes
/// from `Plot`'s layout, ranges and transforms. The controller owns no viewport
/// of its own: set_viewport() forwards to `Plot::set_viewport` (and refreshes the
/// layout) so the two can never disagree, and every gesture runs through
/// `Plot`'s range setters, which clear `auto_scale` in one place.
///
/// Gesture table (grab-and-drag: the data follows the cursor):
/// | gesture | mode | effect |
/// |---|---|---|
/// | shift + left drag | `BoxZoom` | both ranges from the selection rect |
/// | ctrl + left drag | `BoxZoom` | x range only from the selection rect |
/// | alt + left drag | `BoxZoom` | y range only from the selection rect |
/// | left drag on a handle | `MoveCursor` / `MoveRegion` / `ResizeRegion*` | move the handle the press grabbed |
/// | left drag elsewhere | `Pan` | translate both ranges |
/// | right drag | `Pan` | same, for hosts that reserve left |
/// | middle drag on the x label strip | `AxisZoomX` | x-only zoom |
/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom |
/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer |
/// | wheel `dx` (no `dy`) | -- | pan x, 5% of the span per notch |
/// | `+` / `-` | -- | zoom about the plot centre |
/// | `f` | -- | auto-fit every axis |
/// | Left/Right | -- | pan every x axis, 10% of its span |
/// | Up/Down | -- | pan every y axis, 10% of its span |
/// | Escape | -- | cancel the active drag: the ranges captured at `begin_drag` are restored, a box zoom applies nothing |
/// | double click (same button, 0.35 s, 4 px, `PointerEvent::time_s`) | -- | re-fit every axis; the second press begins no drag |
///
/// The right button pans *and* grabs: both are buttons a host drives drags with, so
/// neither one is singled out for handles. Middle keeps its own role on the label
/// strips, and a middle drag anywhere else still does nothing.
///
/// Both axis-zoom drags read the vertical `dy` in pointer pixels with the
/// wheel's sign convention (dragging down zooms in), anchored at the pointer's
/// data coordinate on the axis they zoom. The drag has its own per-pixel
/// sensitivity -- a pixel is not a wheel notch -- of one decade per 200 **logical**
/// px, so a 200 px drag scales the span by 10 whatever the display's scale.
///
/// A drag that changes no range is harmless: a box-zoom selection under 4 px on
/// either side is a click, and leaves the ranges alone.
///
/// ## Direct manipulation (spec §5)
///
/// Cursors and regions are *grabbable*: a press on one enters a drag that moves it,
/// with no modifier, and a press anywhere else pans exactly as it always has -- so
/// the new gesture is a superset of the old one and every host that drives a drag
/// keeps panning. `Ctrl` and `Alt` no longer fall through to that press: they are
/// the modifier box zoom's masks now (see the gesture table -- Ctrl frames the x
/// range only, Alt the y range only, from the selection, over whatever was under
/// the press), which is why a *plain* press is the one that grabs a handle or pans.
///
/// One hit test runs for both, in this order, and that precedence is the whole rule:
///
/// 1. **Cursor markers first**, at the radius `hovered_cursor()` already uses, the
/// nearest one winning. A marker is drawn on top of everything and is hit-tested
/// the same way: the shaded band between two measurement cursors is grabbed by
/// dragging either of the cursors that bound it, because its edges *are* those
/// cursors -- no second path, no second tolerance. A *horizontal* ruler is the same
/// target turned a quarter turn: the distance measured is the pointer's to the
/// ruler's row rather than to a marker's column, and it is the same radius, so a
/// point within reach of one of each resolves to the nearer and neither orientation
/// widens the other's reach. The drag that follows writes only the coordinate the
/// grabbed cursor's orientation owns -- `set_cursor_x()` for a marker,
/// `set_cursor_y()` for a ruler -- and a dragged ruler is clamped to its own y
/// axis' range, because a pointer past the top or bottom of `axes` has no data
/// under it to follow (see `move_cursor()`). The writes are *free*, so the marker
/// tracks the pointer for the whole gesture rather than hopping between the samples
/// it would otherwise snap to; the cursor's own snap rule is applied once, on
/// release (`Plot::snap_cursor()`), exactly as a region's is.
/// 2. **Regions next**, resolved nearest-first against the geometry `Plot::layout()`
/// measured: a bound within the grab radius beats the body (so the bound of a
/// wide region is grabbable even though the pointer is inside the fill), the
/// nearer of the two bounds wins, and with no bound in range the topmost region
/// whose interior holds the pointer is the body. The radius is the one above, cut
/// back when a region is too narrow to hold a band at each end *and* an interior:
/// a region narrower than twice it would otherwise have every interior point
/// within a bound's reach, and could be resized but never moved.
/// 3. **Nothing hot**, and the press pans.
///
/// A region narrower than a few logical pixels is a click rather than an edge grab:
/// its two bounds are under the pointer together, so an edge would always win and
/// the region could never be moved again once collapsed. Such a region is
/// grabbed by its body instead, widened to that same minimum, and it stays movable --
/// and between that minimum and twice the grab radius, where the two bounds would
/// still cover every interior point, the edge bands are cut back until the middle is
/// left, so a region in that band of widths can be moved as well as resized.
/// The *body* drag translates both bounds by one delta -- the width is preserved
/// exactly -- while a bound drag moves one end and leaves the other where it was,
/// so a drag that crosses one bound over the other swaps them rather than inverting
/// the region (`Plot::set_region()` orders on assignment).
///
/// A region that asks to snap is put on the sample grid once, *on release*, not on
/// every motion: a body dragged a fraction of a sample apart would otherwise snap
/// both ends back to where they started and never move at all.
///
/// The hovered and grabbed handles are published to the plot
/// (`Plot::set_hot_handles()`) after every event that can move the geometry under the
/// pointer -- a pointer event, a wheel notch, a key stroke -- so the renderer draws them
/// highlighted without knowing what a pointer is; `hovered_cursor()`,
/// `grabbed_cursor()`, `hovered_region()`, `grabbed_region()` and `region_grab()` are
/// the same answer for a host that wants it directly -- a resize cursor, a tooltip,
/// a status line.
///
/// ## The crosshair (spec §15)
///
/// A pointer over the data area places the plot's crosshair at the data coordinates under
/// it (`Plot::set_crosshair()`), and a pointer that leaves `axes` clears it
/// (`Plot::clear_crosshair()`). That is a *hover*, not a gesture: no button is involved, and
/// a press, a wheel notch or a key stroke that moves the data under the pointer re-anchors
/// the crosshair to the same pointer position rather than clearing it -- it follows the
/// pointer, so anything the pointer did not move leaves it where the pointer is. The
/// per-signal values a readout shows are the plot's (`PlotLayout::crosshair`), not this
/// layer's: the controller says where the pointer is and nothing about what a crosshair
/// means.
///
/// ## Zoom and pan on a live x axis (spec §12)
///
/// An x axis armed with `XFollow::Live` keeps following through every gesture
/// below. Nothing here reads or writes the follow state, and the ranges these
/// gestures set are read by `Plot::set_x_range` as a span and an offset behind the
/// live edge rather than as absolute positions, so both halves of the requirement
/// fall out of the paths that already existed:
///
/// - the wheel *multiplies* the span, so notches compound and the zoom is
/// scale-free. Its anchor is the data coordinate under the pointer while the
/// pointer is inside `axes`; outside it there is no such coordinate, and on a
/// live axis the live edge is used instead (see `x_zoom_anchor()`), which leaves
/// the window ending where it ended. Either way the offset is adjusted to keep
/// the anchor still, and following stays armed;
/// - a pan shifts the offset by the drag's data delta and leaves the span alone, so
/// a user can walk back through the retained window and stops at the live edge
/// rather than past it.
///
/// An `Off` axis -- every axis by default -- behaves exactly as it always has.
///
/// ## Tolerances are logical, the coordinates they are compared against are not
///
/// The plot geometry the controller hit-tests in is device pixels (see
/// `PlotLayout`), and each adapter converts its pointer coordinates into the same
/// space before forwarding them. A tolerance written as a device constant would
/// therefore change its *physical* size with the display: a 7 px grab radius is
/// half the hand movement on a 2x panel, so the same cursor becomes harder to
/// catch there. Both tolerances -- the cursor grab radius and the box-zoom click
/// threshold -- are therefore logical constants, converted through
/// `Style::device()` at the scale of the plot the controller already holds: that is
/// the single place the logical -> device rule lives, and the plot is the one that
/// knows what its device pixel is worth, so the controller keeps no copy of the scale
/// for a host to keep in step.
///
/// The middle-drag axis zoom's per-pixel gain is logical for the same reason, by a
/// different route: it is stated in *logical* pixels (200 of them is one decade),
/// and the drag delta arrives in device pixels like every other coordinate here, so
/// `axis_zoom()` divides it by `Plot::ui_scale()` before applying the gain. Without
/// that the same physical drag would zoom twice as much at 2x. The wheel needs no
/// such conversion: a notch is already device-independent, and the wheel path is
/// left alone.
class Controller {
public:
explicit Controller(Plot& plot) noexcept : plot_(&plot) {}
/// Sets the plot's viewport (the controller keeps none of its own) and
/// refreshes the layout, so gesture geometry and the host agree.
void set_viewport(Rect px);
/// Events are sanitised before they are acted on: a non-finite coordinate or
/// delta -- a host's "no pointer" sentinel (ImGui reports `-FLT_MAX`), a NaN
/// from a degenerate mapping, an infinity from a lost device -- becomes the last
/// known position and no movement. Garbage must not reach the ranges: a gesture
/// that sets a range also clears auto scale, so a range driven to 1e35 is one the
/// user can neither pan nor zoom back. The buttons in the same event are still
/// acted on, so a drag whose release arrives with a bad position still ends.
void on_pointer(const PointerEvent& e);
/// Same, and a wheel amount that is not a number is *refused* rather than
/// zeroed: a zero-notch zoom changes no range but still goes through the range
/// setters, which clear auto scale -- so a sanitised amount would quietly disarm
/// auto-fit. An unusable amount leaves the plot exactly as it was; an unusable
/// *position* only loses its anchor, falling back to the last known one. The
/// same refusal covers `dx`, which pans when `dy` is zero.
void on_wheel(const WheelEvent& e);
void on_key(const KeyEvent& e);
/// A pinch frame from a touch host (see `GestureEvent`): `scale` zooms about
/// `(x, y)` exactly like a wheel notch pair would -- the same setters, the
/// same live-axis behaviour, the same guards -- and `(dx, dy)` pans by the
/// focal point's movement. A frame with `scale == 1` and no movement is
/// refused outright, for the same reason a zero-notch wheel is: the setters
/// would clear `auto_scale` for no visible change. An amount that is not a
/// number is refused rather than sanitised.
void on_gesture(const GestureEvent& e);
DragMode drag_mode() const noexcept { return mode_; }
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
Rect selection_rect() const noexcept { return selection_; }
/// The rectangle a host should *draw* for the open box zoom: the swept area, widened to the full
/// axes along whichever axis the modifier's mask will not zoom. Ctrl frames the x range only, so its
/// preview is the band that spans the plot -- which is what the release will zoom -- where the area
/// a purely horizontal drag sweeps is a sliver a few pixels tall. Shift (and any other combo) frames
/// both, and gets the swept box itself.
///
/// Meaningful while `BoxZoom`, like `selection_rect()`; the zero rect otherwise.
///
/// Defined in the implementation: widening the preview to the axes needs the plot's layout, and
/// `Plot` is only forward-declared here.
Rect selection_preview() const noexcept;
/// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
/// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area
/// or no cursor is within a few pixels.
u64 hovered_cursor() const;
/// The cursor a drag is moving, or `kInvalid` when no cursor drag is open. The two
/// are the same answer the plot publishes for the renderer (see HotHandles), for a
/// host that wants it directly.
u64 grabbed_cursor() const noexcept { return drag_cursor_; }
/// The region a drag is moving or resizing, or `kInvalid` when no region drag is
/// open.
u64 grabbed_region() const noexcept { return drag_region_; }
/// The region the pointer is on, or `kInvalid`. `region_grab()` says which zone.
u64 hovered_region() const;
/// Which zone of `region` the pointer is on: the bound nearest within the grab
/// radius (`Left`/`Right`) -- cut back for a narrow region, see `zone_hit()` -- else
/// `Body` when the pointer is inside the interval --
/// widened to the minimum a collapsed region keeps -- and `None` when it is on
/// neither, when the pointer is outside the plot area, or when no pointer has been
/// seen yet. What a host sets its resize cursor from; an id that names no region
/// reports `None`.
RegionGrab region_grab(u32 region) const;
private:
void begin_drag(const PointerEvent& e);
void update_drag(const PointerEvent& e);
void end_drag(const PointerEvent& e);
/// The ranges (and, for x, the live offsets and auto-scale flags) every axis held
/// when a drag began -- what Escape restores. Captured at `begin_drag`, before the
/// gesture can move anything.
void capture_ranges();
/// Escape: the active drag is cancelled -- the ranges captured at `begin_drag`
/// are restored, a box zoom applies nothing, and the grab state is cleared. The
/// restore goes through the plot's own setters, so a live axis is restored as the
/// span and offset it held (the setters' native reading) rather than as positions
/// that the next frame's follow would overwrite anyway.
void cancel_drag();
/// Re-fits every axis (`auto_fit_x()` plus one `auto_fit_y()` per y axis), which
/// re-arms auto scale with the fit: the double-click reset.
void fit_all();
/// True when `e` is the second press of a double-click: the same button, within
/// 0.35 s (both timestamps > 0 -- a host that leaves `time_s` at 0 never produces
/// one) and within 4 logical px of the recorded press. Records `e` as the press a
/// third one would be measured against either way; a detected double-click clears
/// the record, so a third press starts a fresh gesture rather than re-firing.
bool double_click(const PointerEvent& e);
/// Pans every x axis by `fraction` of its own span, in axis space (log-aware:
/// a log axis translates multiplicatively). A positive fraction scrolls toward
/// larger x. The wheel's `dx` path and the Left/Right arrows share it.
void shift_x_axes(f64 fraction);
/// Same for every y axis: a positive fraction scrolls toward larger y (the Up
/// arrow). Screen y is inverted, so this is *not* the sign `pan()` derives from a
/// drag's `dy` -- an arrow says where to look, a drag says where the data goes.
void shift_y_axes(f64 fraction);
void pan(const PointerEvent& e);
void box_zoom_update(const PointerEvent& e);
void apply_box_zoom();
void move_cursor(const PointerEvent& e);
void move_region(const PointerEvent& e);
void resize_region(const PointerEvent& e);
void axis_zoom(const PointerEvent& e);
/// The nearest visible cursor within the hit radius of (px, py), or kInvalid.
/// kInvalid when (px, py) is outside the plot axes.
u64 cursor_at(f32 px, f32 py) const;
/// The region and zone a pointer position is on. Both fields are "nothing" when
/// the pointer is outside `axes` or no region is hot; `zone` is `Body` for an
/// interior hit and never `None` for a region that is named.
struct RegionHot {
u64 region = kInvalid;
RegionGrab zone = RegionGrab::None;
};
/// One region's zone under a pointer position, with the distance to the bound the
/// zone names: 0 for a body, and meaningless for `None`. The one place the region
/// hit test lives -- `region_hot()` compares what it returns and `region_grab()`
/// reports it -- so the nearest-first rule is written once.
struct ZoneHit {
RegionGrab zone = RegionGrab::None;
f32 distance = 0.0f;
};
/// The band a bound answers to is the controller's grab radius, cut back so that a
/// narrow region keeps an interior a body drag can reach (see the class comment):
/// `min(radius, (width - minimum interior) / 2)`, and no band at all when the two
/// bounds are closer together than the minimum width that separates them.
ZoneHit zone_hit(const RegionLayout& region, f32 px, f32 py) const;
/// Resolves that hit against the geometry `Plot::layout()` measured -- the same
/// rects the renderer paints, so what is drawn and what can be grabbed cannot
/// drift apart -- with the precedence the class comment states: nearest grabbable
/// bound first, topmost body after it.
RegionHot region_hot(f32 px, f32 py) const;
/// Opens the drag a press asks for: the cursor hit first, then the region zones.
/// False when the press is on no handle at all, i.e. when the caller should pan.
/// Records what the drag needs to keep hold of -- the bound an edge resize must
/// leave alone, the bounds and the grabbed data x a body move needs to preserve
/// the width -- so a changed pointer motion never accumulates error.
bool begin_handle_drag(const PointerEvent& e);
/// Places the plot's crosshair under the pointer, or clears it when the pointer is not
/// over the data area (spec §15). Called after every event that can move the pointer *or*
/// the data under it -- a pointer event, a wheel notch, a key stroke, a viewport change --
/// so the crosshair is where the pointer is and reads the data the frame will draw, and
/// never at a coordinate a zoom has already moved out from under it.
///
/// A press does not clear it: the crosshair follows the pointer, and a pointer that is
/// still over the axes is still pointing at whatever gesture just started. The pointer is
/// the only thing that clears it, by leaving `axes`.
void update_crosshair(f32 px, f32 py);
/// Publishes what the pointer is on to the plot, for the renderer to highlight
/// (see HotHandles). Called after every event that can move the geometry under the
/// pointer -- a pointer event, a wheel notch, a key stroke -- so hover and grab are
/// always the answer the hit test gives for the geometry of this frame rather than
/// the geometry of the last time the pointer moved.
void publish_hot();
/// The data x a zoom of `axis` holds fixed, for a gesture at (px, py). The
/// coordinate under the pointer while the pointer is inside `axes`, which is what
/// makes a zoom feel anchored to the data being pointed at; outside `axes` there
/// is no such coordinate, and a live axis (`XFollow::Live`) uses its live edge
/// instead, so a zoom made from the label strip or off the plot zooms the window
/// about the newest sample and leaves it ending where it ended.
f64 x_zoom_anchor(u32 axis, f32 px, f32 py) const;
/// Zoom one axis about `anchor` (a data coordinate) by `factor`, recomputing
/// the range so the anchor keeps its screen position. Leaves `auto_scale` false;
/// under `XFollow::Live` the range is read by `Plot::set_x_range` as a span and an
/// offset behind the live edge, which is what keeps following armed.
void zoom_x_axis(u32 axis, f64 anchor, f64 factor);
void zoom_y_axis(u32 axis, f64 anchor, f64 factor);
Plot* plot_ = nullptr;
DragMode mode_ = DragMode::None;
Rect selection_{};
f32 origin_x_ = 0.0f;
f32 origin_y_ = 0.0f;
/// The box-zoom mask, resolved once at `begin_drag` from the modifier table
/// (Shift = both, Ctrl = x-only, Alt = y-only, any other combo = both): which
/// axes the selection's release frames. `apply_box_zoom()` reads these for the
/// per-axis application *and* for the click threshold -- a masked-out dimension
/// of a thin selection does not make the gesture a click.
bool box_zoom_x_ = true;
bool box_zoom_y_ = true;
/// The range state a drag began with, per axis in order: what `cancel_drag()`
/// restores. Empty while no drag is open.
struct SavedX {
Range range{};
f64 offset = 0.0;
bool auto_scale = false;
};
struct SavedY {
Range range{};
bool auto_scale = false;
};
std::vector<SavedX> saved_x_;
std::vector<SavedY> saved_y_;
/// The last press seen, for the double-click reset: the button, the host
/// timestamp, and the position it was made at. `has_click_` is false until the
/// first press arrives.
bool has_click_ = false;
int click_button_ = -1;
f64 click_time_s_ = 0.0;
f32 click_x_ = 0.0f;
f32 click_y_ = 0.0f;
u64 drag_cursor_ = kInvalid;
/// The region drag's state. `drag_region_` is the region and `drag_zone_` the zone
/// the press resolved, kept for the whole gesture so the highlight a grab started
/// follows the grab rather than the pointer. The three numbers are what keeps a
/// motion from accumulating error: an edge resize holds `drag_fixed_x_`, the bound
/// it must leave alone, and a body move holds the bounds as they were at the press
/// (`drag_x0_`/`drag_x1_`) plus the data x the press grabbed (`drag_grab_x_`), so
/// each motion is a fresh function of the pointer rather than a step from the last
/// one.
u64 drag_region_ = kInvalid;
RegionGrab drag_zone_ = RegionGrab::None;
f64 drag_fixed_x_ = 0.0;
f64 drag_grab_x_ = 0.0;
f64 drag_x0_ = 0.0;
f64 drag_x1_ = 0.0;
bool has_pointer_ = false;
f32 pointer_x_ = 0.0f;
f32 pointer_y_ = 0.0f;
};
} // namespace plot::input