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.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|