Skip to content

Commit 0357eab

Browse files
committed
Add new MultilineSelectionHandler class
1 parent 6932de7 commit 0357eab

File tree

8 files changed

+321
-161
lines changed

8 files changed

+321
-161
lines changed

doc/features/tools/reference.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ Shape tools
8484
:members:
8585
.. autoclass:: plotpy.tools.AnnotatedSegmentTool
8686
:members:
87+
.. autoclass:: plotpy.tools.AnnotatedPolygonTool
88+
:members:
8789
.. autoclass:: plotpy.tools.HRangeTool
8890
:members:
8991

plotpy/events.py

Lines changed: 176 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,9 @@
9393
from qtpy import QtWidgets as QW
9494

9595
from plotpy.config import CONF
96+
from plotpy.constants import SHAPE_Z_OFFSET
9697
from plotpy.coords import axes_to_canvas, canvas_to_axes
97-
from plotpy.items.shape.marker import Marker
98+
from plotpy.items import AnnotatedPolygon, Marker, PolygonShape
9899

99100
if TYPE_CHECKING:
100101
from qtpy.QtCore import QPoint
@@ -1134,16 +1135,16 @@ class QtDragHandler(DragHandler):
11341135
"""Class to handle drag events using Qt signals."""
11351136

11361137
#: Signal emitted by QtDragHandler when starting tracking
1137-
SIG_START_TRACKING = QC.Signal(object, "QEvent")
1138+
SIG_START_TRACKING = QC.Signal(object, "QMouseEvent")
11381139

11391140
#: Signal emitted by QtDragHandler when stopping tracking and not moving
1140-
SIG_STOP_NOT_MOVING = QC.Signal(object, "QEvent")
1141+
SIG_STOP_NOT_MOVING = QC.Signal(object, "QMouseEvent")
11411142

11421143
#: Signal emitted by QtDragHandler when stopping tracking and moving
1143-
SIG_STOP_MOVING = QC.Signal(object, "QEvent")
1144+
SIG_STOP_MOVING = QC.Signal(object, "QMouseEvent")
11441145

11451146
#: Signal emitted by QtDragHandler when moving
1146-
SIG_MOVE = QC.Signal(object, "QEvent")
1147+
SIG_MOVE = QC.Signal(object, "QMouseEvent")
11471148

