#pragma once #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, ///< ctrl+left drag is moving `hovered_cursor()` AxisZoomX, ///< middle drag inside the x label strip; zooms x only AxisZoomY, ///< middle drag inside the y label strip; zooms y only }; /// 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 | /// |---|---|---| /// | left drag | `Pan` | translate both ranges | /// | right drag | `Pan` | same, for hosts that reserve left | /// | shift + left drag | `BoxZoom` | both ranges from the selection rect | /// | ctrl + left drag near a cursor | `MoveCursor` | move that cursor | /// | 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 | /// /// 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. /// /// ## 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; 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 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 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; bool has_pointer_ = false; f32 pointer_x_ = 0.0f; f32 pointer_y_ = 0.0f; }; } // namespace plot::input