// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include #include #include 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 saved_x_; std::vector 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