127 lines
5.6 KiB
Python
127 lines
5.6 KiB
Python
"""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; Ctrl + ЛКМ или Shift + ЛКМ — перемещение по Y. '
|
|
'Щелчок мимо маркеров — одиночный курсор.')
|
|
|
|
|
|
def vertical_pan_requested(event):
|
|
return bool(event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier))
|
|
|
|
|
|
def cursor_hit(plot, position, value, event, radius=8):
|
|
"""Ctrl/Shift always pan; cursors are grabbed near their visible lines."""
|
|
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 _capture_y_pan(self):
|
|
return self.viewport
|
|
|
|
def _apply_y_pan(self, fraction, initial):
|
|
if self.core and not self.lock_y:
|
|
self.viewport = initial.transform(self.core, pan_y=fraction)
|
|
|
|
def _navigation_pan_y(self, fraction):
|
|
self._apply_y_pan(fraction, self._capture_y_pan())
|
|
self._refresh_controls()
|
|
self.update()
|
|
self.view_changed.emit()
|
|
|
|
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_y_pan = self._capture_y_pan()
|
|
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)
|
|
elif self._pan_vertical:
|
|
self._apply_y_pan(delta.y() / values.height(), self._press_y_pan)
|
|
else:
|
|
self.viewport = self._press_viewport.transform(self.core,
|
|
pan_x=delta.x() / rect.width() if not self.lock_x else 0)
|
|
self._refresh_controls()
|
|
if self._dragged is not None:
|
|
getattr(self, "update_overlay", self.update)()
|
|
else:
|
|
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."""
|