feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s

Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
This commit is contained in:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5905 -688

No files matched your search

+111
View File
@@ -229,4 +229,115 @@ inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept {
return -dy;
}
/// The keyvals this adapter maps, as GDK's own numbers, mirrored here so the
/// mapping below -- and a test that drives it -- needs no GTK include directory.
/// `gtk_plot_area.cpp` static-asserts each against GDK's own macro.
///
/// These are the *function* keyvals (the `0xFF00` page), fixed by X11's keysym
/// convention GDK inherits; a keypad arrow (a keypad key with Num Lock off) is a
/// different keyval and deliberately unmapped, exactly as the GLFW adapter maps
/// the main-row arrows and not `GLFW_KEY_KP_4`.
constexpr u32 kGdkKeyEscape = 0xFF1Bu;
constexpr u32 kGdkKeyLeft = 0xFF51u;
constexpr u32 kGdkKeyUp = 0xFF52u;
constexpr u32 kGdkKeyRight = 0xFF53u;
constexpr u32 kGdkKeyDown = 0xFF54u;
/// One key-press translation, by keyval first and Unicode fallback second.
///
/// GTK names a key by its keyval, which is layout- and level-dependent: `+` is
/// usually shift+`=` and arrives as a different keyval, but both decode to the
/// same Unicode code point, which is why the printable keys go through
/// `gdk_keyval_to_unicode()`. The function keys decode to no code point at all,
/// so they are matched on the keyval itself, before that fallback. `0` means "no
/// plot key": the caller returns the event to GTK unhandled.
///
/// The keypad's plus and minus decode to the same code points as the main row's,
/// so they come along for free -- the same set the GLFW adapter maps with
/// `GLFW_KEY_KP_ADD`/`GLFW_KEY_KP_SUBTRACT`.
inline int key_from(u32 keyval, u32 unicode) noexcept {
switch (keyval) {
case kGdkKeyEscape: return input::kKeyEscape;
case kGdkKeyLeft: return input::kKeyLeft;
case kGdkKeyRight: return input::kKeyRight;
case kGdkKeyUp: return input::kKeyUp;
case kGdkKeyDown: return input::kKeyDown;
default: break;
}
switch (unicode) {
case static_cast<u32>(input::kKeyZoomIn):
case static_cast<u32>('='): return input::kKeyZoomIn;
case static_cast<u32>(input::kKeyZoomOut):
case static_cast<u32>('_'): return input::kKeyZoomOut;
case static_cast<u32>(input::kKeyFit):
case static_cast<u32>(input::kKeyFitUpper): return input::kKeyFit;
default: return 0;
}
}
/// The state one `GtkGestureZoom` is folded into `input::GestureEvent`s from.
///
/// `GtkGestureZoom` reports a **cumulative** scale (`gtk_gesture_zoom_get_scale_delta()`
/// and the `scale-changed` argument are the zoom since the gesture began, 1.0 at
/// the start) and knows the focal point only as the bounding box of the pointer
/// the gesture holds (`gtk_gesture_bounding_box_center()`), also cumulative in the
/// sense that it is where the fingers are *now*. The controller wants the
/// increments -- `GestureEvent::scale` is this frame's zoom factor about the focal
/// point, 1.0 = no change, and `(dx, dy)` the focal point's movement since the
/// previous gesture event -- so the area owns one of these and folds each
/// `scale-changed` exactly like the Qt adapter folds its pinch (`PinchTracker`).
///
/// The focal point is cached in **logical** widget pixels, like `DragAnchor` above,
/// for the same reason: the mapping's factor is re-derived on every `::resize`,
/// and a cache in device pixels would compare two points taken at different
/// factors. The difference of two logical points, scaled by the factor in force
/// now, is the movement the fingers actually made.
///
/// A plain value with no toolkit dependency, like every other type here: the area
/// owns one, and the tests drive it directly.
struct ZoomAnchor {
bool active = false; ///< true between the gesture's begin and its end
f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded
f32 x = 0.0f; ///< last focal point, logical widget pixels
f32 y = 0.0f;
/// Starts a gesture at its first cumulative `scale` and logical focal point.
/// Records only: a zoom that just began has zoomed and moved nothing yet.
void begin(f32 scale, Vec2 logical_center) noexcept {
active = true;
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
}
/// Folds one `scale-changed` into an incremental `GestureEvent`, in device
/// pixels at `mapping`: the zoom about the focal point, and the focal point's
/// movement since the previous update. A no-op before `begin()`, so a
/// `scale-changed` that arrives without a begin -- a host that missed the start
/// -- zooms nothing rather than zooming from a made-up anchor.
input::GestureEvent update(f32 scale, Vec2 logical_center,
const DeviceMapping& mapping) noexcept {
input::GestureEvent g;
if (!active) return g;
// This frame's zoom: the cumulative ratio over the one last folded. The first
// update after begin() divides the scale by itself -- 1.0, no zoom, as it
// should be when the fingers have not moved apart yet.
g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f;
const Vec2 device = mapping.to_device(logical_center.x, logical_center.y);
g.x = device.x;
g.y = device.y;
const Vec2 moved{logical_center.x - x, logical_center.y - y};
const Vec2 device_moved = mapping.to_device(moved.x, moved.y);
g.dx = device_moved.x;
g.dy = device_moved.y;
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
return g;
}
/// Ends the gesture: a straggler update after this zooms nothing.
void end() noexcept { active = false; }
};
} // namespace plot::gtk4
+86 -23
View File
@@ -31,6 +31,11 @@ static_assert((1u << 2) == GDK_CONTROL_MASK, "mods_from's control bit must match
static_assert((1u << 3) == GDK_ALT_MASK, "mods_from's alt bit must match GDK's");
static_assert((1u << 26) == GDK_SUPER_MASK, "mods_from's super bit must match GDK's");
static_assert((1u << 28) == GDK_META_MASK, "mods_from's meta bit must match GDK's");
static_assert(plot::gtk4::kGdkKeyEscape == GDK_KEY_Escape, "the mirrored Escape keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyLeft == GDK_KEY_Left, "the mirrored Left keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyUp == GDK_KEY_Up, "the mirrored Up keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyRight == GDK_KEY_Right, "the mirrored Right keyval must match GDK's");
static_assert(plot::gtk4::kGdkKeyDown == GDK_KEY_Down, "the mirrored Down keyval must match GDK's");
namespace plot::gtk4 {
namespace {
@@ -158,6 +163,18 @@ PlotArea* PlotArea::create(Plot& plot) noexcept {
g_signal_connect(drag, "drag-end", G_CALLBACK(&PlotArea::on_drag_end), self);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(drag));
// The two-finger pinch: `scale-changed` carries the cumulative zoom since the
// gesture began, and the gesture's bounding-box centre is the focal point -- the
// pair `ZoomAnchor` folds into the incremental `GestureEvent` the controller
// takes (zoom about the focal point, focal movement as the pan). No button:
// unlike the click/drag pair this gesture is touch-only by nature, and a mouse
// never produces one.
GtkGesture* zoom = gtk_gesture_zoom_new();
g_signal_connect(zoom, "begin", G_CALLBACK(&PlotArea::on_zoom_begin), self);
g_signal_connect(zoom, "scale-changed", G_CALLBACK(&PlotArea::on_zoom_scale_changed), self);
g_signal_connect(zoom, "end", G_CALLBACK(&PlotArea::on_zoom_end), self);
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(zoom));
// Vertical only, as the plan asks: a purely horizontal scroll is not a zoom, and
// because this controller never sees one it cannot be mistaken for one. GTK
// reports the unit per event, so a discrete wheel arrives in notches and a
@@ -279,6 +296,7 @@ void PlotArea::unrealize() noexcept {
renderer_.reset();
press_open_ = false;
drag_.have = false;
zoom_.end();
}
void PlotArea::render() noexcept {
@@ -364,7 +382,8 @@ void PlotArea::on_resize(GtkGLArea*, int width, int height, void* self) noexcept
// --- pointer input --------------------------------------------------------
void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y) noexcept {
void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
u32 time_ms) noexcept {
if (press_open_) return; // one press per button-down sequence: see drag_begin
press_open_ = true;
press_button_ = button;
@@ -383,11 +402,12 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y)
e.mods = mods;
e.down = true;
e.pressed = true;
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
controller_.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
}
void PlotArea::pointer_release(Vec2 position) noexcept {
void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept {
if (!press_open_) return;
press_open_ = false;
@@ -400,6 +420,7 @@ void PlotArea::pointer_release(Vec2 position) noexcept {
e.mods = press_mods_;
e.down = false;
e.released = true;
e.time_s = pointer_time_s(time_ms);
controller_.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
}
@@ -412,7 +433,8 @@ void PlotArea::on_click_pressed(GtkGestureClick* gesture, int, double x, double
if (!button) return; // a button the controller has no gesture for
// The modifiers come off the *event*, not the gesture: GTK reports the state as
// it was before the press, which is what the controller's gesture table reads.
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y);
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y,
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double y,
@@ -421,11 +443,11 @@ void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double
auto* area = static_cast<PlotArea*>(self);
// The first release closes the gesture; a drag's own `drag-end` may already have
// done it (GTK emits both, `drag-end` first), and this is then a no-op.
area->pointer_release(area->to_device(x, y));
area->pointer_release(area->to_device(x, y),
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self);
// A sequence can be cancelled rather than released -- GTK stops the click
// gesture as soon as the drag gesture claims the same sequence, which happens on
@@ -434,7 +456,11 @@ void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
// drag in progress (an exotic cancellation by another widget or a device) closes
// the press, which it has to, or the controller would stay in whatever mode the
// press began.
if (area->press_open_ && !area->drag_.have) area->pointer_release(area->pointer_);
if (area->press_open_ && !area->drag_.have) {
area->pointer_release(
area->pointer_,
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
}
void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
@@ -453,7 +479,8 @@ void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double sta
const std::optional<input::Button> button =
button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture)));
if (button) {
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y);
area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y,
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
}
@@ -482,13 +509,14 @@ void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double o
e.button = area->press_button_;
e.mods = area->press_mods_;
e.down = true;
e.time_s = area->pointer_time_s(
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
area->controller_.on_pointer(e);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
}
void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y,
void* self) noexcept {
(void)gesture;
auto* area = static_cast<PlotArea*>(self);
// The release carries the authoritative end of the gesture: the controller reads
// its position to finish a box zoom's selection, so this is the press point plus
@@ -496,7 +524,46 @@ void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offs
// and not the last update's position.
const Vec2 position = area->drag_.end(offset_x, offset_y, area->mapping_);
area->pointer_ = position;
area->pointer_release(position);
area->pointer_release(
position, gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
}
// --- the pinch ------------------------------------------------------------
void PlotArea::on_zoom_begin(GtkGestureZoom* gesture, GdkEventSequence*, void* self) noexcept {
auto* area = static_cast<PlotArea*>(self);
// The focal point is the centre of the bounding box around the pointers the
// gesture holds, in logical widget pixels -- the same space every other
// gesture coordinate here arrives in, and what `to_device()` maps.
double cx = 0.0;
double cy = 0.0;
gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &cx, &cy);
area->zoom_.begin(static_cast<f32>(gtk_gesture_zoom_get_scale_delta(gesture)),
Vec2{static_cast<f32>(cx), static_cast<f32>(cy)});
}
void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
void* self) noexcept {
auto* area = static_cast<PlotArea*>(self);
double cx = 0.0;
double cy = 0.0;
gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &cx, &cy);
// The `scale` argument and `gtk_gesture_zoom_get_scale_delta()` are the same
// cumulative number -- the zoom since `::begin` -- so the folded event is this
// *frame's* zoom factor about the fingers' centre, plus the centre's movement,
// which is the two-finger pan. An update without a begin folds to a no-op (see
// `ZoomAnchor::update`), so a gesture whose start the host never saw zooms
// nothing rather than zooming from a made-up anchor.
const input::GestureEvent g = area->zoom_.update(
static_cast<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(cy)}, area->mapping_);
area->controller_.on_gesture(g);
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
}
void PlotArea::on_zoom_end(GtkGestureZoom*, GdkEventSequence*, void* self) noexcept {
// No trailing event: the last `scale-changed` carried the gesture's final zoom,
// and `end` only closes the folding so a straggler update zooms nothing.
static_cast<PlotArea*>(self)->zoom_.end();
}
int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
@@ -550,6 +617,8 @@ void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, v
e.dx = 0.0f;
e.dy = 0.0f;
e.down = false;
e.time_s = PlotArea::pointer_time_s(
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion)));
area->controller_.on_pointer(e);
}
@@ -560,20 +629,14 @@ int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval,
(void)keycode;
(void)keys;
auto* area = static_cast<PlotArea*>(self);
// The keys are named by their *symbol*: GTK reports a layout's keyval, and the
// keypad's plus and minus are the same symbol as the main row's. `'='` is mapped
// with `'+'` so both spellings of "zoom in" work (on most layouts `+` *is*
// shift+`=`), the pair the GLFW and Qt adapters map.
int key = 0;
switch (gdk_keyval_to_unicode(keyval)) {
case static_cast<guint32>(input::kKeyZoomIn):
case static_cast<guint32>('='): key = input::kKeyZoomIn; break;
case static_cast<guint32>(input::kKeyZoomOut):
case static_cast<guint32>('_'): key = input::kKeyZoomOut; break;
case static_cast<guint32>(input::kKeyFit):
case static_cast<guint32>(input::kKeyFitUpper): key = input::kKeyFit; break;
default: return FALSE; // not a plot key: GTK and the window keep it
}
// The whole translation lives in gtk_input.hpp (see `key_from`): the function
// keys -- arrows, Escape -- are matched on the *keyval*, which is layout- and
// level-independent for them, and the printable ones on the Unicode they decode
// to, which is what makes `+` and shift+`=` (usually the same physical key) both
// zoom in, and the keypad's plus and minus come along for free. `0` is "no plot
// key": GTK and the window keep it.
const int key = key_from(static_cast<u32>(keyval), static_cast<u32>(gdk_keyval_to_unicode(keyval)));
if (key == 0) return FALSE;
input::KeyEvent e;
e.key = key;
+28 -3
View File
@@ -32,10 +32,12 @@ typedef struct _GtkGLArea GtkGLArea;
typedef struct _GtkGesture GtkGesture;
typedef struct _GtkGestureClick GtkGestureClick;
typedef struct _GtkGestureDrag GtkGestureDrag;
typedef struct _GtkGestureZoom GtkGestureZoom;
typedef struct _GtkEventControllerScroll GtkEventControllerScroll;
typedef struct _GtkEventControllerKey GtkEventControllerKey;
typedef struct _GtkEventControllerMotion GtkEventControllerMotion;
typedef struct _GdkGLContext GdkGLContext;
typedef struct _GdkEventSequence GdkEventSequence;
namespace plot::gtk4 {
@@ -134,6 +136,10 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
/// release, with the button and the modifier state, exactly as the Qt
/// widget's `mousePressEvent`/`mouseReleaseEvent` do. It is also what begins
/// a cursor move: a ctrl+press on a cursor opens `DragMode::MoveCursor` there.
/// * `GtkGestureZoom` supplies the two-finger pinch: each `scale-changed` is
/// folded by `ZoomAnchor` (`gtk_input.hpp`) into one `GestureEvent` -- this
/// frame's zoom about the fingers' centre, and the centre's movement, which is
/// the two-finger pan a trackpad produces alongside it.
/// * `GtkEventControllerMotion` supplies hover (the cursor hit test reads the
/// last position even with no button down). Its positions are forwarded with
/// `down == false` on purpose: a motion event that claimed a button was down
@@ -271,9 +277,16 @@ class PlotArea {
void refresh_viewport() noexcept;
/// One pointer event, from whichever gesture saw it first. Owns the
/// press/release bookkeeping the two gestures share.
void pointer_press(input::Button button, u32 mods, double x, double y) noexcept;
void pointer_release(Vec2 position) noexcept;
/// press/release bookkeeping the two gestures share. `time_ms` is the event's
/// own GDK stamp, in milliseconds, forwarded as the `PointerEvent::time_s` the
/// double-click detector differences (see `pointer_time_s()`).
void pointer_press(input::Button button, u32 mods, double x, double y,
u32 time_ms) noexcept;
void pointer_release(Vec2 position, u32 time_ms) noexcept;
/// The gesture event's stamp, in the seconds `PointerEvent::time_s` speaks: GDK
/// counts milliseconds since an arbitrary epoch, and only differences are read,
/// so the epoch does not matter -- the unit does.
static f64 pointer_time_s(u32 time_ms) noexcept { return static_cast<f64>(time_ms) * 1.0e-3; }
/// Translates the gesture-reported position/delta in *logical* widget pixels
/// into device pixels. The one place that mapping exists.
Vec2 to_device(double x, double y) const noexcept;
@@ -289,6 +302,15 @@ class PlotArea {
static void on_click_released(GtkGestureClick* gesture, int n_press, double x, double y,
void* self) noexcept;
static void on_click_stopped(GtkGestureClick* gesture, void* self) noexcept;
/// The pinch: `begin` anchors the folding, `scale-changed` folds one cumulative
/// scale into an incremental `GestureEvent`, and `end` closes it. `begin`/`end`
/// carry the sequence, which a one-active-pinch adapter never needs.
static void on_zoom_begin(GtkGestureZoom* gesture, GdkEventSequence* sequence,
void* self) noexcept;
static void on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
void* self) noexcept;
static void on_zoom_end(GtkGestureZoom* gesture, GdkEventSequence* sequence,
void* self) noexcept;
static void on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
void* self) noexcept;
static void on_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y,
@@ -324,6 +346,9 @@ class PlotArea {
/// cached rather than re-read per event, so a `drag-update` reports the movement
/// since the previous one, and `have` is whether a drag is recognised.
DragAnchor drag_;
/// The pinch gesture's folding state (see `ZoomAnchor`): one per area, alive
/// between `::begin` and `::end` of the one zoom the gesture recognises.
ZoomAnchor zoom_;
/// The last pointer position seen, in device pixels: the wheel's anchor when a
/// scroll event carries no position of its own.
Vec2 pointer_{};