From e2527225a24e2b6d78f381612823f6b494543868 Mon Sep 17 00:00:00 2001 From: Ioannis Makris Date: Wed, 11 Nov 2026 16:01:56 +0100 Subject: [PATCH] Fix value-axis jumps when enabling drag modifiers Advance the vertical pointer reference on every engaged drag move so Ctrl and Alt consume only new movement. Keep initial click tolerance and horizontal routing unchanged. Add regressions for modifier transitions, re-enablement, stationary and reverse movement, local/shared time axes, and click tolerance. Fixes #33. --- src/qt/plot_interaction_item.cpp | 4 +- tests/test_plot_interaction_item.cpp | 124 +++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/qt/plot_interaction_item.cpp b/src/qt/plot_interaction_item.cpp index e766001..0f283f0 100644 --- a/src/qt/plot_interaction_item.cpp +++ b/src/qt/plot_interaction_item.cpp @@ -294,9 +294,9 @@ void Plot_interaction_item::mouseMoveEvent(QMouseEvent* event) const bool ctrl_held = mods & Qt::ControlModifier; const bool alt_held = mods & Qt::AltModifier; + const qreal dy = y - m_drag_last_y; + m_drag_last_y = y; if (ctrl_held || alt_held) { - const qreal dy = y - m_drag_last_y; - m_drag_last_y = y; m_plot_widget->adjust_v_from_mouse_diff(usable_height(), dy); } diff --git a/tests/test_plot_interaction_item.cpp b/tests/test_plot_interaction_item.cpp index 4ca1239..3b82409 100644 --- a/tests/test_plot_interaction_item.cpp +++ b/tests/test_plot_interaction_item.cpp @@ -46,6 +46,8 @@ class test_interaction_item_t : public plot::Plot_interaction_item public: using plot::Plot_interaction_item::wheelEvent; using plot::Plot_interaction_item::mousePressEvent; + using plot::Plot_interaction_item::mouseMoveEvent; + using plot::Plot_interaction_item::mouseReleaseEvent; using plot::Plot_interaction_item::mouseDoubleClickEvent; }; @@ -444,6 +446,125 @@ bool test_wheel_zoom_handles_near_zero_value_range() return true; } +bool check_drag_modifier_consumes_only_new_vertical_movement(Qt::KeyboardModifier modifier) +{ + for (const bool shared_time_axis : {false, true}) { + plot::Plot_time_axis time_axis; + plot::Plot_widget widget; + if (shared_time_axis) { + widget.set_time_axis(&time_axis); + } + configure_view(widget, 1000, 2000, 0.0f, 100.0f); + widget.set_available_t_range(0, 3000); + widget.set_v_auto(true); + + test_interaction_item_t item; + item.set_plot_widget(&widget); + item.setSize(QSizeF(1000.0 + widget.vbar_width_qml(), 500.0 + widget.reserved_height())); + int clicks = 0; + QObject::connect(&item, &plot::Plot_interaction_item::mouse_clicked, + [&clicks](qreal, qreal) { ++clicks; }); + + QMouseEvent press(QEvent::MouseButtonPress, QPointF(100.0, 100.0), QPointF(100.0, 100.0), + Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + item.mousePressEvent(&press); + TEST_ASSERT(press.isAccepted(), "main plot press should begin the gesture"); + + struct move_t { qreal x; qreal y; bool modified; double applied_y; }; + const move_t moves[] = { + {150.0, 200.0, false, 0.0}, + {151.0, 201.0, true, 1.0}, // Enable after 100 unmodified vertical pixels. + {170.0, 280.0, false, 1.0}, + {170.0, 280.0, true, 1.0}, // Re-enable without movement. + {171.0, 281.0, true, 2.0}, + {150.0, 100.0, false, 2.0}, // Cross the original y while remaining outside click slop. + {150.0, 100.0, true, 2.0}, + {151.0, 99.0, true, 1.0}, + }; + qreal previous_x = 100.0; + qint64 expected_time_min = 1000; + for (const auto& move : moves) { + const Qt::KeyboardModifiers modifiers = move.modified ? modifier : Qt::NoModifier; + QMouseEvent event(QEvent::MouseMove, QPointF(move.x, move.y), QPointF(move.x, move.y), + Qt::NoButton, Qt::LeftButton, modifiers); + item.mouseMoveEvent(&event); + const double expected_min = move.applied_y / 5.0; + if (!nearly_equal(widget.v_min(), expected_min, 1e-5)) { + std::cerr << "modifier=" << static_cast(modifier) << " y=" << move.y + << " v_min=" << widget.v_min() << " expected=" << expected_min << '\n'; + } + TEST_ASSERT(nearly_equal(widget.v_min(), expected_min, 1e-5) && + nearly_equal(widget.v_max(), 100.0 + expected_min, 1e-5), + "enabling a drag modifier must consume only new vertical movement"); + TEST_ASSERT(widget.v_auto() == (move.applied_y == 0.0), + "only value-axis movement should leave automatic scaling"); + + if (!move.modified || modifier != Qt::AltModifier) { + expected_time_min -= static_cast(move.x - previous_x); + } + previous_x = move.x; + TEST_ASSERT(widget.t_min() == expected_time_min && widget.t_max() == expected_time_min + 1000, + "Ctrl pans both axes while Alt keeps time unchanged, without replay on release"); + } + QMouseEvent release(QEvent::MouseButtonRelease, QPointF(151.0, 99.0), QPointF(151.0, 99.0), + Qt::LeftButton, Qt::NoButton, modifier); + item.mouseReleaseEvent(&release); + TEST_ASSERT(clicks == 0, "changing modifiers must not turn an engaged drag into a click"); + } + return true; +} + +bool test_ctrl_drag_consumes_only_new_vertical_movement() +{ + return check_drag_modifier_consumes_only_new_vertical_movement(Qt::ControlModifier); +} + +bool test_alt_drag_consumes_only_new_vertical_movement() +{ + return check_drag_modifier_consumes_only_new_vertical_movement(Qt::AltModifier); +} + +bool test_drag_modifier_preserves_initial_click_tolerance() +{ + for (const auto modifier : {Qt::ControlModifier, Qt::AltModifier}) { + plot::Plot_widget widget; + configure_view(widget, 1000, 2000, 0.0f, 100.0f); + test_interaction_item_t item; + item.set_plot_widget(&widget); + item.setSize(QSizeF(1000.0 + widget.vbar_width_qml(), 500.0 + widget.reserved_height())); + int clicks = 0; + QObject::connect(&item, &plot::Plot_interaction_item::mouse_clicked, + [&clicks](qreal, qreal) { ++clicks; }); + + QMouseEvent press(QEvent::MouseButtonPress, QPointF(100.0, 100.0), QPointF(100.0, 100.0), + Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + item.mousePressEvent(&press); + for (const auto modifiers : {Qt::NoModifier, modifier}) { + QMouseEvent move(QEvent::MouseMove, QPointF(100.0, 102.0), QPointF(100.0, 102.0), + Qt::NoButton, Qt::LeftButton, modifiers); + item.mouseMoveEvent(&move); + } + TEST_ASSERT(widget.v_min() == 0.0f && widget.v_max() == 100.0f && + widget.t_min() == 1000 && widget.t_max() == 2000, + "modifier changes inside initial click tolerance must not pan either axis"); + QMouseEvent release(QEvent::MouseButtonRelease, QPointF(100.0, 102.0), QPointF(100.0, 102.0), + Qt::LeftButton, Qt::NoButton, modifier); + item.mouseReleaseEvent(&release); + TEST_ASSERT(clicks == 1, "movement within initial tolerance must remain a click"); + + item.mousePressEvent(&press); + QMouseEvent slop(QEvent::MouseMove, QPointF(100.0, 102.0), QPointF(100.0, 102.0), + Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + item.mouseMoveEvent(&slop); + QMouseEvent drag(QEvent::MouseMove, QPointF(100.0, 105.0), QPointF(100.0, 105.0), + Qt::NoButton, Qt::LeftButton, modifier); + item.mouseMoveEvent(&drag); + TEST_ASSERT(widget.v_min() == 1.0f && widget.v_max() == 101.0f, + "the first engaged move must preserve the full displacement from the press"); + } + return true; +} + bool test_vertical_interaction_minimum_preserves_ranges() { plot::Plot_widget widget; @@ -1548,6 +1669,9 @@ int main(int argc, char** argv) RUN_TEST(test_zoom_math_handles_zero_velocity); RUN_TEST(test_wheel_event_uses_platform_pixel_policy); RUN_TEST(test_wheel_zoom_handles_near_zero_value_range); + RUN_TEST(test_ctrl_drag_consumes_only_new_vertical_movement); + RUN_TEST(test_alt_drag_consumes_only_new_vertical_movement); + RUN_TEST(test_drag_modifier_preserves_initial_click_tolerance); RUN_TEST(test_vertical_interaction_minimum_preserves_ranges); RUN_TEST(test_restoring_autoscale_cancels_vertical_zoom_inertia); RUN_TEST(test_time_wheel_preserves_vertical_scaling_mode);