// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari // The Qt adapter's pointer translation, driven directly. The widget's cached // pointer anchor is a plain value with no toolkit dependency (see // adapters/qt6/qt_pointer.hpp) precisely so the one thing a screenshot cannot show // -- which pixel space a drag delta is in -- is testable without a QApplication, a // window or a display. #include "harness.hpp" #include #include // The defect this pins: the anchor used to be cached in *device* pixels while the // next event's delta was a raw difference of device positions, so a DPR change // between two move events -- the window dragged onto a monitor with a different // scale factor mid-drag -- mixed two pixel spaces. At 1.0 -> 1.25 a logical // position of 500 produced a spurious 125 device-pixel delta, which // `Controller::pan` applies as a fraction of the range span and which also clears // auto scale: a scale change mid-drag would move the plot by 125 pixels' worth of // data and silently disarm a live axis. PLOT_TEST(pointer_anchor_survives_a_dpr_change_between_move_events) { plot::qt::PointerAnchor anchor; // The press caches a logical position while the window is on the 1.0 display. anchor.set({500.0f, 300.0f}); // The next move arrives after the window moved to the 1.25 display, at the same // logical position: the pointer has not moved, so the delta must be zero -- not // 500 * 1.25 - 500 * 1.0 = 125 device pixels, which is what comparing two device // positions across the scale change reported. const plot::Vec2 still = anchor.device_delta({500.0f, 300.0f}, 1.25); PLOT_CHECK_NEAR(still.x, 0.0, 1e-6); PLOT_CHECK_NEAR(still.y, 0.0, 1e-6); // A real movement is the logical delta scaled by the ratio in effect now, in both // directions. const plot::Vec2 moved = anchor.device_delta({503.0f, 295.0f}, 1.25); PLOT_CHECK_NEAR(moved.x, 3.0 * 1.25, 1e-6); PLOT_CHECK_NEAR(moved.y, -5.0 * 1.25, 1e-6); // And the anchor follows the sample it just reported: the following move is // measured from there, at whatever ratio that event carries. anchor.set({503.0f, 295.0f}); const plot::Vec2 unchanged = anchor.device_delta({503.0f, 295.0f}, 1.0); PLOT_CHECK_NEAR(unchanged.x, 0.0, 1e-6); PLOT_CHECK_NEAR(unchanged.y, 0.0, 1e-6); } // The first sample of a gesture has no anchor to measure from, so its delta is // zero however far the position is from the origin: the first motion of a drag // must never include the jump to where the drag began. PLOT_TEST(pointer_anchor_reports_no_movement_before_its_first_sample) { plot::qt::PointerAnchor anchor; const plot::Vec2 delta = anchor.device_delta({500.0f, 300.0f}, 1.25); PLOT_CHECK_NEAR(delta.x, 0.0, 1e-9); PLOT_CHECK_NEAR(delta.y, 0.0, 1e-9); } // --- the pinch -------------------------------------------------------------- // Qt reports a pinch as cumulative numbers -- `totalScaleFactor()` since the // gesture began, `centerPoint()` where the fingers are now -- while the // controller's `GestureEvent` wants the increments: this frame's zoom factor // (1.0 = no change) and the focal point's movement since the previous event. // `PinchTracker` is the fold, and the ratios it produces are what a pinch feels // like through: a cumulative 1.0 -> 1.25 -> 1.5 spread is a 1.25 frame followed // by a 1.2 frame, never a 1.25 then a 1.5 (which would zoom twice as far as the // fingers did). PLOT_TEST(pinch_tracker_folds_cumulative_scale_into_incremental_zoom) { plot::qt::PinchTracker pinch; // The begin anchors the fold and zooms nothing: a pinch that just started has // moved no scale yet, so the first event the controller could see is 1.0. pinch.begin(1.0f, {200.0f, 150.0f}); PLOT_CHECK(pinch.active); const plot::input::GestureEvent started = pinch.update(1.0f, {200.0f, 150.0f}, 1.0); PLOT_CHECK_NEAR(started.scale, 1.0, 1e-6); PLOT_CHECK_NEAR(started.dx, 0.0, 1e-6); PLOT_CHECK_NEAR(started.dy, 0.0, 1e-6); // Fingers spread to 1.25 cumulative, focal steady: one 1.25 zoom about the // focal point, and no pan. const plot::input::GestureEvent spread = pinch.update(1.25f, {200.0f, 150.0f}, 1.0); PLOT_CHECK_NEAR(spread.scale, 1.25, 1e-6); PLOT_CHECK_NEAR(spread.dx, 0.0, 1e-6); PLOT_CHECK_NEAR(spread.dy, 0.0, 1e-6); // Spread on to 1.5: the *increment* is 1.5 / 1.25, not 1.5 again. const plot::input::GestureEvent more = pinch.update(1.5f, {200.0f, 150.0f}, 1.0); PLOT_CHECK_NEAR(more.scale, 1.2, 1e-6); // And a pinch in (0.75 of the cumulative): an incremental shrink. const plot::input::GestureEvent pinch_in = pinch.update(0.75f, {200.0f, 150.0f}, 1.0); PLOT_CHECK_NEAR(pinch_in.scale, 0.5, 1e-6); } // The focal point's movement is the two-finger pan, and it is mapped through the // ratio in force *now* from an anchor kept in **logical** pixels -- the same // convention as PointerAnchor above, because the same defect applies: a pinch // that crosses onto a display with another scale factor must not read the ratio // change as a focal jump (which the controller would apply as a pan). PLOT_TEST(pinch_tracker_maps_focal_movement_through_the_current_dpr) { plot::qt::PinchTracker pinch; pinch.begin(1.0f, {200.0f, 150.0f}); // A plain pan at 1.0: ten logical pixels right, no zoom. const plot::input::GestureEvent moved = pinch.update(1.0f, {210.0f, 150.0f}, 1.0); PLOT_CHECK_NEAR(moved.scale, 1.0, 1e-6); PLOT_CHECK_NEAR(moved.dx, 10.0, 1e-6); PLOT_CHECK_NEAR(moved.dy, 0.0, 1e-6); PLOT_CHECK_NEAR(moved.x, 210.0, 1e-6); // the focal point the zoom anchors on // Mid-pinch the window lands on the 1.25 display. The fingers move 4 more // logical pixels: the movement is 4 * 1.25 = 5 device pixels, and the anchor // the next frame is measured from is the new logical point -- not the old one // rescaled, which would read the ratio change itself as a jump. const plot::input::GestureEvent rescaled = pinch.update(1.0f, {214.0f, 150.0f}, 1.25); PLOT_CHECK_NEAR(rescaled.scale, 1.0, 1e-6); PLOT_CHECK_NEAR(rescaled.dx, 5.0, 1e-6); PLOT_CHECK_NEAR(rescaled.dy, 0.0, 1e-6); PLOT_CHECK_NEAR(rescaled.x, 214.0 * 1.25, 1e-6); PLOT_CHECK_NEAR(rescaled.y, 150.0 * 1.25, 1e-6); // A diagonal movement, both axes mapped. const plot::input::GestureEvent diagonal = pinch.update(1.0f, {214.0f, 154.0f}, 1.25); PLOT_CHECK_NEAR(diagonal.dx, 0.0, 1e-6); PLOT_CHECK_NEAR(diagonal.dy, 5.0, 1e-6); } // An update outside a gesture -- before any begin, or after the end/cancel -- // zooms and moves nothing: a host that missed the start (or a straggler event // Qt delivers out of order) must not zoom from a made-up anchor. PLOT_TEST(pinch_tracker_ignores_updates_outside_a_gesture) { plot::qt::PinchTracker pinch; const plot::input::GestureEvent orphan = pinch.update(2.0f, {10.0f, 10.0f}, 1.0); PLOT_CHECK_NEAR(orphan.scale, 1.0, 1e-9); PLOT_CHECK_NEAR(orphan.dx, 0.0, 1e-9); PLOT_CHECK_NEAR(orphan.dy, 0.0, 1e-9); pinch.begin(1.0f, {0.0f, 0.0f}); pinch.end(); PLOT_CHECK(!pinch.active); const plot::input::GestureEvent straggler = pinch.update(2.0f, {10.0f, 10.0f}, 1.0); PLOT_CHECK_NEAR(straggler.scale, 1.0, 1e-9); PLOT_CHECK_NEAR(straggler.dx, 0.0, 1e-9); PLOT_CHECK_NEAR(straggler.dy, 0.0, 1e-9); }