The repository had no licence at all, so every source was all-rights-reserved by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino Ferrari) and a two-line SPDX header to each of the 113 first-party sources -- 48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the grant travels with each file rather than relying on the root document alone. Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely vendored code is untouched: third_party/stb_truetype.h carries no header of ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py emit() now writes the two lines into its header comment and the file was regenerated -- gen_atlas.py --check passes and the regeneration adds only those comment lines to the previously committed file. README's provenance section now states MIT and links LICENSE. No code, test or public API change.
341 lines
19 KiB
C++
341 lines
19 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#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 |
|
|
/// | 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 |
|
|
/// | `+` / `-` | -- | zoom about the plot centre |
|
|
/// | `f` | -- | auto-fit every axis |
|
|
///
|
|
/// 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` is no longer needed for a cursor move and does not change
|
|
/// what a press on one does; it only means the same thing it always did (a press
|
|
/// away from a handle pans, with or without it).
|
|
///
|
|
/// 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.
|
|
void on_wheel(const WheelEvent& e);
|
|
void on_key(const KeyEvent& e);
|
|
|
|
DragMode drag_mode() const noexcept { return mode_; }
|
|
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
|
|
Rect selection_rect() const noexcept { return selection_; }
|
|
/// The cursor under the pointer -- 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);
|
|
|
|
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;
|
|
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
|