From 3463e95eb3dcedddadedb3a9e552d9d74f95c2ec Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 2 Oct 2026 16:41:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B5=D0=B5=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=D1=81=D0=BA=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=88=D0=BA=D0=B0=D0=BB=D1=8B=20=D0=B0=D0=BC?= =?UTF-8?q?=D0=BF=D0=BB=D0=B8=D1=82=D1=83=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/set_devices/qt_ports/plot_drag.py | 108 +++++++++++++++ python/set_devices/qt_ports/plot_dual_axis.py | 124 +++++++++++++++++ python/tests/test_plot_drag_qt.py | 129 ++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 python/set_devices/qt_ports/plot_drag.py create mode 100644 python/set_devices/qt_ports/plot_dual_axis.py create mode 100644 python/tests/test_plot_drag_qt.py diff --git a/python/set_devices/qt_ports/plot_drag.py b/python/set_devices/qt_ports/plot_drag.py new file mode 100644 index 0000000..0ca0efc --- /dev/null +++ b/python/set_devices/qt_ports/plot_drag.py @@ -0,0 +1,108 @@ +"""Shared marker picking and axis-constrained plot drags (Qt 5/6). + +The widget supplies plot/value rectangles, a PlotMath core, viewport/markers, +axis locks and refresh hooks. Geometry and numeric transforms use protocan.plot. +""" +from dataclasses import replace + +try: + from PySide6.QtCore import QPointF, Qt + from PySide6.QtWidgets import QApplication +except ImportError: + from PySide2.QtCore import QPointF, Qt + from PySide2.QtWidgets import QApplication + + +DRAG_HINT = ('ЛКМ рядом с маркером — двигать маркер; ЛКМ на свободном поле — ' + 'перемещение по X; Shift + ЛКМ — перемещение по Y. ' + 'Щелчок мимо маркеров — одиночный курсор.') + + +def vertical_pan_requested(event): + return bool(event.modifiers() & Qt.ShiftModifier) + + +def cursor_hit(plot, position, value, event, radius=8): + """Shift always pans; a cursor can only be grabbed near its visible line.""" + return (not vertical_pan_requested(event) and value is not None + and plot.core is not None and plot.has_plot_data + and plot.visible_bounds.left <= value <= plot.visible_bounds.right + and plot.plot_rect().contains(position) + and abs(plot.project(value, 0, plot.plot_rect()).x() - position.x()) <= radius) + + +class PlotDragInteraction: + """Capture a hit marker or pan one axis, chosen at the start of the drag.""" + + def mousePressEvent(self, event): + if event.button() != Qt.LeftButton or not self.core: + return super().mousePressEvent(event) + pos = event.position() if hasattr(event, 'position') else event.localPos() + rect, values = self.plot_rect(), self.value_rect() + self._press = self._dragged = None + if not rect.contains(pos): + return + self.setFocus(Qt.MouseFocusReason) + self._press = QPointF(pos) + self._press_viewport = self.viewport + self._press_markers = self.markers + self._press_bounds = self.visible_bounds + self._pan_vertical = vertical_pan_requested(event) + self._pan_moved = False + if self.marker_mode.isChecked() and not self._pan_vertical and self.has_plot_data: + # X lines span digital lanes; Y lines only occupy the analog pane. + markers = self.markers if values.contains(pos) else replace(self.markers, y_enabled=False) + self._dragged = markers.hit(self.core, pos.x() - rect.left(), + pos.y() - values.top() if values.contains(pos) else 0, + rect.width(), values.height(), 12, self._press_bounds) + if self._dragged is not None: + self.markers = replace(self.markers, selected=self._dragged) + self._refresh_controls() + self.update() + event.accept() + + def mouseMoveEvent(self, event): + if not event.buttons() & Qt.LeftButton: + self._press = self._dragged = None + return super().mouseMoveEvent(event) + if self._press is None or not self.core: + return super().mouseMoveEvent(event) + pos = event.position() if hasattr(event, 'position') else event.localPos() + self._update_drag(pos) + event.accept() + + def _update_drag(self, pos): + delta = pos - self._press + self._pan_moved |= delta.manhattanLength() >= QApplication.startDragDistance() + rect, values = self.plot_rect(), self.value_rect() + if self._dragged is not None: + marker = self._dragged + self.markers = self._press_markers.drag(self.core, marker, + delta.y() if marker.horizontal else delta.x(), + values.height() if marker.horizontal else rect.width(), self._press_bounds) + else: + self.viewport = self._press_viewport.transform(self.core, + pan_x=delta.x() / rect.width() if not self._pan_vertical and not self.lock_x else 0, + pan_y=delta.y() / values.height() if self._pan_vertical and not self.lock_y else 0) + self._refresh_controls() + self.update() + + def mouseReleaseEvent(self, event): + if event.button() != Qt.LeftButton: + return super().mouseReleaseEvent(event) + if self._press is not None and self.core: + # Qt may coalesce moves; always commit the final release position. + pos = event.position() if hasattr(event, 'position') else event.localPos() + self._update_drag(pos) + single_click = (self._dragged is None and not self._pan_vertical + and not self._pan_moved and self.plot_rect().contains(pos)) + if single_click: + # A click with small hand jitter must not pan the time axis. + self.viewport = self._press_viewport + self._single_cursor_click(pos) + self.view_changed.emit() + self._press = self._dragged = None + super().mouseReleaseEvent(event) + + def _single_cursor_click(self, pos): + """Optional widget hook: return from paired markers to a single cursor.""" diff --git a/python/set_devices/qt_ports/plot_dual_axis.py b/python/set_devices/qt_ports/plot_dual_axis.py new file mode 100644 index 0000000..6443416 --- /dev/null +++ b/python/set_devices/qt_ports/plot_dual_axis.py @@ -0,0 +1,124 @@ +"""Independent amplitude axes for SignalPlot-compatible Qt canvases.""" +import math + +try: + from PySide6.QtCore import QPointF, Qt + from PySide6.QtGui import QColor +except ImportError: + from PySide2.QtCore import QPointF, Qt + from PySide2.QtGui import QColor +from protocan.plot import Bounds, Viewport + + +def split_amplitudes(series, minimum_ratio=10): + """Split at the largest decade gap; zero-valued channels join small ones.""" + magnitudes = sorted((max((abs(v) for v in s.values if math.isfinite(v)), default=0), s.key) + for s in series if s.visible and not s.discrete and len(s.values)) + positive = [(m, key) for m, key in magnitudes if m > 0] + gaps = [(positive[i + 1][0] / positive[i][0], positive[i][0]) + for i in range(len(positive) - 1)] + if not gaps or max(gaps)[0] < minimum_ratio: + return set() + threshold = max(gaps)[1] + return {key for magnitude, key in magnitudes if magnitude <= threshold} + + +class DualAxisPlot: + """Mixin: left axis for large channels, right for small channels. + + Shift-wheel selects the axis by the pointer's half of the analog pane. + The host supplies series_bounds/project_series rendering hooks. + """ + def __init__(self, *args, **kwargs): + self.right_axis_keys = set() + self.right_data_bounds = Bounds(0, 1, 0, 1) + self.right_viewport = Viewport() + super().__init__(*args, **kwargs) + + def _sync_bounds(self): + keys = split_amplitudes(self._series) + if keys != self.right_axis_keys: + self.right_viewport = Viewport() + self.right_axis_keys = keys + super()._sync_bounds() + + def set_plot_bounds(self, bounds, has_data=True, x_origin=0): + if self.right_axis_keys: + groups = [[], []] + for series in self._series: + if series.visible and not series.discrete: + groups[series.key in self.right_axis_keys].extend( + v for v in series.values if math.isfinite(v)) + ranges = [] + for values in groups: + low, high = (min(values), max(values)) if values else (0, 1) + pad = max((high - low) * .08, max(abs(low), abs(high)) * .01, 1e-6) + ranges.append(Bounds(bounds.left, bounds.right, low - pad, high + pad)) + bounds, self.right_data_bounds = ranges + super().set_plot_bounds(bounds, has_data, x_origin) + + @property + def right_visible_bounds(self): + right = (self.right_data_bounds.visible(self.core, self.right_viewport) + if self.core else self.right_data_bounds) + left = self.visible_bounds + return Bounds(left.left, left.right, right.bottom, right.top) + + def series_bounds(self, series): + return self.right_visible_bounds if series.key in self.right_axis_keys else self.visible_bounds + + def plot_rect(self): + rect = super().plot_rect() + if self.right_axis_keys: + rect.setWidth(max(1, rect.width() - 72)) + return rect + + def right_axis_margin(self): + return 72 if self.right_axis_keys else 0 + + def reset_viewport(self): + self.right_viewport = Viewport() + super().reset_viewport() + + def reset_interaction(self): + self.right_viewport = Viewport() + super().reset_interaction() + + def wheelEvent(self, event): + if not event.modifiers() & Qt.ShiftModifier: + return super().wheelEvent(event) + pos = event.position() if hasattr(event, 'position') else event.posF() + rect = self.value_rect() + if not rect.top() <= pos.y() <= rect.bottom(): + event.ignore() + return + ticks = event.angleDelta().y() or event.angleDelta().x() + if not ticks: + ticks = (event.pixelDelta().y() or event.pixelDelta().x()) * 2 + if ticks and self.core and not self.lock_y: + factor = 1.25 ** max(-8, min(8, ticks / 120)) + self.setFocus(Qt.MouseFocusReason) + if self.right_axis_keys and pos.x() >= rect.center().x(): + self.right_viewport = self.right_viewport.transform( + self.core, 1, factor, focus_y=(pos.y() - rect.top()) / rect.height()) + self.update() + self.view_changed.emit() + else: + self._navigation_zoom(factor, True, pos) + event.accept() + + def paint_axes_and_markers(self, painter): + super().paint_axes_and_markers(painter) + if not self.right_axis_keys or not self.core: + return + rect, bounds = self.value_rect(), self.right_visible_bounds + painter.save() + painter.setPen(QColor('#a4b8cd')) + step = self.core.tick_step(bounds.top - bounds.bottom, rect.height()) + first = math.ceil(bounds.bottom / step) + count = min(100, max(0, math.floor(bounds.top / step) - first + 1)) + for index in range(count): + value = (first + index) * step + y = rect.top() + bounds.fraction(self.core, value, True) * rect.height() + painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.4g}') + painter.restore() diff --git a/python/tests/test_plot_drag_qt.py b/python/tests/test_plot_drag_qt.py new file mode 100644 index 0000000..925dec8 --- /dev/null +++ b/python/tests/test_plot_drag_qt.py @@ -0,0 +1,129 @@ +"""Shared drag gestures, with real Qt events and C math, without SETGUI imports.""" +import ctypes +import os +import unittest +from dataclasses import replace + +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') +try: + from PySide6.QtCore import QEvent, QPoint, QPointF, QRectF, Qt, Signal + from PySide6.QtGui import QMouseEvent + from PySide6.QtTest import QTest + from PySide6.QtWidgets import QApplication, QCheckBox, QWidget +except ImportError: + from PySide2.QtCore import QEvent, QPoint, QPointF, QRectF, Qt, Signal + from PySide2.QtGui import QMouseEvent + from PySide2.QtTest import QTest + from PySide2.QtWidgets import QApplication, QCheckBox, QWidget + +from protocan.plot import Bounds, Marker, Markers, PlotMath, Viewport +from set_devices.qt_ports.plot_drag import PlotDragInteraction + + +class Canvas(PlotDragInteraction, QWidget): + view_changed = Signal() + + def __init__(self, core): + super().__init__() + self.core = core + self.viewport = Viewport(.25, .25, .5, .5) + self.markers = replace(Markers(a=40, b=60, e=50, f=65), y_enabled=True) + self.marker_mode = QCheckBox(self) + self.marker_mode.setChecked(True) + self.marker_mode.hide() + self.has_plot_data = True + self.lock_x = self.lock_y = False + self._press = self._dragged = None + self.single_clicks = [] + self.resize(440, 340) + + @property + def visible_bounds(self): + return Bounds(0, 100, 0, 100).visible(self.core, self.viewport) + + def plot_rect(self): + return QRectF(20, 20, 400, 300) + + def value_rect(self): + return QRectF(20, 20, 400, 200) + + def _refresh_controls(self): + pass + + def _single_cursor_click(self, pos): + self.single_clicks.append(pos) + + +@unittest.skipUnless(os.environ.get('SETPROTOCOL_LIBRARY'), 'Set SETPROTOCOL_LIBRARY') +class PlotDragTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + cls.core = PlotMath(ctypes.CDLL(os.environ['SETPROTOCOL_LIBRARY'])) + + def setUp(self): + self.plot = Canvas(self.core) + self.plot.show() + self.app.processEvents() + + def tearDown(self): + self.plot.close() + + def drag(self, start, end, modifiers=Qt.NoModifier): + QTest.mousePress(self.plot, Qt.LeftButton, modifiers, start) + QTest.mouseRelease(self.plot, Qt.LeftButton, modifiers, end) + + def test_empty_space_pans_x_and_preserves_markers(self): + before, markers = self.plot.viewport, self.plot.markers + self.drag(QPoint(220, 180), QPoint(260, 210)) + self.assertEqual(before.transform(self.core, pan_x=.1), self.plot.viewport) + self.assertEqual(markers, self.plot.markers) + + def test_shift_over_marker_pans_only_y_and_respects_lock(self): + before, markers = self.plot.viewport, self.plot.markers + self.drag(QPoint(140, 180), QPoint(180, 200), Qt.ShiftModifier) + self.assertEqual(before.transform(self.core, pan_y=.1), self.plot.viewport) + self.assertEqual(markers, self.plot.markers) + self.plot.lock_y = True + before = self.plot.viewport + self.drag(QPoint(140, 180), QPoint(180, 200), Qt.ShiftModifier) + self.assertEqual(before, self.plot.viewport) + + def test_marker_capture_in_analog_and_digital_panes(self): + before = self.plot.viewport + # X marker A at x=140 remains draggable in a digital lane. + self.drag(QPoint(145, 270), QPoint(185, 290)) + self.assertAlmostEqual(45, self.plot.markers.a) + self.assertEqual(before, self.plot.viewport) + # Y marker E at y=120 uses the analog pane's height. + self.drag(QPoint(220, 125), QPoint(240, 145)) + self.assertAlmostEqual(45, self.plot.markers.e) + self.assertEqual(before, self.plot.viewport) + + def test_click_miss_and_locked_x_do_not_move_markers(self): + before, markers = self.plot.viewport, self.plot.markers + self.drag(QPoint(220, 180), QPoint(220, 180)) + self.assertEqual(before, self.plot.viewport) + self.assertEqual(markers, self.plot.markers) + self.assertEqual(1, len(self.plot.single_clicks)) + self.plot.lock_x = True + self.drag(QPoint(220, 180), QPoint(260, 210)) + self.assertEqual(before, self.plot.viewport) + self.assertEqual(markers, self.plot.markers) + self.assertEqual(1, len(self.plot.single_clicks)) + + def test_marker_click_and_drag_returning_to_start_are_not_single_clicks(self): + point = QPoint(220, 180) + self.drag(QPoint(140, 180), QPoint(140, 180)) + QTest.mousePress(self.plot, Qt.LeftButton, pos=point) + moved = point + QPoint(40, 0) + event = QMouseEvent(QEvent.MouseMove, QPointF(moved), + QPointF(self.plot.mapToGlobal(moved)), + Qt.NoButton, Qt.LeftButton, Qt.NoModifier) + self.app.sendEvent(self.plot, event) + QTest.mouseRelease(self.plot, Qt.LeftButton, pos=point) + self.assertEqual([], self.plot.single_clicks) + + +if __name__ == '__main__': + unittest.main()