// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include namespace plot::qt { /// The cached pointer position a move event's movement is measured from, in /// **logical** widget pixels. /// /// Logical, not device, pixels on purpose. Qt reports every widget position in /// logical pixels, and the device-pixel ratio can change between two move events /// -- the window being dragged onto a monitor with another scale factor is the /// case -- so an anchor cached in device pixels would compare a position computed /// at the old ratio with one computed at the new one. At 1.0 -> 1.25 a logical /// position of 500 would then look like a jump of 125 device pixels, and /// `Controller::pan` applies a delta as a fraction of the range span (and clears /// auto scale), so a scale change mid-drag would pan the plot by 125 pixels' worth /// of data. Keeping the anchor logical and scaling the *difference* by the ratio /// in effect now makes the same gesture map to the same device distance whatever /// the window's scale is. /// /// A plain value with no toolkit dependency: the widget owns one, and this is the /// part of the adapter's translation a test can drive directly (see /// tests/test_qt_pointer.cpp). struct PointerAnchor { bool have = false; ///< false until the first sample, which has no movement f32 x = 0.0f; ///< last logical position, widget pixels f32 y = 0.0f; /// Movement from the cached position to `logical`, in device pixels at `dpr`. /// Zero before the first sample, so the first motion of a drag never includes /// the jump to where the drag began. Vec2 device_delta(Vec2 logical, f64 dpr) const noexcept { if (!have) return Vec2{0.0f, 0.0f}; return Vec2{static_cast((static_cast(logical.x) - static_cast(x)) * dpr), static_cast((static_cast(logical.y) - static_cast(y)) * dpr)}; } /// Caches `logical` as the position the next movement is measured from. Always /// logical pixels, whatever the ratio is: see the type's comment. void set(Vec2 logical) noexcept { x = logical.x; y = logical.y; have = true; } }; /// The state one pinch gesture is folded into `input::GestureEvent`s from. /// /// Qt reports a pinch as **cumulative** values with no delta of its own: /// `QPinchGesture::totalScaleFactor()` is the zoom since the gesture began, and /// `centerPoint()` the focal point now, both absolute in the gesture's life. The /// controller wants the opposite of both -- `GestureEvent::scale` is *this frame's* /// zoom factor (1.0 = no change) and `(dx, dy)` the focal point's movement since the /// previous gesture event -- so the widget owns one of these and folds each update: /// the zoom arrives as the ratio of the two cumulative scales, the pan as the /// difference of two focal points. /// /// The focal point is cached in **logical** widget pixels, like `PointerAnchor` /// above, for the same reason: `devicePixelRatioF()` can change mid-gesture (the /// window dragged onto another display), and a cache in device pixels would compare /// a point taken at the old ratio with one taken at the new one -- a phantom zoom /// anchor movement, delivered to the controller as a pan. The difference of two /// logical points, scaled by the ratio in force now, is the movement the fingers /// actually made. /// /// A plain value with no toolkit dependency: the widget owns one, and this is the /// part of the adapter's pinch translation a test can drive directly (see /// tests/test_qt_pointer.cpp). struct PinchTracker { bool active = false; ///< true between the gesture's begin and its end/cancel 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 Qt's first cumulative `scale` and logical focal point. /// The first event must carry no zoom and no movement -- a pinch that begins has /// zoomed nothing yet -- so this only records where the folding starts from. void begin(f32 scale, Vec2 logical_center) noexcept { active = true; last_scale = scale; x = logical_center.x; y = logical_center.y; } /// Folds one update into an incremental `GestureEvent`, in device pixels at /// `dpr`: the zoom about the focal point, and the focal point's movement since /// the previous update. Zero zoom and zero movement before `begin()`, so an /// update that arrives without a begin -- a host that missed the start -- is a /// no-op rather than a zoom from a made-up anchor. input::GestureEvent update(f32 scale, Vec2 logical_center, f64 dpr) noexcept { input::GestureEvent g; if (!active) return g; // The frame's zoom factor: Qt's cumulative ratio over the one this tracker // last folded. The first update after begin() divides the scale by itself, // which is 1.0 -- a pinch that has not moved has zoomed nothing. g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f; g.x = static_cast(static_cast(logical_center.x) * dpr); g.y = static_cast(static_cast(logical_center.y) * dpr); g.dx = static_cast((static_cast(logical_center.x) - static_cast(x)) * dpr); g.dy = static_cast((static_cast(logical_center.y) - static_cast(y)) * dpr); last_scale = scale; x = logical_center.x; y = logical_center.y; return g; } /// Ends the gesture: an update after this (a straggler `GestureUpdated` Qt /// delivers out of order, a cancel that still reports) is again a no-op. void end() noexcept { active = false; } }; } // namespace plot::qt