openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,1414 @@
1
+ """The net canvas: the drawing surface and its editing tools.
2
+
3
+ Two classes:
4
+
5
+ :class:`NetScene`
6
+ Owns the mapping from model objects to graphics items and rebuilds the
7
+ scene when the page changes. It also implements the editing tools, because
8
+ a tool's behaviour is defined by where you click in the *scene*.
9
+
10
+ :class:`NetView`
11
+ A thin :class:`QGraphicsView` adding zooming and panning. Kept separate so
12
+ that the interaction logic in the scene is testable without a viewport.
13
+
14
+ Editing model
15
+ -------------
16
+ The canvas edits the model objects **in place**. There is no separate
17
+ "document" layer: dragging a place writes its new coordinates straight onto
18
+ ``place.graphics``. Signals tell the main window what changed so it can mark
19
+ the file dirty and refresh the panels.
20
+
21
+ Tools
22
+ -----
23
+ ``select``
24
+ Default. Click to select, drag to move.
25
+ ``place`` / ``transition``
26
+ Click on empty canvas to create one there.
27
+ ``arc``
28
+ Click a place then a transition (or the reverse) to connect them. The
29
+ direction is inferred from the order of the two clicks, which is how CPN
30
+ Tools behaves and saves having two separate arc tools.
31
+
32
+ With ``select``, the arrow beside a hovered node draws arcs the same way.
33
+ Dragging an arc (either way) onto empty canvas adds the other kind of node
34
+ there, joined by it -- the quick way to grow a net.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from typing import Callable
40
+
41
+ from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal
42
+ from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QTransform
43
+ from PySide6.QtWidgets import (
44
+ QApplication, QGraphicsItem, QGraphicsLineItem, QGraphicsPathItem, QGraphicsScene,
45
+ QGraphicsSceneMouseEvent, QGraphicsView, QWidget,
46
+ )
47
+
48
+ from ..model.net import Arc, CPNet, Page, Place, Transition
49
+ from . import arc_editing as edit
50
+ from . import theme
51
+ from .panning import CanvasPanning
52
+ from .items import (
53
+ PLAIN_PLACE, PLAIN_TRANSITION, ArcItem, PlaceItem, TransitionItem, to_model,
54
+ )
55
+
56
+ #: Grid spacing in scene units: the background's dots, and what nodes and
57
+ #: arc bends snap to when :attr:`NetScene.snap_to_grid` is on.
58
+ GRID_STEP = 28
59
+
60
+
61
+ def on_grid(point: QPointF) -> QPointF:
62
+ """The grid dot nearest to ``point``."""
63
+ return QPointF(round(point.x() / GRID_STEP) * GRID_STEP,
64
+ round(point.y() / GRID_STEP) * GRID_STEP)
65
+ #: Perpendicular separation between two arcs joining the same pair of nodes.
66
+ PARALLEL_ARC_SPACING = 14.0
67
+
68
+
69
+ class _ConnectHandle(QGraphicsPathItem):
70
+ """The translucent arrow that appears next to a hovered place or
71
+ transition: drag it to another node to draw an arc. It sits on the
72
+ side facing the mouse and keeps the same size at every zoom."""
73
+
74
+ def __init__(self) -> None:
75
+ super().__init__()
76
+ self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
77
+ self.setZValue(60)
78
+ self.setAcceptHoverEvents(True)
79
+ self.setCursor(Qt.CrossCursor)
80
+ self.setToolTip("Drag to another place or transition to add an arc")
81
+ path = QPainterPath()
82
+ path.moveTo(-9, -2.2)
83
+ path.lineTo(3, -2.2)
84
+ path.lineTo(3, -7)
85
+ path.lineTo(11, 0)
86
+ path.lineTo(3, 7)
87
+ path.lineTo(3, 2.2)
88
+ path.lineTo(-9, 2.2)
89
+ path.closeSubpath()
90
+ self.setPath(path)
91
+ accent = theme.palette().accent
92
+ self.setBrush(accent)
93
+ self.setPen(QPen(QColor(255, 255, 255, 140), 1.0))
94
+ #: The node it belongs to.
95
+ self.node = None
96
+ self.set_hot(False)
97
+
98
+ #: Opacity at rest (a hint that it is there, not in the way) and with the
99
+ #: mouse on it (clear enough to aim at).
100
+ RESTING, HOT = 0.3, 0.75
101
+
102
+ def set_node(self, node) -> None:
103
+ self.node = node
104
+ other = "transition" if isinstance(node, PlaceItem) else "place"
105
+ self.setToolTip(f"Drag to a {other} to add an arc, or to empty space to add "
106
+ f"a new {other} joined by one")
107
+
108
+ def set_hot(self, hot: bool) -> None:
109
+ """Mouse on the handle: clearer and a little larger."""
110
+ self.setOpacity(self.HOT if hot else self.RESTING)
111
+ self.setScale(1.15 if hot else 1.0)
112
+
113
+ def shape(self) -> QPainterPath:
114
+ path = QPainterPath()
115
+ path.addEllipse(QPointF(1, 0), 13, 13) # easy to grab
116
+ return path
117
+
118
+ def boundingRect(self) -> QRectF: # noqa: N802
119
+ return QRectF(-14, -14, 28, 28)
120
+
121
+
122
+ class _GhostNode(QGraphicsPathItem):
123
+ """While an arc is dragged over empty canvas: a see-through preview of
124
+ the place or transition that letting go there will add."""
125
+
126
+ def __init__(self) -> None:
127
+ super().__init__()
128
+ accent = theme.palette().accent
129
+ pen = QPen(accent, 1.6, Qt.DashLine)
130
+ pen.setCosmetic(True)
131
+ self.setPen(pen)
132
+ fill = QColor(accent)
133
+ fill.setAlpha(40)
134
+ self.setBrush(fill)
135
+ self.setZValue(41) # just above the rubber-band line
136
+ self.setAcceptedMouseButtons(Qt.NoButton)
137
+ self.kind: type | None = None
138
+
139
+ def show_as(self, kind: type, rect: QRectF, centre: QPointF, radius: float) -> None:
140
+ if kind is not self.kind or rect != self.path().boundingRect():
141
+ path = QPainterPath()
142
+ if kind is PlaceItem:
143
+ path.addEllipse(rect)
144
+ else:
145
+ path.addRoundedRect(rect, radius, radius)
146
+ self.setPath(path)
147
+ self.kind = kind
148
+ self.setPos(centre)
149
+ self.show()
150
+
151
+
152
+ def _rect(item: QGraphicsItem) -> tuple[float, float, float, float]:
153
+ """A node's outline in scene coordinates: (left, top, width, height)."""
154
+ rect = item.rect().translated(item.pos())
155
+ return rect.left(), rect.top(), rect.width(), rect.height()
156
+
157
+
158
+ class NetScene(QGraphicsScene):
159
+ """Draws one page of a model and handles clicks on it."""
160
+
161
+ #: Put new and moved nodes, and arc bends, on the grid's dots (one
162
+ #: setting for every editor; the Snap to grid box).
163
+ snap_to_grid = False
164
+
165
+ #: Emitted with the selected model object (or ``None``) when selection changes.
166
+ selection_changed = Signal(object)
167
+ #: Emitted whenever the model was modified by a canvas interaction.
168
+ model_changed = Signal()
169
+ #: Emitted when the user clicks a transition while the simulator is active.
170
+ transition_activated = Signal(object)
171
+ #: Emitted just *before* the canvas changes the model (for undo).
172
+ about_to_change = Signal()
173
+ #: A drag may be starting (for undo: the state before a possible move).
174
+ drag_started = Signal()
175
+ #: Elements were dragged to new positions.
176
+ moved = Signal()
177
+ #: A place or transition was just created (the page offers to name it).
178
+ created = Signal(object)
179
+ #: A place or transition was double-clicked: rename it.
180
+ rename_requested = Signal(object)
181
+ #: A short explanation for the status bar (e.g. why an arc was refused).
182
+ message = Signal(str)
183
+
184
+ def __init__(self, net: CPNet) -> None:
185
+ super().__init__()
186
+ self.net = net
187
+ self.page: Page | None = net.pages[0] if net.pages else None
188
+ self.tool = "select"
189
+ #: When False, clicking an enabled transition only selects it (edit
190
+ #: mode); when True it fires it (the token game).
191
+ self.fire_on_click = True
192
+ #: Token values next to the count bubbles: None = as the model says
193
+ #: (CPN Tools' per-place "hide marking"), True = all, False = none.
194
+ self.show_token_values: bool | None = None
195
+ #: While the arc tool is mid-draw: the node it started from, the
196
+ #: rubber-band line and the node under the mouse.
197
+ self._connecting: dict | None = None
198
+ #: The "drag to connect" arrow next to a hovered node (made on demand).
199
+ self._handle: _ConnectHandle | None = None
200
+ #: The preview of the node a drag to empty space will add (on demand).
201
+ self._ghost: _GhostNode | None = None
202
+ #: Edit mode: arcs and labels can be dragged (CPN IDE's rules, see
203
+ #: :mod:`openprocess.gui.arc_editing`).
204
+ self.editable = True
205
+ #: An arc being edited: see :meth:`_start_arc_drag`.
206
+ self._arc_drag: dict | None = None
207
+ #: A node being dragged: what its centre can snap to.
208
+ self._snap_node: PlaceItem | TransitionItem | None = None
209
+ self._snap_targets: list[tuple[float, float]] = []
210
+ self._guides: list[QGraphicsLineItem] = []
211
+
212
+ self.place_items: dict[str, PlaceItem] = {}
213
+ self.transition_items: dict[str, TransitionItem] = {}
214
+ self.arc_items: dict[str, ArcItem] = {}
215
+
216
+ self.setBackgroundBrush(theme.palette().canvas)
217
+ self.selectionChanged.connect(self._on_selection_changed)
218
+
219
+ # =======================================================================
220
+ # Building
221
+ # =======================================================================
222
+ def show_page(self, page: Page | None) -> None:
223
+ """Replace the scene contents with ``page``."""
224
+ self.page = page
225
+ self.rebuild()
226
+
227
+ def rebuild(self) -> None:
228
+ """Recreate every item from the model.
229
+
230
+ Rebuilding wholesale rather than diffing keeps the code honest: there
231
+ is exactly one way the canvas can get out of step with the model, and
232
+ it is fixed by calling this. Nets are small enough that the cost is
233
+ irrelevant.
234
+ """
235
+ self.clear()
236
+ self._guides = [] # cleared with the scene
237
+ self._arc_drag = None
238
+ self._connecting = None
239
+ self._handle = None # deleted with the scene
240
+ self._ghost = None
241
+ self._snap_node = None
242
+ self.place_items.clear()
243
+ self.transition_items.clear()
244
+ self.arc_items.clear()
245
+
246
+ if self.page is None:
247
+ return
248
+
249
+ plain = getattr(self.net, "plain", False)
250
+ outside = getattr(self.net, "names_outside", False)
251
+ for place in self.page.places:
252
+ item = PlaceItem(place, plain, outside)
253
+ self.addItem(item)
254
+ self.place_items[place.id] = item
255
+
256
+ for transition in self.page.transitions:
257
+ item = TransitionItem(transition, plain, outside)
258
+ self.addItem(item)
259
+ self.transition_items[transition.id] = item
260
+
261
+ # Arcs joining the same place and transition must be separated, or a
262
+ # consume-and-return pair would be drawn exactly on top of itself.
263
+ # Group them first, then give each member of a group its own bow.
264
+ groups: dict[tuple[str, str], list[Arc]] = {}
265
+ for arc in self.page.arcs:
266
+ groups.setdefault((arc.place_id, arc.transition_id), []).append(arc)
267
+
268
+ bows: dict[str, float] = {}
269
+ for members in groups.values():
270
+ count = len(members)
271
+ for index, arc in enumerate(members):
272
+ # Symmetric offsets about zero: a lone arc gets 0 and stays
273
+ # straight, a pair gets -s/+s, a triple -s/0/+s, and so on.
274
+ bows[arc.id] = (index - (count - 1) / 2) * PARALLEL_ARC_SPACING
275
+
276
+ for arc in self.page.arcs:
277
+ place_item = self.place_items.get(arc.place_id)
278
+ transition_item = self.transition_items.get(arc.transition_id)
279
+ if place_item is None or transition_item is None:
280
+ # A dangling arc (endpoint deleted); skip rather than crash.
281
+ continue
282
+ item = ArcItem(arc, place_item, transition_item, bow=bows.get(arc.id, 0.0),
283
+ plain=plain)
284
+ self.addItem(item)
285
+ self.arc_items[arc.id] = item
286
+
287
+ self._apply_error_highlighting()
288
+ # The margin matches zoom_to_fit's, so a fitted page shows no scroll bars.
289
+ self.setSceneRect(self.itemsBoundingRect().adjusted(-40, -40, 40, 40))
290
+
291
+ def refresh_labels(self) -> None:
292
+ """Re-read inscriptions from the model without rebuilding the scene."""
293
+ for item in self.place_items.values():
294
+ item.refresh()
295
+ for item in self.transition_items.values():
296
+ item.refresh()
297
+ for item in self.arc_items.values():
298
+ item.reroute()
299
+ self._apply_error_highlighting()
300
+
301
+ def _apply_error_highlighting(self) -> None:
302
+ """Outline in red every element whose inscription failed to compile.
303
+
304
+ Doing this for all three element kinds -- not just arcs -- means a bad
305
+ initial marking or a bad guard is visible on the canvas, not only in
306
+ the problems list.
307
+ """
308
+ failing = {issue.element_id for issue in self.net.errors}
309
+ for identifier, item in self.arc_items.items():
310
+ item.set_error(identifier in failing)
311
+ for identifier, place_item in self.place_items.items():
312
+ place_item.set_error(identifier in failing)
313
+ for identifier, transition_item in self.transition_items.items():
314
+ transition_item.set_error(identifier in failing)
315
+
316
+ # =======================================================================
317
+ # Simulation feedback
318
+ # =======================================================================
319
+ def update_enabling(self, counts: dict[str, int]) -> None:
320
+ """Colour transitions by their number of enabled binding elements."""
321
+ for transition_id, item in self.transition_items.items():
322
+ item.set_enabled_count(counts.get(transition_id, 0))
323
+
324
+ #: How many steps the trace fades over.
325
+ TRACE_FADE = 8
326
+
327
+ def set_trace(self, fired: list[tuple[int, str]] | None) -> None:
328
+ """Highlight the path the tokens took: ``fired`` is ``(step number,
329
+ transition id)`` for each firing so far (None switches it off).
330
+
331
+ Every fired transition gets a badge with its step numbers, and its
332
+ input and output arcs are underlaid in the accent colour -- strongest
333
+ for the latest step, fading over :attr:`TRACE_FADE` steps.
334
+ """
335
+ steps: dict[str, list[int]] = {}
336
+ age: dict[str, int] = {}
337
+ for index, (step, transition_id) in enumerate(fired or []):
338
+ steps.setdefault(transition_id, []).append(step)
339
+ age[transition_id] = len(fired) - 1 - index
340
+ strength = {t: max(0.15, 1.0 - a / self.TRACE_FADE) for t, a in age.items()}
341
+ for transition_id, item in self.transition_items.items():
342
+ item.set_trace(steps.get(transition_id, []), strength.get(transition_id, 0.0))
343
+ for item in self.arc_items.values():
344
+ item.set_trace(strength.get(item.arc.transition_id, 0.0))
345
+
346
+ def update_markings(self, describe: Callable[[str], tuple[int, str]]) -> None:
347
+ """Show the simulator's current token contents on each place.
348
+
349
+ ``describe`` returns ``(token_count, multiset_text)``; the count drives
350
+ the pill and the text the annotation beside it.
351
+ """
352
+ for place_id, item in self.place_items.items():
353
+ count, text = describe(place_id)
354
+ item.set_marking(count, text, self.show_token_values)
355
+
356
+ # =======================================================================
357
+ # Interaction
358
+ # =======================================================================
359
+ def _on_selection_changed(self) -> None:
360
+ items = self.selectedItems()
361
+ if not items:
362
+ self.selection_changed.emit(None)
363
+ return
364
+ item = items[0]
365
+ if isinstance(item, PlaceItem):
366
+ self.selection_changed.emit(item.place)
367
+ elif isinstance(item, TransitionItem):
368
+ self.selection_changed.emit(item.transition)
369
+ elif isinstance(item, ArcItem):
370
+ self.selection_changed.emit(item.arc)
371
+ else:
372
+ self.selection_changed.emit(None)
373
+
374
+ def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
375
+ if self.page is None or event.button() != Qt.LeftButton:
376
+ super().mousePressEvent(event)
377
+ return
378
+
379
+ position = event.scenePos()
380
+ if self._connecting is not None and self._connecting.get("clicked"):
381
+ self._arc_press(position) # the second click of click-click
382
+ return
383
+ if not self._handle_hit(position):
384
+ # A redraw (say, finishing a name with this very click) takes the
385
+ # arrow away: put it back where the mouse is, if it belongs there.
386
+ self._update_connect_handle(position)
387
+ if self._handle_hit(position):
388
+ node = self._handle.node
389
+ self.hide_connect_handle()
390
+ self._begin_connection(node, position)
391
+ event.accept()
392
+ return
393
+ self.hide_connect_handle()
394
+ item = self.itemAt(position, QTransform())
395
+ if self.tool == "select":
396
+ # Remember where everything is, to tell on release whether this
397
+ # press turned into a move.
398
+ self._positions_before = self._layout_state()
399
+ self.drag_started.emit()
400
+ if self.editable and self._start_arc_drag(event, item):
401
+ return
402
+ self._start_node_drag(item)
403
+
404
+ if self.tool == "place":
405
+ self._create_place(position)
406
+ return
407
+ if self.tool == "transition":
408
+ self._create_transition(position)
409
+ return
410
+ if self.tool == "arc":
411
+ self._arc_press(position)
412
+ return
413
+
414
+ # Select tool: a plain click on an enabled transition asks the main
415
+ # window to fire it. This is the "token game" interaction.
416
+ owner = self._owner_of(item)
417
+ if isinstance(owner, Transition) and event.modifiers() == Qt.NoModifier \
418
+ and self.fire_on_click:
419
+ transition_item = self.transition_items.get(owner.id)
420
+ if transition_item is not None and transition_item.enabled_count:
421
+ self.transition_activated.emit(owner)
422
+ super().mousePressEvent(event)
423
+
424
+ def _layout_state(self) -> tuple:
425
+ """Everything a drag can change: node positions, bends, label spots."""
426
+ items = list(self.place_items.items()) + list(self.transition_items.items())
427
+ nodes = {key: (item.pos().x(), item.pos().y(),
428
+ tuple(sorted(item.graphics().label_offsets.items())))
429
+ for key, item in items}
430
+ arcs = {key: (tuple(item.arc.bendpoints),
431
+ item.arc.graphics.label_offsets.get("annot"))
432
+ for key, item in self.arc_items.items()}
433
+ return nodes, arcs
434
+
435
+ def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
436
+ if self._connecting is not None:
437
+ self._arc_move(event.scenePos())
438
+ return
439
+ if not event.buttons():
440
+ self._update_connect_handle(event.scenePos())
441
+ if self._arc_drag is not None:
442
+ self._drag_arc(event)
443
+ return
444
+ super().mouseMoveEvent(event)
445
+ if self._snap_node is not None and event.buttons() & Qt.LeftButton:
446
+ self._snap_dragged_nodes()
447
+
448
+ def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
449
+ if self._connecting is not None:
450
+ self._arc_release(event.scenePos())
451
+ return
452
+ if self._arc_drag is not None:
453
+ self._finish_arc_drag(event)
454
+ else:
455
+ super().mouseReleaseEvent(event)
456
+ self._end_node_drag()
457
+ before = getattr(self, "_positions_before", None)
458
+ self._positions_before = None
459
+ if before is not None and before != self._layout_state():
460
+ self.moved.emit()
461
+
462
+ # -- moving nodes (with CPN IDE's snapping) ------------------------------
463
+ def _start_node_drag(self, item: QGraphicsItem | None) -> None:
464
+ """A press on a node may start moving it (and the rest of the selection).
465
+
466
+ Every arc remembers its shape, so its bends can be kept in order
467
+ while its nodes move; the pressed node's centre will snap level
468
+ with (or in line with) other nodes and with the corners of
469
+ right-angled arcs, as in CPN IDE.
470
+ """
471
+ node = item
472
+ while node is not None and not isinstance(node, (PlaceItem, TransitionItem, ArcItem)):
473
+ node = node.parentItem()
474
+ if not isinstance(node, (PlaceItem, TransitionItem)):
475
+ node = None
476
+ self._snap_node = node
477
+ for arc_item in self.arc_items.values():
478
+ arc_item.start_node_drag()
479
+ if node is None:
480
+ return
481
+ moving = {node, *self.selected_nodes()}
482
+ targets = [(n.pos().x(), n.pos().y())
483
+ for n in [*self.place_items.values(), *self.transition_items.values()]
484
+ if n not in moving]
485
+ for arc_item in self.arc_items.values():
486
+ points = [(p.x(), p.y()) for p in arc_item.waypoints()]
487
+ targets.extend(edit.corner_bends(points))
488
+ self._snap_targets = targets
489
+
490
+ def selected_nodes(self) -> list[PlaceItem | TransitionItem]:
491
+ return [i for i in self.selectedItems() if isinstance(i, (PlaceItem, TransitionItem))]
492
+
493
+ def _snap_dragged_nodes(self) -> None:
494
+ node = self._snap_node
495
+ # Only while the node itself is dragged (not one of its labels).
496
+ if node is None or not node.isSelected() or self.mouseGrabberItem() is not node:
497
+ return
498
+ centre = (node.pos().x(), node.pos().y())
499
+ if self.snap_to_grid:
500
+ # The pressed node goes dot to dot; the rest of the selection
501
+ # moves with it, keeping its shape (so it stays on the grid too
502
+ # if it was). On the grid, nodes line up by themselves: no guides.
503
+ target = on_grid(node.pos())
504
+ delta = target - node.pos()
505
+ if delta.x() or delta.y():
506
+ for other in self.selected_nodes():
507
+ other.setPos(other.pos() + delta)
508
+ return
509
+ sx, sy = edit.snap_node(centre, self._snap_targets)
510
+ dx = 0.0 if sx is None else sx - centre[0]
511
+ dy = 0.0 if sy is None else sy - centre[1]
512
+ if dx or dy:
513
+ for other in self.selected_nodes():
514
+ other.setPos(other.pos() + QPointF(dx, dy))
515
+ self._show_guides(sx, sy)
516
+
517
+ def _show_guides(self, x: float | None, y: float | None) -> None:
518
+ """Dashed lines showing what the dragged node lined up with."""
519
+ if not self._guides:
520
+ pen = QPen(theme.palette().accent, 1.0, Qt.DashLine)
521
+ pen.setCosmetic(True)
522
+ for _ in range(2):
523
+ line = QGraphicsLineItem()
524
+ line.setPen(pen)
525
+ line.setZValue(50)
526
+ self.addItem(line)
527
+ self._guides.append(line)
528
+ area = self.sceneRect().adjusted(-2000, -2000, 2000, 2000)
529
+ vertical, horizontal = self._guides
530
+ vertical.setVisible(x is not None)
531
+ horizontal.setVisible(y is not None)
532
+ if x is not None:
533
+ vertical.setLine(x, area.top(), x, area.bottom())
534
+ if y is not None:
535
+ horizontal.setLine(area.left(), y, area.right(), y)
536
+
537
+ def _end_node_drag(self) -> None:
538
+ for arc_item in self.arc_items.values():
539
+ arc_item.drag_origin = None
540
+ self._snap_node = None
541
+ for line in self._guides:
542
+ if line.scene() is self:
543
+ self.removeItem(line)
544
+ self._guides = []
545
+
546
+ # -- editing arcs (CPN IDE's rules) --------------------------------------
547
+ def _tolerance(self) -> float:
548
+ """Grab distance in scene units: 10, or more when zoomed far out."""
549
+ views = self.views()
550
+ scale = views[0].transform().m11() if views else 1.0
551
+ return max(edit.HIT_TOLERANCE, 6.0 / max(scale, 1e-6))
552
+
553
+ def _start_arc_drag(self, event: QGraphicsSceneMouseEvent,
554
+ item: QGraphicsItem | None) -> bool:
555
+ """A press on an arc: decide what dragging will do (see
556
+ :func:`~openprocess.gui.arc_editing.hit_test`). Nothing changes until the
557
+ mouse actually moves; a plain click just selects the arc."""
558
+ position = event.scenePos()
559
+ p = (position.x(), position.y())
560
+ tolerance = self._tolerance()
561
+ # The handles of a hovered or selected arc sit above everything else.
562
+ candidates = [a for a in self.arc_items.values() if a.handles_visible()]
563
+ if isinstance(item, ArcItem) and item not in candidates:
564
+ candidates.append(item)
565
+ hit = None
566
+ for arc_item in candidates:
567
+ points = [(w.x(), w.y()) for w in arc_item.waypoints()]
568
+ found = edit.hit_test(points, p, tolerance)
569
+ if found is None:
570
+ continue
571
+ kind, index, _ = found
572
+ on_handle = kind == "bend"
573
+ if on_handle or arc_item is item:
574
+ hit = (arc_item, found)
575
+ break
576
+ if hit is None:
577
+ return False
578
+ arc_item, (kind, index, point) = hit
579
+ last = len(arc_item.waypoints()) - 1
580
+ if kind == "bend" and index in (0, last):
581
+ kind = "reconnect"
582
+ if not arc_item.isSelected():
583
+ if not event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier):
584
+ self.clearSelection()
585
+ arc_item.setSelected(True)
586
+ self._arc_drag = {
587
+ "item": arc_item, "kind": kind, "index": index, "press": position,
588
+ "screen": event.screenPos(), "started": False,
589
+ "bends": list(arc_item.arc.bendpoints),
590
+ "waypoints": [(w.x(), w.y()) for w in arc_item.waypoints()],
591
+ "target": None,
592
+ }
593
+ event.accept()
594
+ return True
595
+
596
+ def _drag_arc(self, event: QGraphicsSceneMouseEvent) -> None:
597
+ drag = self._arc_drag
598
+ arc_item: ArcItem = drag["item"]
599
+ if not drag["started"]:
600
+ moved = event.screenPos() - drag["screen"]
601
+ if abs(moved.x()) + abs(moved.y()) < 4:
602
+ return # still a click
603
+ drag["started"] = True
604
+ arc_item.editing = True
605
+ if drag["kind"] == "insert": # a new bend where the line was grabbed
606
+ arc_item.arc.bendpoints.insert(drag["index"] - 1, to_model(drag["press"]))
607
+ drag["kind"], drag["waypoints"] = "bend", (
608
+ drag["waypoints"][:drag["index"]]
609
+ + [(drag["press"].x(), drag["press"].y())]
610
+ + drag["waypoints"][drag["index"]:])
611
+ position = event.scenePos()
612
+ p = (position.x(), position.y())
613
+ kind, index, points = drag["kind"], drag["index"], drag["waypoints"]
614
+ if kind == "bend":
615
+ # Snap level with / in line with the neighbours (a node's centre
616
+ # stands in for an end of the arc).
617
+ neighbours = [points[index - 1], points[index + 1]]
618
+ if index - 1 == 0:
619
+ neighbours[0] = self._centre(arc_item.transition_item)
620
+ if index + 1 == len(points) - 1:
621
+ neighbours[1] = self._centre(arc_item.place_item)
622
+ snapped = (on_grid(position).toTuple() if self.snap_to_grid
623
+ else edit.snap_bend(p, neighbours))
624
+ arc_item.arc.bendpoints[index - 1] = to_model(QPointF(*snapped))
625
+ arc_item.reroute()
626
+ elif kind == "segment":
627
+ a, b = points[index - 1], points[index]
628
+ axis = 1 if edit.aligned(a, b) == "horizontal" else 0
629
+ delta = p[axis] - (drag["press"].y() if axis else drag["press"].x())
630
+ if self.snap_to_grid: # the segment lands on a grid line
631
+ delta = round((a[axis] + delta) / GRID_STEP) * GRID_STEP - a[axis]
632
+ new_points = edit.move_segment(
633
+ points, index, delta,
634
+ (self._centre(arc_item.transition_item), _rect(arc_item.transition_item)),
635
+ (self._centre(arc_item.place_item), _rect(arc_item.place_item)))
636
+ arc_item.arc.bendpoints = [to_model(QPointF(*q)) for q in new_points[1:-1]]
637
+ arc_item.reroute()
638
+ elif kind == "reconnect":
639
+ which = "transition" if index == 0 else "place"
640
+ arc_item.loose_end = (which, position)
641
+ target = self._node_at(position, PlaceItem if which == "place" else TransitionItem)
642
+ if drag["target"] is not None and drag["target"] is not target:
643
+ drag["target"].set_halo(False)
644
+ if target is not None:
645
+ target.set_halo(True)
646
+ drag["target"] = target
647
+ arc_item.reroute()
648
+
649
+ def _finish_arc_drag(self, event: QGraphicsSceneMouseEvent) -> None:
650
+ drag, self._arc_drag = self._arc_drag, None
651
+ arc_item: ArcItem = drag["item"]
652
+ arc_item.editing = False
653
+ if not drag["started"]:
654
+ arc_item.update()
655
+ return
656
+ if drag["kind"] == "reconnect":
657
+ target = drag["target"]
658
+ arc_item.loose_end = None
659
+ if target is not None:
660
+ target.set_halo(False)
661
+ which = "transition" if drag["index"] == 0 else "place"
662
+ current = arc_item.transition_item if which == "transition" else arc_item.place_item
663
+ if target is None or target is current:
664
+ arc_item.reroute()
665
+ return
666
+ self.about_to_change.emit()
667
+ if which == "place":
668
+ arc_item.arc.place_id = target.model_id
669
+ else:
670
+ arc_item.arc.transition_id = target.model_id
671
+ arc_id = arc_item.arc.id
672
+ self.rebuild()
673
+ self.model_changed.emit()
674
+ new_item = self.arc_items.get(arc_id)
675
+ if new_item is not None:
676
+ new_item.setSelected(True)
677
+ return
678
+ # Bends left in a straight line with their neighbours disappear.
679
+ points = [(w.x(), w.y()) for w in arc_item.waypoints()]
680
+ tidy = edit.without_redundant(points)
681
+ arc_item.arc.bendpoints = [to_model(QPointF(*q)) for q in tidy[1:-1]]
682
+ arc_item.reroute()
683
+ arc_item.update()
684
+
685
+ @staticmethod
686
+ def _centre(item: QGraphicsItem) -> tuple[float, float]:
687
+ return item.pos().x(), item.pos().y()
688
+
689
+ def _node_at(self, position: QPointF, kind: type):
690
+ for item in self.items(position):
691
+ while item is not None and not isinstance(item, (PlaceItem, TransitionItem)):
692
+ item = item.parentItem()
693
+ if isinstance(item, kind):
694
+ return item
695
+ return None
696
+
697
+ @staticmethod
698
+ def _owner_of(item: QGraphicsItem | None):
699
+ """Walk up to the top-level item and return the model object it draws."""
700
+ while item is not None and not isinstance(item, (PlaceItem, TransitionItem, ArcItem)):
701
+ item = item.parentItem()
702
+ if isinstance(item, PlaceItem):
703
+ return item.place
704
+ if isinstance(item, TransitionItem):
705
+ return item.transition
706
+ if isinstance(item, ArcItem):
707
+ return item.arc
708
+ return None
709
+
710
+ # -- creation ------------------------------------------------------------
711
+ @property
712
+ def plain(self) -> bool:
713
+ return getattr(self.net, "plain", False)
714
+
715
+ def _hold_views_still(self) -> None:
716
+ """Adding a node must not shift the drawing on screen: a net smaller
717
+ than the window is otherwise re-centred as it grows, moving the new
718
+ node away from the mouse that just put it there."""
719
+ for view in self.views():
720
+ if isinstance(view, NetView):
721
+ view.hold_still()
722
+
723
+ def _create_place(self, position: QPointF) -> None:
724
+ self.about_to_change.emit()
725
+ self._hold_views_still()
726
+ place = self._new_place(on_grid(position) if self.snap_to_grid else position)
727
+ self.rebuild()
728
+ self.model_changed.emit()
729
+ self.created.emit(place)
730
+
731
+ def _create_transition(self, position: QPointF) -> None:
732
+ self.about_to_change.emit()
733
+ self._hold_views_still()
734
+ transition = self._new_transition(on_grid(position) if self.snap_to_grid else position)
735
+ self.rebuild()
736
+ self.model_changed.emit()
737
+ self.created.emit(transition)
738
+
739
+ def _new_place(self, position: QPointF) -> Place:
740
+ """Add a place centred on ``position`` to the model (no redraw)."""
741
+ assert self.page is not None
742
+ prefix = "p" if self.plain else "P"
743
+ place = Place(name=self._unique_name(prefix, [p.name for p in self.net.all_places()]),
744
+ colour_set_name="UNIT")
745
+ place.graphics.x, place.graphics.y = to_model(position)
746
+ if self.plain:
747
+ place.graphics.width = place.graphics.height = PLAIN_PLACE
748
+ self.page.places.append(place)
749
+ return place
750
+
751
+ def _new_transition(self, position: QPointF) -> Transition:
752
+ """Add a transition centred on ``position`` to the model (no redraw)."""
753
+ assert self.page is not None
754
+ prefix = "t" if self.plain else "T"
755
+ transition = Transition(
756
+ name=self._unique_name(prefix, [t.name for t in self.net.all_transitions()])
757
+ )
758
+ transition.graphics.x, transition.graphics.y = to_model(position)
759
+ self.page.transitions.append(transition)
760
+ return transition
761
+
762
+ # -- drawing arcs: drag from one node to another (or click both) -----------
763
+ def _node_under(self, position: QPointF):
764
+ """The place or transition under ``position`` (arcs and labels ignored)."""
765
+ return self._node_at(position, (PlaceItem, TransitionItem))
766
+
767
+ def _arc_press(self, position: QPointF) -> None:
768
+ node = self._node_under(position)
769
+ pending = self._connecting
770
+ if pending is not None and pending.get("clicked"):
771
+ # Second click of the click-click way: finish (or cancel) here.
772
+ self._finish_connection(node)
773
+ return
774
+ if node is None:
775
+ self._cancel_connection()
776
+ self.message.emit("Press on a place or transition and drag to the node to connect")
777
+ return
778
+ self._begin_connection(node, position)
779
+
780
+ def _begin_connection(self, node, position: QPointF) -> None:
781
+ """Start drawing an arc from ``node``: a dashed line follows the mouse."""
782
+ line = QGraphicsLineItem()
783
+ pen = QPen(theme.palette().accent, 1.6, Qt.DashLine)
784
+ pen.setCosmetic(True)
785
+ line.setPen(pen)
786
+ line.setOpacity(0.55) # a hint, not a wall over the drawing
787
+ line.setZValue(40)
788
+ self.addItem(line)
789
+ line.setLine(node.pos().x(), node.pos().y(), position.x(), position.y())
790
+ self._connecting = {"source": node, "line": line, "press": position,
791
+ "clicked": False, "target": None, "spot": None}
792
+ node.set_halo(True)
793
+
794
+ def _arc_move(self, position: QPointF) -> None:
795
+ pending = self._connecting
796
+ if pending is None:
797
+ return
798
+ source = pending["source"]
799
+ target = self._node_under(position)
800
+ if pending["target"] is not None and pending["target"] is not target \
801
+ and pending["target"] is not source:
802
+ pending["target"].set_halo(False)
803
+ valid = target is not None and type(target) is not type(source)
804
+ if valid:
805
+ target.set_halo(True)
806
+ pending["target"] = target if valid else None
807
+ # Over empty canvas, a drag (not the click-click way) shows the node
808
+ # that letting go will add, and the line runs to it.
809
+ spot = None
810
+ if target is None and not pending["clicked"]:
811
+ spot = self._drop_spot(source, position)
812
+ if spot is not None and pending["spot"] is None:
813
+ other = "transition" if isinstance(source, PlaceItem) else "place"
814
+ self.message.emit(f"Let go to add a {other} here, joined by an arc "
815
+ f"(Esc cancels)")
816
+ pending["spot"] = spot
817
+ self._show_ghost(source, spot)
818
+ end = position if spot is None else spot
819
+ pending["line"].setLine(source.pos().x(), source.pos().y(), end.x(), end.y())
820
+ pen = pending["line"].pen()
821
+ pen.setColor(theme.palette().danger if target is not None and not valid
822
+ and target is not source else theme.palette().accent)
823
+ pending["line"].setPen(pen)
824
+
825
+ def _arc_release(self, position: QPointF) -> None:
826
+ pending = self._connecting
827
+ if pending is None or pending.get("clicked"):
828
+ return
829
+ source = pending["source"]
830
+ node = self._node_under(position)
831
+ moved = (position - pending["press"]).manhattanLength()
832
+ if node is source or (node is None and moved < 6):
833
+ # A click, not a drag: wait for a click on the second node.
834
+ pending["clicked"] = True
835
+ self._show_ghost(source, None)
836
+ self.message.emit("Now click the place or transition to connect to "
837
+ "(Esc cancels)")
838
+ return
839
+ spot = self._drop_spot(source, position) if node is None else None
840
+ if spot is not None:
841
+ self._cancel_connection()
842
+ self._connect_to_new_node(source, spot)
843
+ return
844
+ self._finish_connection(node)
845
+
846
+ # -- dragging an arc to empty canvas adds the node at its end ---------------
847
+ #: Least space (scene units) between the source and a node added beside it.
848
+ NEW_NODE_GAP = 16.0
849
+ #: How far (scene units) a new node moves to line up with another node.
850
+ NEW_NODE_ALIGN = 12.0
851
+
852
+ def _new_node_size(self, kind: type) -> tuple[float, float]:
853
+ """The size a new place or transition gets (see :meth:`_new_place`)."""
854
+ if self.plain:
855
+ return (PLAIN_PLACE, PLAIN_PLACE) if kind is PlaceItem \
856
+ else (PLAIN_TRANSITION, PLAIN_TRANSITION)
857
+ return (76.0, 48.0) if kind is PlaceItem else (82.0, 48.0)
858
+
859
+ def _drop_spot(self, source, position: QPointF) -> QPointF | None:
860
+ """Where a node added by letting go at ``position`` would be centred:
861
+ on the grid, or else lined up with a nearby node when it is almost
862
+ level with it. ``None`` while it would still overlap ``source``
863
+ (too close to tell a drag from a fumble)."""
864
+ if self.snap_to_grid:
865
+ centre = on_grid(position)
866
+ else:
867
+ nodes = [*self.place_items.values(), *self.transition_items.values()]
868
+ sx, sy = edit.snap_node((position.x(), position.y()),
869
+ [self._centre(n) for n in nodes], self.NEW_NODE_ALIGN)
870
+ centre = QPointF(position.x() if sx is None else sx,
871
+ position.y() if sy is None else sy)
872
+ kind = TransitionItem if isinstance(source, PlaceItem) else PlaceItem
873
+ width, height = self._new_node_size(kind)
874
+ new = QRectF(centre.x() - width / 2, centre.y() - height / 2, width, height)
875
+ gap = self.NEW_NODE_GAP
876
+ if new.adjusted(-gap, -gap, gap, gap).intersects(QRectF(*_rect(source))):
877
+ return None
878
+ return centre
879
+
880
+ def _show_ghost(self, source, spot: QPointF | None) -> None:
881
+ if spot is None:
882
+ if self._ghost is not None and self._ghost.scene() is self:
883
+ self._ghost.hide()
884
+ return
885
+ if self._ghost is None or self._ghost.scene() is not self:
886
+ self._ghost = _GhostNode()
887
+ self.addItem(self._ghost)
888
+ kind = TransitionItem if isinstance(source, PlaceItem) else PlaceItem
889
+ width, height = self._new_node_size(kind)
890
+ self._ghost.show_as(kind, QRectF(-width / 2, -height / 2, width, height), spot,
891
+ 2.0 if self.plain else theme.RADIUS)
892
+
893
+ def _connect_to_new_node(self, source, spot: QPointF) -> Arc:
894
+ """Add the other kind of node at ``spot`` and an arc to it from
895
+ ``source`` -- one edit, so one undo takes both away."""
896
+ self.about_to_change.emit()
897
+ self._hold_views_still()
898
+ if isinstance(source, PlaceItem):
899
+ new = self._new_transition(spot)
900
+ arc = self._add_arc(source.place, new, "PtoT")
901
+ else:
902
+ new = self._new_place(spot)
903
+ arc = self._add_arc(new, source.transition, "TtoP")
904
+ self.rebuild()
905
+ self.model_changed.emit()
906
+ what = "transition" if isinstance(new, Transition) else "place"
907
+ self.message.emit(f"Added {what} {new.name}, joined by an arc — type its name, "
908
+ f"or drag on from it")
909
+ self.created.emit(new)
910
+ return arc
911
+
912
+ def _finish_connection(self, target) -> None:
913
+ pending = self._connecting
914
+ source = pending["source"] if pending else None
915
+ self._cancel_connection()
916
+ if source is None or target is None or target is source:
917
+ return
918
+ if type(target) is type(source):
919
+ kind = "places" if isinstance(source, PlaceItem) else "transitions"
920
+ self.message.emit(f"Can't connect two {kind}: an arc always joins a place and "
921
+ "a transition")
922
+ return
923
+ if isinstance(source, PlaceItem):
924
+ self.connect(source.place, target.transition, "PtoT")
925
+ else:
926
+ self.connect(target.place, source.transition, "TtoP")
927
+
928
+ def _cancel_connection(self) -> None:
929
+ pending, self._connecting = self._connecting, None
930
+ if pending is None:
931
+ return
932
+ line = pending["line"]
933
+ if line.scene() is self:
934
+ self.removeItem(line)
935
+ for node in (pending["source"], pending.get("target")):
936
+ if node is not None and node.scene() is self:
937
+ node.set_halo(False)
938
+ if self._ghost is not None and self._ghost.scene() is self:
939
+ self._ghost.hide()
940
+
941
+ def connect(self, place: Place, transition: Transition, orientation: str) -> None:
942
+ """Add an arc (in a plain net, a second arc the same way adds weight)."""
943
+ assert self.page is not None
944
+ existing = next((a for a in self.page.arcs if a.place_id == place.id
945
+ and a.transition_id == transition.id
946
+ and a.orientation == orientation), None)
947
+ self.about_to_change.emit()
948
+ if self.plain and existing is not None:
949
+ from ..model.plain import tokens_text, weight_of
950
+ existing.expression_text = tokens_text(weight_of(existing) + 1)
951
+ self.message.emit(f"That arc already exists: its weight is now "
952
+ f"{weight_of(existing)}")
953
+ else:
954
+ self._add_arc(place, transition, orientation)
955
+ what = (f"{place.name} → {transition.name}" if orientation == "PtoT"
956
+ else f"{transition.name} → {place.name}")
957
+ self.message.emit(f"Arc {what} added — keep dragging to add more")
958
+ self.rebuild()
959
+ self.model_changed.emit()
960
+
961
+ def _add_arc(self, place: Place, transition: Transition, orientation: str) -> Arc:
962
+ """Add an arc to the model (no redraw), with the usual inscription."""
963
+ assert self.page is not None
964
+ default = "" if self.plain else ("1`()" if place.colour_set_name == "UNIT" else "")
965
+ arc = Arc(place_id=place.id, transition_id=transition.id,
966
+ orientation=orientation, expression_text=default)
967
+ self.page.arcs.append(arc)
968
+ return arc
969
+
970
+ # -- the "drag to connect" arrow ------------------------------------------------
971
+ #: How near (screen pixels) the mouse must come to a node for its arrow.
972
+ HANDLE_REACH = 26.0
973
+ #: Gap between a node's outline and its arrow (screen pixels).
974
+ HANDLE_GAP = 16.0
975
+
976
+ def _view_scale(self) -> float:
977
+ views = self.views()
978
+ return max(views[0].transform().m11(), 1e-6) if views else 1.0
979
+
980
+ def _handle_hit(self, position: QPointF) -> bool:
981
+ handle = self._handle
982
+ if handle is None or not handle.isVisible() or handle.node is None:
983
+ return False
984
+ gap = position - handle.pos()
985
+ return (gap.x() ** 2 + gap.y() ** 2) ** 0.5 <= 14.0 / self._view_scale()
986
+
987
+ def hide_connect_handle(self) -> None:
988
+ if self._handle is not None and self._handle.scene() is self:
989
+ self._handle.hide()
990
+ self._handle.node = None
991
+
992
+ def _update_connect_handle(self, position: QPointF) -> None:
993
+ """Show the arrow beside the node nearest the mouse (Select tool, edit
994
+ mode), on the side the mouse is on -- only once the mouse is outside the
995
+ node: over the node itself it would just be in the way of selecting,
996
+ moving or reading it."""
997
+ if not (self.editable and self.tool == "select") or self.page is None \
998
+ or self._arc_drag is not None:
999
+ self.hide_connect_handle()
1000
+ return
1001
+ scale = self._view_scale()
1002
+ if self._handle_hit(position):
1003
+ self._handle.set_hot(True)
1004
+ return
1005
+ import math
1006
+ reach = self.HANDLE_REACH / scale
1007
+ # The nearest, not the first: zoomed out, the reach can span several nodes.
1008
+ near = [item for item in [*self.place_items.values(), *self.transition_items.values()]
1009
+ if item.sceneBoundingRect().adjusted(-reach, -reach, reach, reach)
1010
+ .contains(position)]
1011
+ node = min(near, key=lambda item: math.hypot(*(position - item.pos()).toTuple()),
1012
+ default=None)
1013
+ if node is None:
1014
+ self.hide_connect_handle()
1015
+ return
1016
+ direction = position - node.pos()
1017
+ length = math.hypot(direction.x(), direction.y())
1018
+ ux, uy = (1.0, 0.0) if length < 1e-6 else (direction.x() / length,
1019
+ direction.y() / length)
1020
+ rect = node.rect()
1021
+ a, b = rect.width() / 2, rect.height() / 2
1022
+ if isinstance(node, PlaceItem): # distance to the outline
1023
+ edge = 1.0 / math.sqrt((ux / a) ** 2 + (uy / b) ** 2)
1024
+ else:
1025
+ edge = min(a / abs(ux) if abs(ux) > 1e-9 else math.inf,
1026
+ b / abs(uy) if abs(uy) > 1e-9 else math.inf)
1027
+ if length <= edge or any(other.contains(other.mapFromScene(position))
1028
+ for other in [*self.place_items.values(),
1029
+ *self.transition_items.values()]):
1030
+ self.hide_connect_handle() # on a node: no arrow
1031
+ return
1032
+ if any(self._owner_of(item) is not None and item.parentItem() is not None
1033
+ for item in self.items(position)):
1034
+ self.hide_connect_handle() # on a label: it moves itself
1035
+ return
1036
+ if self._handle is None or self._handle.scene() is not self:
1037
+ self._handle = _ConnectHandle()
1038
+ self.addItem(self._handle)
1039
+ handle = self._handle
1040
+ handle.set_node(node)
1041
+ handle.setPos(node.pos() + QPointF(ux, uy) * (edge + self.HANDLE_GAP / scale))
1042
+ handle.setRotation(math.degrees(math.atan2(uy, ux)))
1043
+ handle.set_hot(False)
1044
+ handle.show()
1045
+
1046
+ def keyPressEvent(self, event) -> None: # noqa: N802
1047
+ if event.key() == Qt.Key_Escape and self._connecting is not None:
1048
+ self._cancel_connection()
1049
+ event.accept()
1050
+ return
1051
+ super().keyPressEvent(event)
1052
+
1053
+ def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
1054
+ """Double-click a place or transition to rename it (edit mode)."""
1055
+ if self.editable and self.page is not None and event.button() == Qt.LeftButton:
1056
+ node = self._node_under(event.scenePos())
1057
+ if node is not None:
1058
+ self.rename_requested.emit(node.place if isinstance(node, PlaceItem)
1059
+ else node.transition)
1060
+ event.accept()
1061
+ return
1062
+ super().mouseDoubleClickEvent(event)
1063
+
1064
+ @staticmethod
1065
+ def _unique_name(prefix: str, existing: list[str]) -> str:
1066
+ index = 1
1067
+ while f"{prefix}{index}" in existing:
1068
+ index += 1
1069
+ return f"{prefix}{index}"
1070
+
1071
+ # -- deletion ------------------------------------------------------------
1072
+ def delete_selected(self) -> None:
1073
+ """Remove selected elements, plus any arcs left dangling."""
1074
+ if self.page is None:
1075
+ return
1076
+ if self.selectedItems():
1077
+ self.about_to_change.emit()
1078
+ removed = False
1079
+ for item in list(self.selectedItems()):
1080
+ owner = self._owner_of(item)
1081
+ if isinstance(owner, Place):
1082
+ self.page.places.remove(owner)
1083
+ self.page.arcs = [a for a in self.page.arcs if a.place_id != owner.id]
1084
+ removed = True
1085
+ elif isinstance(owner, Transition):
1086
+ self.page.transitions.remove(owner)
1087
+ self.page.arcs = [a for a in self.page.arcs if a.transition_id != owner.id]
1088
+ removed = True
1089
+ elif isinstance(owner, Arc) and owner in self.page.arcs:
1090
+ self.page.arcs.remove(owner)
1091
+ removed = True
1092
+ if removed:
1093
+ self.rebuild()
1094
+ self.model_changed.emit()
1095
+
1096
+ def set_editable(self, editable: bool) -> None:
1097
+ """Edit mode on or off (arcs and labels can only be dragged when on)."""
1098
+ self.editable = editable
1099
+ if not editable:
1100
+ self.hide_connect_handle()
1101
+ for item in self.arc_items.values():
1102
+ item.update()
1103
+
1104
+ # -- background ----------------------------------------------------------
1105
+ def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
1106
+ """A faint dot grid, purely as a visual aid for alignment.
1107
+
1108
+ Drawn with a cosmetic pen so the dots stay one pixel across at every
1109
+ zoom level -- a scaled grid quickly turns into either invisible specks
1110
+ or distracting blobs.
1111
+ """
1112
+ painter.fillRect(rect, theme.palette().canvas)
1113
+
1114
+ pen = QPen(theme.palette().grid, 1.4)
1115
+ pen.setCosmetic(True)
1116
+ painter.setPen(pen)
1117
+
1118
+ left = int(rect.left()) - (int(rect.left()) % GRID_STEP)
1119
+ top = int(rect.top()) - (int(rect.top()) % GRID_STEP)
1120
+ points = []
1121
+ y = top
1122
+ while y < rect.bottom():
1123
+ x = left
1124
+ while x < rect.right():
1125
+ points.append(QPointF(x, y))
1126
+ x += GRID_STEP
1127
+ y += GRID_STEP
1128
+ painter.drawPoints(points)
1129
+
1130
+
1131
+ class _ClickAway(QObject):
1132
+ """While a name box is open: a mouse press anywhere outside it, or the box
1133
+ losing the focus, finishes the edit -- as if Return had been pressed.
1134
+
1135
+ The click itself is not swallowed, so it still selects, drags or presses
1136
+ whatever it landed on. Switching to another application or opening the
1137
+ box's own context menu does not count.
1138
+ """
1139
+
1140
+ def __init__(self, editor, commit: Callable[[], None]) -> None:
1141
+ super().__init__(editor)
1142
+ self.editor = editor
1143
+ self.commit = commit
1144
+
1145
+ def eventFilter(self, watched, event) -> bool: # noqa: N802 - Qt naming
1146
+ kind = event.type()
1147
+ if kind == QEvent.MouseButtonPress:
1148
+ # A press reaches the window first, then the widget under the
1149
+ # mouse: only a widget outside the box counts.
1150
+ if isinstance(watched, QWidget) and not self._inside(watched):
1151
+ self.commit()
1152
+ elif kind == QEvent.FocusOut and watched is self.editor:
1153
+ if event.reason() not in (Qt.PopupFocusReason, Qt.ActiveWindowFocusReason):
1154
+ self.commit()
1155
+ return False
1156
+
1157
+ def _inside(self, widget: QWidget) -> bool:
1158
+ """The box itself, or something it owns -- such as its right-click
1159
+ menu, which is a window of its own (so ``isAncestorOf`` says no)."""
1160
+ while widget is not None:
1161
+ if widget is self.editor:
1162
+ return True
1163
+ widget = widget.parentWidget()
1164
+ return False
1165
+
1166
+
1167
+ class NetView(CanvasPanning, QGraphicsView):
1168
+ """An endless canvas with zooming (floating − % + Fit bar, ⌘-scroll, pinch,
1169
+ all about the pointer) and panning (scroll, or drag with the middle button
1170
+ or with Space held; see :mod:`.panning`)."""
1171
+
1172
+ zoom_changed = Signal(float)
1173
+ drawing_name = "Your net"
1174
+ MIN_ZOOM, MAX_ZOOM = 0.1, 5.0
1175
+ MIN_SCALE, MAX_SCALE = MIN_ZOOM, MAX_ZOOM # older names
1176
+ STEP = 1.25
1177
+ #: Zooming to fit will not magnify beyond this, so a two-node model does
1178
+ #: not fill the window with enormous circles.
1179
+ FIT_MAX_SCALE = 1.4
1180
+
1181
+ def __init__(self, scene: NetScene, zoom_controls: bool = True) -> None:
1182
+ super().__init__(scene)
1183
+ self.setRenderHints(
1184
+ QPainter.Antialiasing
1185
+ | QPainter.TextAntialiasing
1186
+ | QPainter.SmoothPixmapTransform
1187
+ )
1188
+ self.setDragMode(QGraphicsView.RubberBandDrag)
1189
+ self.setFrameShape(QGraphicsView.NoFrame)
1190
+ # Repaint the whole viewport on change: with antialiased curves and
1191
+ # text, partial updates leave smearing artefacts at the edges.
1192
+ self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)
1193
+ # Zoom towards the pointer rather than the view centre.
1194
+ self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
1195
+ self.setFocusPolicy(Qt.StrongFocus)
1196
+ #: While True, resizing the view re-fits the page; any manual zoom
1197
+ #: turns it off so the user's zoom level is respected.
1198
+ self.auto_fit = True
1199
+ from .studio.graph_view import ZoomControls
1200
+ self.zoom_controls = ZoomControls(self) if zoom_controls else None
1201
+ self._place_controls()
1202
+ self.viewport().setMouseTracking(True) # the rubber-band arc follows the mouse
1203
+ #: The inline name editor while one is open, and where it belongs:
1204
+ #: a function giving the scene point it is centred on and its least
1205
+ #: width, asked again whenever the view moves (see :meth:`edit_text`).
1206
+ self.name_editor = None
1207
+ self._editor_anchor = None
1208
+ self.zoom_changed.connect(lambda _zoom: self._place_editor())
1209
+ self.init_canvas()
1210
+
1211
+ # -- editing a name in place ------------------------------------------------
1212
+ def edit_text(self, centre, text: str, done, min_width: float = 60.0) -> None:
1213
+ """Open a small text field over the canvas with ``text`` selected.
1214
+
1215
+ ``centre`` is the scene point to centre it on, or a function that
1216
+ returns ``(centre, min_width)`` -- or None once there is nothing to
1217
+ name any more. A function lets the box follow a node that is moved
1218
+ or redrawn while it is open. Either way the box also follows the
1219
+ view: it stays over its spot when the canvas scrolls, zooms or is
1220
+ resized (the view re-fits itself when the window changes, which used
1221
+ to leave the box where the node *was*; issue #35).
1222
+
1223
+ The box is at least ``min_width`` scene units wide (the node's
1224
+ width), growing with the text. Return, a click anywhere else, or
1225
+ moving the focus away calls ``done(new_text)``; Esc closes it
1226
+ without a change.
1227
+ """
1228
+ from PySide6.QtWidgets import QLineEdit
1229
+ self.close_editor()
1230
+ if callable(centre):
1231
+ anchor = centre
1232
+ else:
1233
+ point, width = QPointF(centre), float(min_width)
1234
+ anchor = lambda: (point, width)
1235
+ editor = QLineEdit(text, self.viewport())
1236
+ editor.setObjectName("canvasNameEditor")
1237
+ editor.setAlignment(Qt.AlignCenter)
1238
+ accent = theme.palette().accent.name()
1239
+ editor.setStyleSheet(f"QLineEdit#canvasNameEditor {{ padding: 1px 3px; border-radius: 4px;"
1240
+ f" border: 1.5px solid {accent}; }}")
1241
+ finished = {"done": False}
1242
+
1243
+ def commit() -> None:
1244
+ if finished["done"]:
1245
+ return
1246
+ finished["done"] = True
1247
+ value = editor.text()
1248
+ self.close_editor()
1249
+ done(value)
1250
+
1251
+ def cancel() -> None:
1252
+ finished["done"] = True
1253
+ self.close_editor()
1254
+
1255
+ self.name_editor = editor
1256
+ self._editor_anchor = anchor
1257
+ self._place_editor()
1258
+ editor.textChanged.connect(lambda _text: self._place_editor()) # grow with the text
1259
+ editor.returnPressed.connect(commit)
1260
+ editor.editingFinished.connect(commit)
1261
+ from PySide6.QtGui import QShortcut, QKeySequence
1262
+ QShortcut(QKeySequence(Qt.Key_Escape), editor, cancel)
1263
+ editor.show()
1264
+ editor.selectAll()
1265
+ editor.setFocus(Qt.MouseFocusReason)
1266
+ # Qt 6 reports editingFinished on focus loss only after a change, and
1267
+ # a click on the canvas or a toolbar button may not move the focus at
1268
+ # all -- so an untouched box (a new node's suggested name) stayed
1269
+ # open. Watch for clicks elsewhere and focus loss ourselves.
1270
+ self._click_away = _ClickAway(editor, commit)
1271
+ QApplication.instance().installEventFilter(self._click_away)
1272
+
1273
+ def _place_editor(self) -> None:
1274
+ """Put the name box over its spot at the current zoom and scroll
1275
+ position: the same size as the name on the canvas, at least as wide
1276
+ as the node, centred on the name. Closes it if its node is gone."""
1277
+ editor, anchor = self.name_editor, self._editor_anchor
1278
+ if editor is None or anchor is None:
1279
+ return
1280
+ where = anchor()
1281
+ if where is None:
1282
+ self.close_editor()
1283
+ return
1284
+ centre, min_width = where
1285
+ scale = self._scale
1286
+ editor.setFont(theme.ui_font(max(9, min(28, round(12 * scale))), theme.QFont.DemiBold))
1287
+ width = max(int(min_width * scale), editor.fontMetrics().horizontalAdvance(editor.text()) + 18)
1288
+ height = editor.sizeHint().height()
1289
+ if (width, height) != (editor.width(), editor.height()):
1290
+ editor.resize(width, height)
1291
+ point = self.mapFromScene(centre)
1292
+ editor.move(point.x() - width // 2, point.y() - height // 2)
1293
+
1294
+ def scrollContentsBy(self, dx: int, dy: int) -> None: # noqa: N802
1295
+ super().scrollContentsBy(dx, dy)
1296
+ self._place_editor()
1297
+
1298
+ def close_editor(self) -> None:
1299
+ editor, self.name_editor = self.name_editor, None
1300
+ self._editor_anchor = None
1301
+ watcher, self._click_away = getattr(self, "_click_away", None), None
1302
+ if watcher is not None:
1303
+ QApplication.instance().removeEventFilter(watcher)
1304
+ if editor is not None:
1305
+ editor.blockSignals(True) # hiding must not count as "finished"
1306
+ editor.hide()
1307
+ editor.deleteLater()
1308
+ self.setFocus()
1309
+
1310
+ # -- zoom -----------------------------------------------------------------
1311
+ @property
1312
+ def _scale(self) -> float:
1313
+ return self.transform().m11()
1314
+
1315
+ def zoom(self) -> float:
1316
+ return self.transform().m11()
1317
+
1318
+ def zoom_by(self, factor: float, around_centre: bool = False, at=None) -> None:
1319
+ """Zoom by ``factor`` about the pointer (or ``at``, a viewport point),
1320
+ or about the middle of the view."""
1321
+ self.auto_fit = False
1322
+ current = self.transform().m11()
1323
+ target = min(max(current * factor, self.MIN_ZOOM), self.MAX_ZOOM)
1324
+ if target == current:
1325
+ return
1326
+ self.zoom_about(target / current, QPointF(self.viewport().rect().center())
1327
+ if around_centre else at)
1328
+ self.zoom_changed.emit(target)
1329
+
1330
+ def zoom_in(self) -> None:
1331
+ self.zoom_by(self.STEP, around_centre=True)
1332
+
1333
+ def zoom_out(self) -> None:
1334
+ self.zoom_by(1 / self.STEP, around_centre=True)
1335
+
1336
+ def actual_size(self) -> None:
1337
+ self.zoom_by(1 / self.transform().m11(), around_centre=True)
1338
+
1339
+ def zoom_to_fit(self) -> None:
1340
+ """Frame the whole page, within sensible magnification limits."""
1341
+ self.auto_fit = True
1342
+ items_rect = self.scene().itemsBoundingRect()
1343
+ if items_rect.isEmpty():
1344
+ return
1345
+ self.fitInView(items_rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
1346
+ scale = self.transform().m11()
1347
+ if scale > self.FIT_MAX_SCALE:
1348
+ # fitInView has already applied the transform, so scale back down.
1349
+ self.scale(self.FIT_MAX_SCALE / scale, self.FIT_MAX_SCALE / scale)
1350
+ self.zoom_changed.emit(self.transform().m11())
1351
+
1352
+ fit = zoom_to_fit
1353
+
1354
+ def reset_zoom(self) -> None:
1355
+ self.auto_fit = False
1356
+ self.resetTransform()
1357
+ self.zoom_changed.emit(1.0)
1358
+
1359
+ def wheelEvent(self, event) -> None: # noqa: N802
1360
+ """⌘/Ctrl + scroll zooms; plain scrolling pans, as in every Mac app."""
1361
+ if event.modifiers() & (Qt.ControlModifier | Qt.MetaModifier):
1362
+ self.zoom_by(1.0015 ** event.angleDelta().y(), at=event.position())
1363
+ event.accept()
1364
+ return
1365
+ self.wheel_pan(event)
1366
+
1367
+ def event(self, event) -> bool:
1368
+ # macOS trackpad pinch arrives as a native zoom gesture.
1369
+ if event.type() == event.Type.NativeGesture:
1370
+ try:
1371
+ if event.gestureType() == Qt.ZoomNativeGesture:
1372
+ self.zoom_by(1 + event.value(),
1373
+ at=self.viewport().mapFromGlobal(event.globalPosition()))
1374
+ return True
1375
+ except AttributeError: # pragma: no cover
1376
+ pass
1377
+ return super().event(event)
1378
+
1379
+ def keyPressEvent(self, event) -> None: # noqa: N802
1380
+ key = event.key()
1381
+ if event.modifiers() == Qt.NoModifier and key in (Qt.Key_Plus, Qt.Key_Equal):
1382
+ self.zoom_in()
1383
+ elif event.modifiers() == Qt.NoModifier and key == Qt.Key_Minus:
1384
+ self.zoom_out()
1385
+ elif event.modifiers() == Qt.NoModifier and key == Qt.Key_0:
1386
+ self.zoom_to_fit()
1387
+ else:
1388
+ super().keyPressEvent(event)
1389
+
1390
+ # -- floating zoom bar --------------------------------------------------------
1391
+ def resizeEvent(self, event) -> None: # noqa: N802
1392
+ super().resizeEvent(event)
1393
+ self._place_controls()
1394
+ if self.auto_fit:
1395
+ self.zoom_to_fit()
1396
+ self._place_editor()
1397
+
1398
+ def _place_controls(self) -> None:
1399
+ if self.zoom_controls is None:
1400
+ return
1401
+ self.zoom_controls.adjustSize()
1402
+ size = self.zoom_controls.sizeHint()
1403
+ area = self.viewport().geometry()
1404
+ self.zoom_controls.setVisible(area.width() >= size.width() + 60 and area.height() >= 90)
1405
+ self.zoom_controls.move(area.right() - size.width() - 9, area.bottom() - size.height() - 9)
1406
+ self.zoom_controls.raise_()
1407
+
1408
+ # -- panning ----------------------------------------------------------------
1409
+ def leaveEvent(self, event) -> None: # noqa: N802
1410
+ scene = self.scene()
1411
+ if isinstance(scene, NetScene):
1412
+ scene.hide_connect_handle()
1413
+ super().leaveEvent(event)
1414
+