// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include #include #include #include #include #include #include namespace plot::input { namespace { /// Zoom step for one wheel notch (dy == 1). A notch changes the span by /// `exp(-0.15)` (~14%), a step that feels like one detent on a normal mouse. constexpr f64 kZoomPerNotch = 0.15; /// A keyboard zoom press is one wheel notch. constexpr f64 kKeyZoomNotches = 1.0; /// Zoom per pixel of a middle-drag along an axis strip. A pointer drag reports /// pixels, a different unit from a wheel notch, so it must not reuse /// kZoomPerNotch: 200 px of drag is one decade, exp(-200 * ln10/200) == 0.1. The /// pixels are **logical** ones -- a distance the hand moves, like the tolerances /// below -- while the deltas that arrive are device pixels, so `axis_zoom()` /// converts through the plot's scale before applying this. constexpr f64 kZoomPerPixel = std::numbers::ln10 / 200.0; /// How close the pointer must be to a cursor to grab it, in **logical** pixels: the /// listener's aim is a physical distance, so it is converted to the device-pixel /// radius the comparison needs through `Style::device()` at the plot's own scale /// (see `cursor_at()`). An unscaled constant would make grabbing physically harder /// -- half as forgiving -- on a 2x display, which is the defect the scale exists to /// prevent. Comfortably wider than a marker's 1 px stroke, so a press lands on the /// handle the hover highlight promised: `cursor_at()` is the *one* value both the /// hover and the grab read, and a region's edge bands are cut back from this same /// number, so the affordance the host sees and the gesture it gets agree. constexpr f32 kCursorHitRadiusLogical = 7.0f; /// A box-zoom selection narrower than this on either side is a click. Logical for /// the same reason: the gesture is the same hand movement on any display. constexpr f32 kMinBoxLogical = 4.0f; /// Share of an axis' span one horizontal wheel notch (`dx`) pans, in axis space: /// a notch is a small, deliberate step, a twentieth of the view. constexpr f64 kPanPerNotch = 0.05; /// Share of an axis' span one arrow-key press pans, in axis space -- twice the /// wheel's step, because a key press is a coarser statement than a detent. A held /// key repeats (the adapters' shared policy), so a hold pans continuously. constexpr f64 kPanPerPress = 0.10; /// The double-click window: two presses of the same button within this many /// seconds -- and within `kDoubleClickPxLogical` of each other -- are a double-click /// reset, not two gestures. Measured on `PointerEvent::time_s`, the host's clock. constexpr f64 kDoubleClickSeconds = 0.35; /// How close together the two presses of a double-click must land, in **logical** /// pixels: the same hand-distance rule as the other tolerances here, converted /// through `Style::device()` at the plot's scale. constexpr f32 kDoubleClickPxLogical = 4.0f; /// The narrowest *interior* a region's body keeps, in **logical** pixels: the edge bands are /// cut back until this much of the middle is left, so a region too narrow for the full grab /// radius on each side is still something a pointer can take hold of and *move*. Without it /// every interior point of a region narrower than twice the radius (14 logical px) lies /// within a bound's reach, and the bound branch would always win -- such a region could be /// resized but never moved. Half of it either side of the midpoint, which is what makes the /// middle of even the narrowest region with grabbable bounds (3 logical px) reachable. constexpr f32 kMinRegionBodyLogical = 2.0f; /// The narrowest region whose bounds are grabbable, in **logical** pixels. Closer /// together than this, the two bounds are one target the pointer cannot separate: /// an edge would win the hit test every time and the region could never be moved /// again, so a *narrower* region is grabbed by its body -- widened to this same /// minimum around its bounds, which keeps a collapsed region something a pointer can /// still take hold of rather than a line. Logical like the two tolerances above, and /// converted through the same one call. constexpr f32 kMinRegionEdgeGrabLogical = 3.0f; /// An axis endpoint in transform space: log10 when the axis is logarithmic, the /// value itself otherwise. Pan and zoom arithmetic runs in this space so a log /// axis translates and scales multiplicatively, exactly as its transform maps. f64 to_space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; } f64 from_space(f64 v, bool log) noexcept { return log ? std::pow(10.0, v) : v; } /// Zoom factor for a number of wheel notches: positive notches (scroll up, `+` /// key) zoom in, so the span shrinks by `exp(-notches * kZoomPerNotch)`. f64 zoom_factor(f64 notches) noexcept { return std::exp(-notches * kZoomPerNotch); } /// True when a coordinate is one arithmetic can work with. Every event here comes /// from a host, and a host can hand over anything: a toolkit's "no pointer" /// sentinel, a NaN from a degenerate mapping of its own, an infinity from a lost /// device. A non-finite value rides the pan and zoom arithmetic into a range that /// is no longer a range, and since a gesture that sets a range also clears auto /// scale, `f` would be the only way back -- so it must not get in. bool finite(f32 v) noexcept { return std::isfinite(v); } /// `e`, with an unusable position replaced by the last known one and an unusable /// delta by no movement. Sanitising rather than rejecting keeps the buttons in the /// same event alive: a drag whose release arrived with a garbage position must /// still end. PointerEvent sanitized(const PointerEvent& e, bool have_pointer, f32 last_x, f32 last_y) noexcept { PointerEvent in = e; if (!finite(in.x) || !finite(in.y)) { in.x = have_pointer ? last_x : 0.0f; in.y = have_pointer ? last_y : 0.0f; in.dx = 0.0f; in.dy = 0.0f; return in; } if (!finite(in.dx)) in.dx = 0.0f; if (!finite(in.dy)) in.dy = 0.0f; return in; } /// `e`, with an unusable position replaced by the last known one. The wheel's /// amount is deliberately left alone: `on_wheel` rejects an unusable one *before* /// the event is acted on, because a zeroed amount is a zoom of exactly one, and /// running that over an unchanged range would still clear every axis' auto scale. WheelEvent sanitized(const WheelEvent& e, bool have_pointer, f32 last_x, f32 last_y) noexcept { WheelEvent in = e; if (!finite(in.x) || !finite(in.y)) { in.x = have_pointer ? last_x : 0.0f; in.y = have_pointer ? last_y : 0.0f; } return in; } } // namespace void Controller::set_viewport(Rect px) { // The controller owns no viewport: it forwards the caller's rect to the plot // (so the two can never disagree) and refreshes the layout the hit tests and // transforms read. plot_->set_viewport(px); plot_->layout(); // A resize moves the axes (and with them the data under a stationary pointer), so the // crosshair is re-placed against the new rect -- and cleared if the pointer is now outside // it, which is the same rule as a pointer that left. if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); // The hover the layout is part of: a handle under the pointer may be somewhere // else now, and the highlight the renderer draws reads what this publishes. publish_hot(); } void Controller::on_pointer(const PointerEvent& raw) { const PointerEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_); has_pointer_ = true; pointer_x_ = e.x; pointer_y_ = e.y; plot_->layout(); // keep the chrome geometry the gestures map through current if (e.pressed && e.down) { if (double_click(e)) { // The second press of a double-click re-fits every axis and begins no drag: // a reset is a click-shaped gesture, not the start of one. fit_all(); } else { begin_drag(e); } } else if (e.released) { end_drag(e); } else if (e.down && mode_ != DragMode::None) { update_drag(e); } // The crosshair is placed (or cleared) from the pointer's own position, after the gesture // above: a press does not clear it -- the pointer is still over the axes, still pointing at // whatever it just grabbed -- and a drag keeps it re-anchored to the pointer as the data // moves under it. update_crosshair(e.x, e.y); // Every exit above ends here: a hover that moved, a gesture that opened, and a // gesture that closed all change what is under the pointer, and the publish is // what makes the renderer's highlight (and a host's resize cursor) follow. publish_hot(); } void Controller::on_wheel(const WheelEvent& raw) { // An amount that is not a number is not a wheel: sanitising it to zero would look // like a zero-notch zoom -- a factor of exactly one -- and the zoom body would // still run, calling the range setters with the unchanged range. Those clear // `auto_scale` unconditionally, so a single garbage wheel would silently disarm // auto-fit on every axis. `dx` is refused by the same rule: it pans when `dy` is // zero, and a garbage pan is no more welcome than a garbage zoom. Return before // the event can mutate anything. if (!std::isfinite(raw.dy) || !std::isfinite(raw.dx)) return; const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_); plot_->layout(); // A zero-notch wheel -- `dy == 0` and `dx == 0`, which a trackpad or a driver // happily forwards -- must change nothing at all. Letting it run would call the // range setters with every range unchanged, and those clear `auto_scale`: one // empty scroll would silently disarm auto-fit on every axis. There is nothing to // apply, so there is nothing to lay out or republish either. if (e.dy == 0.0f && e.dx == 0.0f) return; if (e.dy != 0.0f) { const f64 factor = zoom_factor(static_cast(e.dy)); // The zoom is refused, and the whole event with it, when its factor could not // be applied honestly: a factor of exactly one changes no range but would // still clear `auto_scale` on the way through the setters, a non-finite one // (a huge downward `dy` explodes `exp`) would hand a non-finite span to the // setters, and a non-positive one is not a zoom at all. "Leaves the plot // exactly as it was" is the rule for an unusable amount. if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return; const bool shift = has_mod(e.mods, Mod::Shift); const bool ctrl = has_mod(e.mods, Mod::Ctrl); // Shift alone restricts the zoom to x, ctrl alone to y; neither (or both) // zooms both axes. The data coordinate under the pointer stays put. const bool zoom_x = !(ctrl && !shift); const bool zoom_y = !(shift && !ctrl); if (zoom_x) { for (u32 i = 0; i < plot_->x_axis_count(); ++i) { zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor); } } if (zoom_y) { for (u32 i = 0; i < plot_->y_axis_count(); ++i) { zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast(e.y)), factor); } } } // A horizontal wheel pans x only: `dx` notches, 5% of the x span each, in axis // space (so a log axis scrolls multiplicatively, exactly as its transform maps). // A positive `dx` scrolls right -- the view moves toward larger x, the same // reading `WheelEvent`'s comment gives the field. On a live axis the range // setter reads the shifted window as a span and an offset behind the edge, so // scrolling right runs to the live edge and stops there, exactly as a drag pan // does. if (e.dx != 0.0f) shift_x_axes(static_cast(e.dx) * kPanPerNotch); // The ranges moved under a pointer that did not, so the geometry under it changed: refresh // the layout -- the region rects the hit test reads were measured for the range this wheel // has just replaced -- and republish what is hot. A frame drawn right after a scroll then // emphasizes the handle the pointer is over *now*, rather than the one it was over before, // without waiting for the mouse to move again. plot_->layout(); // The data under the pointer moved with the ranges, so the crosshair -- which is a data // coordinate -- is re-anchored to the pointer rather than left on the value it was placed // at: it follows the pointer, and this is the one thing that moves the data without moving // the pointer. if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); publish_hot(); } void Controller::on_gesture(const GestureEvent& raw) { // The same refusal rule as the wheel: a frame carrying something that is not // a number is not a gesture, and a frame that could not change anything -- // a unit scale and no movement -- must not reach the range setters, which // clear `auto_scale` for no visible change. `scale <= 0` is not a zoom. if (!std::isfinite(raw.x) || !std::isfinite(raw.y) || !std::isfinite(raw.scale) || !std::isfinite(raw.dx) || !std::isfinite(raw.dy)) { return; } if (raw.scale <= 0.0f) return; if (raw.scale == 1.0f && raw.dx == 0.0f && raw.dy == 0.0f) return; plot_->layout(); const f64 factor = 1.0 / static_cast(raw.scale); // pinch out = zoom in if (std::isfinite(factor) && factor > 0.0 && factor != 1.0) { // A pinch is a two-handed instrument, so it has no modifier semantics: it // zooms both axes about the focal point, exactly as an unmodified wheel // notch pair would -- same setters, same live-axis behaviour. for (u32 i = 0; i < plot_->x_axis_count(); ++i) { zoom_x_axis(i, x_zoom_anchor(i, raw.x, raw.y), factor); } for (u32 i = 0; i < plot_->y_axis_count(); ++i) { zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast(raw.y)), factor); } } // The focal point's movement is a two-finger pan: the same path a one-finger // drag takes, deltas included, so a pinch-and-drag moves the data under the // fingers on every axis the drag would move. if (raw.dx != 0.0f || raw.dy != 0.0f) { PointerEvent pan_e; pan_e.x = raw.x; pan_e.y = raw.y; pan_e.dx = raw.dx; pan_e.dy = raw.dy; pan(pan_e); } plot_->layout(); if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); publish_hot(); } void Controller::on_key(const KeyEvent& e) { if (!e.down) return; // every action below is press-only // Escape cancels the active drag (see cancel_drag). A key with no drag open -- // and any other key -- changes nothing and returns below without touching the // layout, the crosshair or the published handles. if (e.key == kKeyEscape) { if (mode_ != DragMode::None) cancel_drag(); return; } // The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y // axis, 10% of that axis' own span per press (and per repeat, which is why the // adapters forward key repeat). An arrow says where to *look*: Left walks the // window toward smaller x, Up toward larger y -- the screen-y inversion a drag's // dy carries does not apply, because a drag says where the data goes and a key // says where the viewer goes. switch (e.key) { case kKeyLeft: shift_x_axes(-kPanPerPress); break; case kKeyRight: shift_x_axes(kPanPerPress); break; case kKeyUp: shift_y_axes(kPanPerPress); break; case kKeyDown: shift_y_axes(-kPanPerPress); break; default: break; } const bool pans = e.key >= kKeyLeft && e.key <= kKeyDown; const bool zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut; const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper; if (!pans && !zooms && !fits) return; // a key this controller has no gesture for if (zooms) { const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches; const f64 factor = zoom_factor(notches); // Ctrl restricts the zoom to x, Alt to y; bare +/- zooms both. (Shift cannot be // used: '+' is shift+'=' on common layouts, so key_from() folds them together and // a bare zoom-in already arrives with Mod::Shift set.) Ctrl+Alt zooms neither, // which is harmless and needs no special case. const bool only_x = has_mod(e.mods, Mod::Ctrl); const bool only_y = has_mod(e.mods, Mod::Alt); if (!only_y) { for (u32 i = 0; i < plot_->x_axis_count(); ++i) { const Axis& a = plot_->x_axis(i); const f64 lo = to_space(a.range.min, a.log); const f64 hi = to_space(a.range.max, a.log); zoom_x_axis(i, from_space(0.5 * (lo + hi), a.log), factor); } } if (!only_x) { for (u32 i = 0; i < plot_->y_axis_count(); ++i) { const Axis& a = plot_->y_axis(i); const f64 lo = to_space(a.range.min, a.log); const f64 hi = to_space(a.range.max, a.log); zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor); } } } else if (fits) { plot_->auto_fit_x(); for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i); } // A key stroke that moved a range moved the geometry under a stationary pointer, exactly // as a wheel notch does: refresh the layout and republish what is hot, so the highlight a // frame draws is the handle the pointer is over now (see on_wheel). A key this controller // has no gesture for changes nothing and returns above -- and so does not touch the // crosshair either. plot_->layout(); if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); publish_hot(); } void Controller::update_crosshair(f32 px, f32 py) { // Inside the data area the crosshair is at the coordinates under the pointer; outside it // there is no data to read, so there is no crosshair -- the null answer, rather than a // value clamped to the nearest edge of the window. const PlotLayout& layout = std::as_const(*plot_).layout(); if (!layout.axes.contains(px, py)) { plot_->clear_crosshair(); return; } // Both coordinates, on the primary axis pair (the same pair the grid and a region's fill // are placed through): a pointer over the axes knows its x and its y, so a single plot's // crosshair is the full cross. A `Figure` is driven by `Figure::set_crosshair()` instead, // which applies one x to every row and draws no horizontal line -- its rows share x and // not y. plot_->set_crosshair(plot_->x_transform(0u).to_data_x(static_cast(px)), plot_->y_transform(0u).to_data_y(static_cast(py)), true); } u64 Controller::hovered_cursor() const { if (!has_pointer_) return kInvalid; return cursor_at(pointer_x_, pointer_y_); } u64 Controller::hovered_region() const { if (!has_pointer_) return kInvalid; return region_hot(pointer_x_, pointer_y_).region; } RegionGrab Controller::region_grab(u32 region) const { if (!has_pointer_) return RegionGrab::None; for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) { if (candidate.region == region) return zone_hit(candidate, pointer_x_, pointer_y_).zone; } return RegionGrab::None; // an id that names no visible region } Controller::ZoneHit Controller::zone_hit(const RegionLayout& region, f32 px, f32 py) const { ZoneHit hit; // Off the data area nothing is hot, whether the pointer is over a label strip or // off the plot entirely. A bound that has scrolled just outside `axes` is still // within reach of a pointer inside it -- the slack the cursor hit test has too -- // but the strips are not a place a region can be grabbed from. if (!std::as_const(*plot_).layout().axes.contains(px, py)) return hit; const f32 left_x = handle_column(region.left_handle); const f32 right_x = handle_column(region.right_handle); const f32 min_w = plot_->style().device(kMinRegionEdgeGrabLogical); // Grabbable bounds: the bounds are told apart only while the region is wide enough to // hold a band at each end and an interior between them -- below that the body is the // only handle it has, which is the collapsed-region rule above -- and each band is cut // back so that interior always exists. The radius every other handle in this controller // is hit-tested with is kept wherever a region can afford it: the band is that radius // while twice it plus the minimum interior still fits, and half the remainder after // that, so the middle of a 6 px region is a body rather than inside both bounds. One // tolerance, so what `hovered_cursor()` calls a hit and what a region bound calls a hit // agree wherever there is room for both. if (right_x - left_x >= min_w) { const f32 radius = plot_->style().device(kCursorHitRadiusLogical); const f32 band = std::min(radius, 0.5f * (right_x - left_x - plot_->style().device(kMinRegionBodyLogical))); const f32 dl = std::fabs(px - left_x); const f32 dr = std::fabs(px - right_x); const f32 d = std::min(dl, dr); if (d <= band) { hit.zone = dl <= dr ? RegionGrab::Left : RegionGrab::Right; hit.distance = d; return hit; } } // No bound in range: the body, widened by half the minimum on each side so a // region too narrow to have grabbable bounds is still something a pointer can take // hold of rather than a line it cannot hit. const f32 grow = 0.5f * min_w; if (px >= left_x - grow && px <= right_x + grow) hit.zone = RegionGrab::Body; return hit; } Controller::RegionHot Controller::region_hot(f32 px, f32 py) const { RegionHot hot; u64 body = kInvalid; f32 nearest = 0.0f; for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) { const ZoneHit hit = zone_hit(candidate, px, py); if (hit.zone == RegionGrab::None) continue; if (hit.zone == RegionGrab::Body) { // Remembered rather than chosen: a body only wins when no bound *anywhere* is // in range, and the last region holding the pointer is the one on top -- // regions are drawn in order, so a later one covers an earlier one's interior. body = candidate.region; continue; } if (hot.region == kInvalid || hit.distance < nearest) { hot.region = candidate.region; hot.zone = hit.zone; nearest = hit.distance; } } if (hot.region != kInvalid) return hot; return body == kInvalid ? RegionHot{} : RegionHot{body, RegionGrab::Body}; } void Controller::publish_hot() { HotHandles hot; if (has_pointer_) { hot.cursor = cursor_at(pointer_x_, pointer_y_); if (drag_region_ != kInvalid) { // An open region drag keeps its own zone hot for as long as it runs, so the // highlight follows the grab rather than the pointer: a body dragged out from // under the pointer still reads as the region being moved, and a bound keeps // its emphasis after the pointer has left it. hot.region = drag_region_; hot.zone = drag_zone_; } else { const RegionHot region = region_hot(pointer_x_, pointer_y_); hot.region = region.region; hot.zone = region.zone; } } hot.grabbed_cursor = drag_cursor_; plot_->set_hot_handles(hot); } // --- drag dispatch -------------------------------------------------------- void Controller::begin_drag(const PointerEvent& e) { origin_x_ = e.x; origin_y_ = e.y; drag_cursor_ = kInvalid; drag_region_ = kInvalid; drag_zone_ = RegionGrab::None; // What Escape will restore if this gesture is cancelled: the axes' state *now*, // before the drag moves anything (see capture_ranges). capture_ranges(); // The modifier box zoom, on the left only: a selection box is what a modified // left press means even when it starts over a handle. The mask is resolved once, // here, from the modifier table -- Shift is the plain both-axis selection it has // always been, Ctrl frames the x range only, Alt the y range only, and any other // combo falls back to both -- and `apply_box_zoom()` reads it on release, per // axis and for the click threshold alike. if (e.button == Button::Left) { const bool shift = has_mod(e.mods, Mod::Shift); const bool ctrl = has_mod(e.mods, Mod::Ctrl); const bool alt = has_mod(e.mods, Mod::Alt); if (shift || ctrl || alt) { mode_ = DragMode::BoxZoom; // The masks: Ctrl alone frames the x range only, Alt alone the y range only, // and every other combo (Shift included) frames both -- the field says whether // the release *applies* that axis, so the axis named by the mask is the one // that stays true. const bool ctrl_only = ctrl && !shift && !alt; const bool alt_only = alt && !shift && !ctrl; box_zoom_x_ = !alt_only; box_zoom_y_ = !ctrl_only; selection_ = Rect{e.x, e.y, 0.0f, 0.0f}; return; } } if (e.button == Button::Middle) { const PlotLayout& lay = std::as_const(*plot_).layout(); if (lay.x_label_area.contains(e.x, e.y)) { mode_ = DragMode::AxisZoomX; } else if (lay.y_label_area.contains(e.x, e.y)) { mode_ = DragMode::AxisZoomY; } else { mode_ = DragMode::None; // a middle drag off the label strips does nothing } return; } // Everything else -- the left and the right button both, since a host that // reserves the left one still drives its drags with the right -- grabs the handle // under the press before it pans. Ctrl needs no special case: a press on a cursor // does exactly this with or without it, and a ctrl press on nothing still pans. if (begin_handle_drag(e)) return; mode_ = DragMode::Pan; // left and right drag both pan } bool Controller::begin_handle_drag(const PointerEvent& e) { // Cursor markers first: they are what a measure's band edges *are* (a band // between two cursors is resized by dragging those cursors), and a marker is drawn // over everything else, so it is the thing a press on it means. const u64 cursor = cursor_at(e.x, e.y); if (cursor != kInvalid) { mode_ = DragMode::MoveCursor; drag_cursor_ = cursor; return true; } const RegionHot hot = region_hot(e.x, e.y); if (hot.region == kInvalid) return false; drag_region_ = hot.region; drag_zone_ = hot.zone; const Region& region = plot_->region(static_cast(hot.region)); switch (hot.zone) { case RegionGrab::Left: // The other bound is held for the whole gesture: each motion is then the two // numbers this drag is made of, not a step from the previous one, so a pointer // that moves back and forth cannot accumulate a drift -- and when it crosses // the held bound the store orders them, which swaps the interval instead of // inverting it (see Plot::set_region). mode_ = DragMode::ResizeRegionLeft; drag_fixed_x_ = region.x1; return true; case RegionGrab::Right: mode_ = DragMode::ResizeRegionRight; drag_fixed_x_ = region.x0; return true; case RegionGrab::Body: // A body drag preserves the width exactly by construction: both bounds move by // the same delta from where they were when the gesture opened. mode_ = DragMode::MoveRegion; drag_x0_ = region.x0; drag_x1_ = region.x1; drag_grab_x_ = plot_->x_transform(0u).to_data_x(static_cast(e.x)); return true; case RegionGrab::None: break; } drag_region_ = kInvalid; drag_zone_ = RegionGrab::None; return false; } void Controller::update_drag(const PointerEvent& e) { switch (mode_) { case DragMode::Pan: pan(e); break; case DragMode::BoxZoom: box_zoom_update(e); break; case DragMode::MoveCursor: move_cursor(e); break; case DragMode::MoveRegion: move_region(e); break; case DragMode::ResizeRegionLeft: case DragMode::ResizeRegionRight: resize_region(e); break; case DragMode::AxisZoomX: case DragMode::AxisZoomY: axis_zoom(e); break; case DragMode::None: break; } } void Controller::end_drag(const PointerEvent& e) { // The release carries the authoritative end of the gesture: refresh the // selection from it first, so a host that reports only a press and a release // (no motion event in between) still frames the box the release describes // instead of leaving selection_ at 0x0 and degrading the zoom to a click. if (mode_ == DragMode::BoxZoom) { box_zoom_update(e); apply_box_zoom(); } // A region's snap is applied exactly once, here, on the gesture's final bounds: // the drag itself stores them verbatim, so they follow the pointer instead of // sticking to the sample grid as it moves (see Plot::snap_region()). if (drag_region_ != kInvalid) plot_->snap_region(static_cast(drag_region_)); // And the same for a grabbed cursor: `move_cursor()` wrote the free pointer position // for the whole gesture, so this is where the marker lands on its own snap rule -- // the drop commits the snap, both orientations alike (see Plot::snap_cursor()). if (drag_cursor_ != kInvalid) plot_->snap_cursor(static_cast(drag_cursor_)); mode_ = DragMode::None; selection_ = Rect{}; drag_cursor_ = kInvalid; drag_region_ = kInvalid; drag_zone_ = RegionGrab::None; // The capture is the cancelled gesture's undo record, and this gesture was not // cancelled: keeping it would let a later Escape restore a state that ended long // ago (and `capture_ranges()` only runs at a press). saved_x_.clear(); saved_y_.clear(); } // --- gestures ------------------------------------------------------------- void Controller::capture_ranges() { saved_x_.clear(); saved_y_.clear(); for (u32 i = 0; i < plot_->x_axis_count(); ++i) { const Axis& a = plot_->x_axis(i); saved_x_.push_back(SavedX{a.range, a.offset, a.auto_scale}); } for (u32 i = 0; i < plot_->y_axis_count(); ++i) { const Axis& a = plot_->y_axis(i); saved_y_.push_back(SavedY{a.range, a.auto_scale}); } } void Controller::cancel_drag() { // The ranges the axes held at `begin_drag`, through the plot's own setters: an // `Off` axis gets its range and its auto-scale flag back exactly, and a live one // gets its span and offset restated the way the setters read them (the offset is // written back verbatim after the range, since the live edge of *this* moment -- // not of the press -- is what the setter would otherwise derive it from). A // cancelled gesture is as if it never ran: nothing is applied, no auto-scale is // disarmed that was not already, and no box is framed. for (u32 i = 0; i < plot_->x_axis_count() && i < saved_x_.size(); ++i) { const SavedX& saved = saved_x_[i]; plot_->set_x_range(i, saved.range); plot_->set_x_offset(i, saved.offset); plot_->set_x_auto_scale(i, saved.auto_scale); } for (u32 i = 0; i < plot_->y_axis_count() && i < saved_y_.size(); ++i) { const SavedY& saved = saved_y_[i]; plot_->set_y_range(i, saved.range); plot_->set_y_auto_scale(i, saved.auto_scale); } // The gesture itself: no mode, no selection, nothing grabbed -- and the highlight // the renderer draws follows, through the same tail every event that changes the // geometry under the pointer runs. mode_ = DragMode::None; selection_ = Rect{}; drag_cursor_ = kInvalid; drag_region_ = kInvalid; drag_zone_ = RegionGrab::None; plot_->layout(); if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); publish_hot(); } void Controller::fit_all() { plot_->auto_fit_x(); for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i); // A fit moved the geometry under a stationary pointer, exactly as a wheel notch // does -- same tail, same reason (see on_wheel). plot_->layout(); if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); publish_hot(); } bool Controller::double_click(const PointerEvent& e) { const f32 radius = plot_->style().device(kDoubleClickPxLogical); const f32 dx = e.x - click_x_; const f32 dy = e.y - click_y_; // Both presses must carry a host timestamp for the window to mean anything: a // host that leaves `time_s` at 0 never produces a double-click (the field is // optional, and two presses "0 s apart" would otherwise fire on any host). const bool in_time = e.time_s > 0.0 && click_time_s_ > 0.0 && e.time_s - click_time_s_ <= kDoubleClickSeconds; const bool in_space = dx * dx + dy * dy <= radius * radius; const bool is_double = has_click_ && click_button_ == static_cast(e.button) && in_time && in_space; // This press is what a third one would be measured against -- unless the two // just made a double-click, in which case the record clears: a third press starts // a fresh gesture rather than re-firing the reset. has_click_ = !is_double; click_button_ = static_cast(e.button); click_time_s_ = e.time_s; click_x_ = e.x; click_y_ = e.y; return is_double; } void Controller::shift_x_axes(f64 fraction) { for (u32 i = 0; i < plot_->x_axis_count(); ++i) { const Axis& a = plot_->x_axis(i); const f64 lo = to_space(a.range.min, a.log); const f64 hi = to_space(a.range.max, a.log); const f64 d = fraction * (hi - lo); // positive scrolls toward larger x plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)}); } } void Controller::shift_y_axes(f64 fraction) { for (u32 i = 0; i < plot_->y_axis_count(); ++i) { const Axis& a = plot_->y_axis(i); const f64 lo = to_space(a.range.min, a.log); const f64 hi = to_space(a.range.max, a.log); const f64 d = fraction * (hi - lo); // positive scrolls toward larger y plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)}); } } void Controller::pan(const PointerEvent& e) { // The transforms map a range onto layout().axes, so the pixel-to-data scale is // the range span over the axes rect -- not over the whole viewport. Using the // viewport (56 px of label strip on an 800 px plot) makes dragged data lag the // pointer by the strip's fraction, breaking grab-and-drag. const Rect axes = std::as_const(*plot_).layout().axes; if (!(axes.w > 0.0f) || !(axes.h > 0.0f)) return; const f64 fx = static_cast(e.dx) / static_cast(axes.w); const f64 fy = static_cast(e.dy) / static_cast(axes.h); for (u32 i = 0; i < plot_->x_axis_count(); ++i) { const Axis& a = plot_->x_axis(i); const f64 lo = to_space(a.range.min, a.log); const f64 hi = to_space(a.range.max, a.log); const f64 d = -fx * (hi - lo); // drag right reveals earlier (smaller) data plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)}); } for (u32 i = 0; i < plot_->y_axis_count(); ++i) { const Axis& a = plot_->y_axis(i); const f64 lo = to_space(a.range.min, a.log); const f64 hi = to_space(a.range.max, a.log); const f64 d = fy * (hi - lo); // screen y is inverted: dragging down raises y plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)}); } } Rect Controller::selection_preview() const noexcept { // Nothing to preview off a box zoom: the mask bits outlive the gesture -- `end_drag()` clears the // selection, not them -- so widening without this guard would hand out a full-band rect for a drag // that finished minutes ago. if (mode_ != DragMode::BoxZoom) return Rect{}; // And nothing has been framed until the drag has swept every axis the release will apply: at the // press instant the selection is an empty rect at the pointer, and the *widened* form of that is a // band the width of the plot, which is a claim about a range that does not exist yet. Ctrl's other // axis is deliberately not required: a drag with no vertical movement at all is a band across the // plot, which is exactly what Ctrl is for. if ((box_zoom_x_ && !(selection_.w > 0.0f)) || (box_zoom_y_ && !(selection_.h > 0.0f))) return Rect{}; Rect preview = selection_; if (box_zoom_x_ && box_zoom_y_) return preview; const Rect& axes = plot_->layout().axes; if (!box_zoom_x_) { preview.x = axes.x; preview.w = axes.w; } if (!box_zoom_y_) { preview.y = axes.y; preview.h = axes.h; } return preview; } void Controller::box_zoom_update(const PointerEvent& e) { const f32 x = std::min(origin_x_, e.x); const f32 y = std::min(origin_y_, e.y); selection_ = Rect{x, y, std::fabs(e.x - origin_x_), std::fabs(e.y - origin_y_)}; } void Controller::apply_box_zoom() { const Rect sel = selection_; // A selection under a few *logical* pixels on the axes the mask will zoom is a // click: no zoom. The threshold is masked with the gesture -- a Ctrl drag frames // the x range from a selection however short it is vertically, and vice versa -- // which is what makes a thin horizontal band a legal x-only box rather than a // click no matter what the y says. The selection is measured in device pixels // like everything the controller is handed, so the threshold is converted at the // plot's own scale -- through the one place that conversion lives, so a change to // its fallback is picked up here too. const f32 min_box = plot_->style().device(kMinBoxLogical); if (box_zoom_x_ && sel.w < min_box) return; if (box_zoom_y_ && sel.h < min_box) return; // Clamped to the axes rect the transforms are built from, so a drag that left // the plot area cannot ask for data outside what the box covers on screen. const Rect axes = std::as_const(*plot_).layout().axes; const f64 ax = static_cast(axes.x); const f64 ar = static_cast(axes.right()); const f64 ay = static_cast(axes.y); const f64 ab = static_cast(axes.bottom()); const f64 l = std::clamp(static_cast(sel.x), ax, ar); const f64 r = std::clamp(static_cast(sel.right()), ax, ar); const f64 t = std::clamp(static_cast(sel.y), ay, ab); const f64 b = std::clamp(static_cast(sel.bottom()), ay, ab); if (box_zoom_x_ && !(r > l)) return; if (box_zoom_y_ && !(b > t)) return; // The ranges come straight from the transforms, so the box frames exactly the // data under it; the selection's bottom edge is the range minimum on y. Masked // axes are left exactly as they were -- the release applies only what the press // promised. if (box_zoom_x_) { for (u32 i = 0; i < plot_->x_axis_count(); ++i) { const Transform tx = plot_->x_transform(i); plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)}); } } if (box_zoom_y_) { for (u32 i = 0; i < plot_->y_axis_count(); ++i) { const Transform ty = plot_->y_transform(i); plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)}); } } } void Controller::move_cursor(const PointerEvent& e) { if (drag_cursor_ == kInvalid) return; const u32 id = static_cast(drag_cursor_); const Cursor& cursor = plot_->cursor(id); // A grabbed cursor mutates the coordinate its own orientation owns and nothing else, and // each half names the one setter for it -- so a ruler dragged along y can never disturb a // marker's x, and the dead coordinate of either is untouched by construction. // // Both writes are `CursorPlacement::Free`: the marker follows the pointer for the whole // gesture and lands on its grid only when the drag ends (`end_drag()` calls // `Plot::snap_cursor()`), so a snapping cursor tracks the hand instead of hopping between // samples -- the defect reported from use. A caller reading `cursor(id).x` / `.y` while // the drag runs reads that free position. if (cursor.is_horizontal()) { // Clamped to the ruler's own y axis: a pointer that has left the axes has no data under // it, and the coordinate it extrapolates to is past the end of the axis the ruler reads // values off -- a position where the ruler would either vanish (the renderer skips a row // outside `axes`) or, worse, have to be dragged back by a zoom nobody asked for. Clamping // keeps it readable at the edge it was dragged to instead. `set_cursor_y()` itself stays // verbatim, so a *caller* that means to park a ruler off range still can. const Axis& axis = plot_->y_axis(cursor.axis_y); const f64 y = plot_->y_transform(cursor.axis_y).to_data_y(static_cast(e.y)); // `std::clamp` needs an ordered pair and a y range may be inverted -- descending depth, // row index or time is ordinary -- because `set_y_range()` stores it verbatim and only // the transform widens it. Order the bounds rather than assuming `min <= max`, or an // inverted axis would collapse the dragged ruler onto one end (and the call is UB). plot_->set_cursor_y(id, std::clamp(y, std::min(axis.range.min, axis.range.max), std::max(axis.range.min, axis.range.max)), CursorPlacement::Free); return; } plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast(e.x)), CursorPlacement::Free); } void Controller::move_region(const PointerEvent& e) { if (drag_region_ == kInvalid) return; // The pointer's own data coordinate, not the event's delta: the delta a host // reports for one motion is not always the difference between the positions it // reported, and a region's two bounds must move by exactly the same amount or the // width changes. Both come from the bounds the gesture opened with plus this one // offset, so nothing accumulates over a long drag. const f64 grabbed = plot_->x_transform(0u).to_data_x(static_cast(e.x)); const f64 delta = grabbed - drag_grab_x_; plot_->set_region(static_cast(drag_region_), drag_x0_ + delta, drag_x1_ + delta); } void Controller::resize_region(const PointerEvent& e) { if (drag_region_ == kInvalid) return; const f64 x = plot_->x_transform(0u).to_data_x(static_cast(e.x)); // The bound the gesture did not grab stays where it was for the whole drag, and // the grabbed one is wherever the pointer says. When the two cross, the *store* // orders them (`set_region` keeps min and max), so the interval swaps rather than // inverting -- and the pointer keeps hold of its bound: the next motion is still // "the other end plus this pointer", with no state to get stuck in. if (mode_ == DragMode::ResizeRegionLeft) { plot_->set_region(static_cast(drag_region_), x, drag_fixed_x_); } else { plot_->set_region(static_cast(drag_region_), drag_fixed_x_, x); } } void Controller::axis_zoom(const PointerEvent& e) { // A pointer drag reports pixels, not wheel notches, so it uses its own // sensitivity: 200 px is one decade. The gain is a physical distance the hand // moves, so the device-pixel delta is converted to logical pixels first -- the // same drag must zoom the same amount on a 2x display. // // A scale that is not a positive number is not a scale -- a host that has not // measured its display, a field nobody filled in -- the same state // `Style::device()` tolerates. Here it means *no zoom*: dividing by it would make // `logical_dy` infinite and the factor infinite (or NaN), and that factor reaches // `set_x_range()`/`set_y_range()`, which store it verbatim and clear `auto_scale` // -- an axis left non-finite until an auto-fit. A negative scale would silently // invert the zoom direction too, so the whole class is refused here. const f64 scale = static_cast(plot_->ui_scale()); if (!(scale > 0.0) || !std::isfinite(scale)) return; const f64 logical_dy = static_cast(e.dy) / scale; const f64 factor = std::exp(-logical_dy * kZoomPerPixel); // The same degenerate factors the wheel refuses: a factor of exactly one (a // motion that reports no dy -- running it would still clear `auto_scale` on its // way through the range setters), a non-finite one (an enormous dy explodes // `exp` into a span the setters cannot store), and a non-positive one, which is // not a zoom at all. if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return; if (mode_ == DragMode::AxisZoomX) { for (u32 i = 0; i < plot_->x_axis_count(); ++i) { zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor); } } else { for (u32 i = 0; i < plot_->y_axis_count(); ++i) { zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast(e.y)), factor); } } } // --- helpers -------------------------------------------------------------- f64 Controller::x_zoom_anchor(u32 axis, f32 px, f32 py) const { // The pointer's own coordinate while it is over the data area -- the anchor a // zoom has always had, and the reason it feels attached to what is under the // cursor. Outside `axes` there is no data under the pointer at all, so the // coordinate it extrapolates to is a number the user cannot see; a live axis has // a better answer, its live edge, which makes a zoom from the label strip or from // off the plot keep the window ending at the newest sample instead of drifting // away from it. Any other axis keeps the extrapolated coordinate, and `Off` is // the default, so nothing changes for a plot that is not following. // // A figure row's axis is `Off` even while its window follows, because a link group // owns the shared window *and* the follow state (`Figure::link_x()` refuses a `Live` // member) -- so the axis' own follow flag is not the whole answer. A following group // publishes its edge to every member (`Plot::x_live_owner()`), and that edge is read // second: it is the group's -- the union of its members' newest x -- which is the // coordinate the window actually ends at, and the member's own series would be a // different answer in a figure whose rows are fed at different rates. if (!std::as_const(*plot_).layout().axes.contains(px, py)) { f64 live = 0.0; if (plot_->x_follow(axis) == XFollow::Live && plot_->x_live_edge(axis, live)) return live; if (plot_->x_live_owner(axis, live)) return live; } return plot_->x_transform(axis).to_data_x(static_cast(px)); } u64 Controller::cursor_at(f32 px, f32 py) const { const PlotLayout& lay = std::as_const(*plot_).layout(); if (!lay.axes.contains(px, py)) return kInvalid; const f32 radius = plot_->style().device(kCursorHitRadiusLogical); u64 best = kInvalid; f32 best_d = 0.0f; for (u32 i = 0; i < plot_->cursor_count(); ++i) { const Cursor& cursor = plot_->cursor(i); // A readout is drawn but not offered to the pointer: skipping it here is what makes it // undraggable, rather than fighting the drag a frame at a time. if (!cursor.visible || !cursor.selectable) continue; // One hit test for both orientations, because a cursor *is* one line across the axes: // what the pointer has to be near is that line's own coordinate -- the column of a // vertical marker, the row of a horizontal ruler -- measured with the same radius // either way, which is what keeps hover and grab agreeing about a ruler exactly as // they do about a marker. A point within reach of one of each resolves to the nearer, // so neither orientation widens the other's target. const bool horizontal = cursor.is_horizontal(); const f32 d = std::fabs(plot_->cursor_screen_coordinate(i) - (horizontal ? py : px)); if (d > radius) continue; if (best == kInvalid || d < best_d) { best = i; best_d = d; } } return best; } void Controller::zoom_x_axis(u32 axis, f64 anchor, f64 factor) { const Axis& a = plot_->x_axis(axis); const Range r = a.range; if (!(r.span() > 0.0) || !(factor > 0.0)) return; const f64 lo = to_space(r.min, a.log); const f64 hi = to_space(r.max, a.log); const f64 aa = to_space(anchor, a.log); // Recomputing the range with the anchor held fixed keeps the anchor's screen // position invariant, for linear and log axes alike. plot_->set_x_range(axis, Range{from_space(aa + (lo - aa) * factor, a.log), from_space(aa + (hi - aa) * factor, a.log)}); } void Controller::zoom_y_axis(u32 axis, f64 anchor, f64 factor) { const Axis& a = plot_->y_axis(axis); const Range r = a.range; if (!(r.span() > 0.0) || !(factor > 0.0)) return; const f64 lo = to_space(r.min, a.log); const f64 hi = to_space(r.max, a.log); const f64 aa = to_space(anchor, a.log); plot_->set_y_range(axis, Range{from_space(aa + (lo - aa) * factor, a.log), from_space(aa + (hi - aa) * factor, a.log)}); } } // namespace plot::input