11481149
def start_tracking(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
11491150
"""Starts tracking the drag event.
@@ -1165,8 +1166,7 @@ def stop_notmoving(self, filter: StatefulEventFilter, event: QMouseEvent) -> Non
11651166
self.SIG_STOP_NOT_MOVING.emit(filter, event)
11661167

11671168
def stop_moving(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
1168-
"""
1169-
Stops the movement of the drag event.
1169+
"""Stops the movement of the drag event.
11701170
11711171
Args:
11721172
filter: The StatefulEventFilter instance.
@@ -1632,6 +1632,10 @@ def __init__(
16321632
) -> None:
16331633
super().__init__(filter, btn, mods, start_state)
16341634
self.avoid_null_shape = False
1635+
self.setup_shape_cb = None
1636+
self.shape = None
1637+
self.shape_h0 = None
1638+
self.shape_h1 = None
16351639

16361640
def set_shape(
16371641
self,
@@ -1766,6 +1770,171 @@ def stop_moving_action(
17661770
filter.plot.do_zoom_rect_view(self.start, QC.QPointF(event.pos()))
17671771

17681772

1773+
class MultilineSelectionHandler(DragHandler):
1774+
"""
1775+
A handler for handling multi-line selections.
1776+
1777+
This handler extends the DragHandler to handle multi-line selections.
1778+
1779+
Args:
1780+
filter: The StatefulEventFilter instance.
1781+
btn: The mouse button to match.
1782+
mods: The keyboard modifiers to match. (default: QC.Qt.NoModifier)
1783+
start_state: The starting state. (default: 0)
1784+
closed: Whether the polygon should be closed. (default: False)
1785+
"""
1786+
1787+
SIG_END_POLYLINE = QC.Signal(object, np.ndarray)
1788+
1789+
def __init__(
1790+
self,
1791+
filter: StatefulEventFilter,
1792+
btn: int,
1793+
mods: QC.Qt.KeyboardModifiers = QC.Qt.NoModifier,
1794+
start_state: int = 0,
1795+
closed: bool = False,
1796+
) -> None:
1797+
super().__init__(filter, btn, mods, start_state)
1798+
self.closed = closed
1799+
filter.add_event(
1800+
start_state,
1801+
KeyEventMatch((QC.Qt.Key_Enter, QC.Qt.Key_Return, QC.Qt.Key_Space)),
1802+
self.accept_polygonshape,
1803+
start_state,
1804+
)
1805+
filter.add_event(
1806+
start_state,
1807+
KeyEventMatch((QC.Qt.Key_Backspace, QC.Qt.Key_Escape)),
1808+
self.cancel_point,
1809+
start_state,
1810+
)
1811+
self.init_pos: QC.QPointF | None = None
1812+
self.shape: PolygonShape | AnnotatedPolygon | None = None
1813+
self.current_handle: int | None = None
1814+
self.setup_shape_cb: Callable | None = None
1815+
1816+
def set_shape(
1817+
self,
1818+
shape: PolygonShape | AnnotatedPolygon,
1819+
setup_shape_cb: Callable | None = None,
1820+
) -> None:
1821+
"""Set the shape.
1822+
1823+
Args:
1824+
shape: The shape.
1825+
setup_shape_cb: The setup shape callback.
1826+
"""
1827+
self.shape = shape
1828+
self.setup_shape_cb = setup_shape_cb
1829+
1830+
def start_tracking(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
1831+
"""Starts tracking the drag event.
1832+
1833+
Args:
1834+
filter: The StatefulEventFilter instance.
1835+
event: The QC.QEvent instance.
1836+
"""
1837+
if self.init_pos is None:
1838+
self.init_pos = QC.QPointF(event.pos())
1839+
1840+
def start_moving(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
1841+
"""Start moving the object.
1842+
1843+
Args:
1844+
filter: The StatefulEventFilter instance.
1845+
event: The mouse event.
1846+
"""
1847+
if self.init_pos is None:
1848+
return
1849+
if self.shape.plot() is None:
1850+
self.shape.set_points(None)
1851+
filter.plot.add_item_with_z_offset(self.shape, SHAPE_Z_OFFSET)
1852+
self.shape.setZ(filter.plot.get_max_z() + 1)
1853+
if self.setup_shape_cb is not None:
1854+
self.setup_shape_cb(self.shape)
1855+
self.shape.add_local_point(self.init_pos)
1856+
self.current_handle = self.shape.add_local_point(QC.QPointF(event.pos()))
1857+
self.shape.set_closed(self.closed and len(self.shape.get_points()) > 2)
1858+
self.shape.show()
1859+
filter.plot.replot()
1860+
1861+
def move(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
1862+
"""
1863+
Handle mouse move event to update the position of the last point.
1864+
1865+
Args:
1866+
filter: The plot filter.
1867+
event: The mouse event.
1868+
"""
1869+
self.shape.move_local_point_to(self.current_handle, QC.QPointF(event.pos()))
1870+
filter.plot.replot()
1871+
1872+
def stop_notmoving(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
1873+
"""Stops tracking when the drag event is not moving.
1874+
1875+
Args:
1876+
filter: The StatefulEventFilter instance.
1877+
event: The QC.QEvent instance.
1878+
"""
1879+
points = self.shape.get_points()
1880+
if len(points) > 1:
1881+
self.accept_polygonshape(filter, event)
1882+
1883+
def stop_moving(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
1884+
"""Stops the movement of the drag event.
1885+
1886+
Args:
1887+
filter: The StatefulEventFilter instance.
1888+
event: The QC.QEvent instance
1889+
"""
1890+
pos = QC.QPointF(event.pos())
1891+
if self.init_pos == pos:
1892+
self.shape.del_point(-1)
1893+
else:
1894+
self.shape.move_local_point_to(self.current_handle, pos)
1895+
filter.plot.replot()
1896+
1897+
def accept_polygonshape(
1898+
self, filter: StatefulEventFilter, event: QMouseEvent
1899+
) -> None:
1900+
"""Accept the polygon shape.
1901+
1902+
Args:
1903+
filter: The StatefulEventFilter instance.
1904+
event: The mouse event.
1905+
"""
1906+
if self.shape.plot() is None:
1907+
return
1908+
filter.plot.del_item(self.shape)
1909+
self.init_pos = None
1910+
self.current_handle = None
1911+
self.SIG_END_POLYLINE.emit(filter, self.shape.get_points())
1912+
1913+
def cancel_point(self, filter: StatefulEventFilter, event: QMouseEvent) -> None:
1914+
"""
1915+
Cancel the last point or remove the shape if it has less than 3 points.
1916+
1917+
Args:
1918+
filter: The plot filter.
1919+
event: The triggering event.
1920+
"""
1921+
points = self.shape.get_points()
1922+
if points is None or self.shape.plot() is None:
1923+
return
1924+
if len(points) <= 2:
1925+
filter.plot.del_item(self.shape)
1926+
self.init_pos = None
1927+
self.shape.closed = False
1928+
else:
1929+
if self.current_handle:
1930+
newh = self.shape.del_point(self.current_handle)
1931+
else:
1932+
newh = self.shape.del_point(-1)
1933+
self.current_handle = newh
1934+
self.shape.set_closed(self.closed and len(self.shape.get_points()) > 2)
1935+
filter.plot.replot()
1936+
1937+
17691938
def setup_standard_tool_filter(filter: StatefulEventFilter, start_state: int) -> int:
17701939
"""Creation of standard filters (pan/zoom) on middle/right buttons
17711940

plotpy/items/annotation.py

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from qtpy.QtGui import QPainter
4848

4949
from plotpy.interfaces import IItemType
50+
from plotpy.plot import BasePlot
5051
from plotpy.styles.base import ItemParameters
5152

5253

@@ -638,7 +639,7 @@ def __init__(
638639
self.setIcon(get_icon("polygon.png"))
639640

640641
# ----Public API-------------------------------------------------------------
641-
def set_points(self, points: list[tuple[float, float]] | None) -> None:
642+
def set_points(self, points: list[tuple[float, float]] | np.ndarray | None) -> None:
642643
"""Set the polygon points
643644
644645
Args:
@@ -647,26 +648,99 @@ def set_points(self, points: list[tuple[float, float]] | None) -> None:
647648
self.shape.set_points(points)
648649
self.set_label_position()
649650

650-
def get_points(self) -> list[tuple[float, float]]:
651-
"""Return the polygon points"""
651+
def get_points(self) -> np.ndarray:
652+
"""Return polygon points
653+
654+
Returns:
655+
Polygon points (array of shape (N, 2))
656+
"""
652657
return self.shape.get_points()
653658

654659
def set_closed(self, state: bool) -> None:
655-
"""Set the polygon closed state
660+
"""Set closed state
656661
657662
Args:
658-
state: True if polygon is closed
663+
state: True if the polygon is closed, False otherwise
659664
"""
660665
self.shape.set_closed(state)
661666

662667
def is_closed(self) -> bool:
663-
"""Return True if polygon is closed
668+
"""Return True if the polygon is closed, False otherwise
664669
665670
Returns:
666-
True if polygon is closed
671+
True if the polygon is closed, False otherwise
667672
"""
668673
return self.shape.is_closed()
669674

675+
def is_empty(self) -> bool:
676+
"""Return True if the item is empty
677+
678+
Returns:
679+
True if the item is empty, False otherwise
680+
"""
681+
return self.shape.is_empty()
682+
683+
def add_local_point(self, pos: tuple[float, float]) -> int:
684+
"""Add a point in canvas coordinates (local coordinates)
685+
686+
Args:
687+
pos: Position
688+
689+
Returns:
690+
Handle of the added point
691+
"""
692+
pt = canvas_to_axes(self, pos)
693+
return self.add_point(pt)
694+
695+
def add_point(self, pt: tuple[float, float]) -> int:
696+
"""Add a point in axis coordinates
697+
698+
Args:
699+
pt: Position
700+
701+
Returns:
702+
Handle of the added point
703+
"""
704+
handle = self.shape.add_point(pt)
705+
self.set_label_position()
706+
return handle
707+
708+
def del_point(self, handle: int) -> int:
709+
"""Delete a point
710+
711+
Args:
712+
handle: Handle
713+
714+
Returns:
715+
Handle of the deleted point
716+
"""
717+
handle = self.shape.del_point(handle)
718+
self.set_label_position()
719+
return handle
720+
721+
def move_local_point_to(self, handle: int, pos: QPointF, ctrl: bool = None) -> None:
722+
"""Move a handle as returned by hit_test to the new position
723+
724+
Args:
725+
handle: Handle
726+
pos: Position
727+
ctrl: True if <Ctrl> button is being pressed, False otherwise
728+
"""
729+
pt = canvas_to_axes(self, pos)
730+
self.move_point_to(handle, pt)
731+
732+
def move_shape(
733+
self, old_pos: tuple[float, float], new_pos: tuple[float, float]
734+
) -> None:
735+
"""Translate the shape such that old_pos becomes new_pos in axis coordinates
736+
737+
Args:
738+
old_pos: Old position
739+
new_pos: New position
740+
"""
741+
self.shape.move_shape(old_pos, new_pos)
742+
self.set_label_position()
743+
670744
# ----AnnotatedShape API-----------------------------------------------------
671745
def create_shape(self):
672746
"""Return the shape object associated to this annotated shape object"""

0 commit comments

Comments
 (0)