304 lines
17 KiB
C++
304 lines
17 KiB
C++
#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.
|
|
/// 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.
|
|
///
|
|
/// ## 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 5 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, 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);
|
|
/// 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
|