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
openprocess/gui/items.py
ADDED
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
"""Qt graphics items that draw the net on the canvas.
|
|
2
|
+
|
|
3
|
+
Coordinate systems
|
|
4
|
+
------------------
|
|
5
|
+
CPN Tools files place the origin at the centre of the page with **y increasing
|
|
6
|
+
upwards**. Qt's scene has y increasing *downwards*. The whole GUI therefore
|
|
7
|
+
uses one rule, applied in exactly two functions here (:func:`to_scene` /
|
|
8
|
+
:func:`to_model`): flip the sign of y at the boundary. Everywhere else in the
|
|
9
|
+
GUI, coordinates are scene coordinates.
|
|
10
|
+
|
|
11
|
+
Item structure
|
|
12
|
+
--------------
|
|
13
|
+
Each net element is one top-level item with its labels as *child* items, so
|
|
14
|
+
moving a place moves its annotations with it for free. Labels are plain text
|
|
15
|
+
items rather than editable ones: editing happens in the properties panel, which
|
|
16
|
+
keeps this module free of input handling.
|
|
17
|
+
|
|
18
|
+
Where the annotations go, and why
|
|
19
|
+
---------------------------------
|
|
20
|
+
A place carries up to four pieces of text, and none of them may collide --
|
|
21
|
+
neither with each other nor with the annotations of the *next* place along::
|
|
22
|
+
|
|
23
|
+
③ 1`ph(1)++1`ph(3) <- current marking, centred ABOVE
|
|
24
|
+
( Think ) PH <- colour set, to the RIGHT
|
|
25
|
+
1`ph(1)++1`ph(2)… <- initial marking, centred BELOW
|
|
26
|
+
|
|
27
|
+
The three long annotations are stacked vertically and each is **centred on the
|
|
28
|
+
place**, which is what keeps neighbouring nodes apart: a label capped at ~130 px
|
|
29
|
+
and centred extends only ~65 px either side, so two places 250 units apart can
|
|
30
|
+
never overprint one another. Putting the live marking and the declared initial
|
|
31
|
+
marking on the same horizontal line -- the obvious first arrangement -- fails
|
|
32
|
+
badly here, because those two strings are usually similar in length and content
|
|
33
|
+
and end up sitting side by side across the whole diagram.
|
|
34
|
+
|
|
35
|
+
Long annotations are shortened with an ellipsis and carry the full text in a
|
|
36
|
+
tooltip; nothing is lost, because the complete marking is always listed in the
|
|
37
|
+
simulation panel.
|
|
38
|
+
|
|
39
|
+
Arc inscriptions sit at **35 % along the arc**, not at the midpoint. Two arcs
|
|
40
|
+
running between the same neighbourhood in opposite directions have their
|
|
41
|
+
midpoints in nearly the same place, so midpoint labels overlap; measuring from
|
|
42
|
+
each arc's own source separates them.
|
|
43
|
+
|
|
44
|
+
Colours come from :mod:`openprocess.gui.theme`, so everything here follows the system
|
|
45
|
+
light/dark appearance.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import math
|
|
51
|
+
from typing import TYPE_CHECKING
|
|
52
|
+
|
|
53
|
+
from PySide6.QtCore import QPointF, QRectF, Qt
|
|
54
|
+
from PySide6.QtGui import (
|
|
55
|
+
QBrush, QColor, QFont, QFontMetricsF, QPainter, QPainterPath,
|
|
56
|
+
QPainterPathStroker, QPen, QPolygonF,
|
|
57
|
+
)
|
|
58
|
+
from PySide6.QtWidgets import (
|
|
59
|
+
QGraphicsItem, QGraphicsPathItem, QGraphicsSimpleTextItem, QStyle,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
from . import theme
|
|
63
|
+
|
|
64
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
65
|
+
from ..model.net import Arc, Place, Transition
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Coordinate conversion
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
def to_scene(x: float, y: float) -> QPointF:
|
|
72
|
+
"""Model coordinates -> scene coordinates (flip y)."""
|
|
73
|
+
return QPointF(x, -y)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def to_model(point: QPointF) -> tuple[float, float]:
|
|
77
|
+
"""Scene coordinates -> model coordinates (flip y back)."""
|
|
78
|
+
return point.x(), -point.y()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Shared helpers
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
#: Longest annotation we will draw before shortening it, in scene units.
|
|
85
|
+
#: A marking such as ``1`ph(1)++1`ph(2)++1`ph(3)`` is wider than the gap
|
|
86
|
+
#: between two nodes, so drawn in full it runs straight over its neighbour.
|
|
87
|
+
#: The full text is always available in the tooltip and in the simulation
|
|
88
|
+
#: panel, so shortening on the canvas loses nothing.
|
|
89
|
+
MAX_ANNOTATION_WIDTH = 132.0
|
|
90
|
+
#: Inscriptions placed by the modeller (positions from a ``.cpn`` file) were
|
|
91
|
+
#: laid out for their full text, so they may be much longer before shortening.
|
|
92
|
+
MAX_AUTHORED_WIDTH = 340.0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _Annotation(QGraphicsSimpleTextItem):
|
|
96
|
+
"""Text on the canvas, optionally on a soft backdrop of the canvas colour.
|
|
97
|
+
|
|
98
|
+
Inscriptions often sit on top of arcs or grid dots; a translucent
|
|
99
|
+
backdrop keeps them legible without boxing them in.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
PAD = 2.0
|
|
103
|
+
|
|
104
|
+
def __init__(self, parent: QGraphicsItem, backdrop: bool) -> None:
|
|
105
|
+
super().__init__("", parent)
|
|
106
|
+
self.backdrop = backdrop
|
|
107
|
+
#: Called with the label's new centre (parent coordinates) after the
|
|
108
|
+
#: user dragged it; ``None`` = the label cannot be dragged.
|
|
109
|
+
self.on_dragged = None
|
|
110
|
+
self._drag_from: QPointF | None = None
|
|
111
|
+
|
|
112
|
+
# -- dragging (edit mode only) ------------------------------------------------
|
|
113
|
+
def _draggable(self) -> bool:
|
|
114
|
+
scene = self.scene()
|
|
115
|
+
return (self.on_dragged is not None and bool(self.text())
|
|
116
|
+
and getattr(scene, "editable", False) and getattr(scene, "tool", "") == "select")
|
|
117
|
+
|
|
118
|
+
def mousePressEvent(self, event) -> None: # noqa: N802
|
|
119
|
+
if event.button() != Qt.LeftButton or not self._draggable():
|
|
120
|
+
event.ignore() # let the node underneath handle it
|
|
121
|
+
return
|
|
122
|
+
event.accept()
|
|
123
|
+
owner = self.parentItem()
|
|
124
|
+
if owner is not None and owner.flags() & QGraphicsItem.ItemIsSelectable \
|
|
125
|
+
and not owner.isSelected():
|
|
126
|
+
if not event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier):
|
|
127
|
+
self.scene().clearSelection()
|
|
128
|
+
owner.setSelected(True)
|
|
129
|
+
self._drag_from = event.scenePos() - self.pos()
|
|
130
|
+
|
|
131
|
+
def mouseMoveEvent(self, event) -> None: # noqa: N802
|
|
132
|
+
if self._drag_from is None:
|
|
133
|
+
return
|
|
134
|
+
self.setPos(event.scenePos() - self._drag_from)
|
|
135
|
+
|
|
136
|
+
def mouseReleaseEvent(self, event) -> None: # noqa: N802
|
|
137
|
+
if self._drag_from is None:
|
|
138
|
+
return
|
|
139
|
+
self._drag_from = None
|
|
140
|
+
centre = self.pos() + super().boundingRect().center()
|
|
141
|
+
self.on_dragged(centre)
|
|
142
|
+
|
|
143
|
+
def hoverEnterEvent(self, event) -> None: # noqa: N802
|
|
144
|
+
self.setCursor(Qt.OpenHandCursor if self._draggable() else Qt.ArrowCursor)
|
|
145
|
+
|
|
146
|
+
def boundingRect(self) -> QRectF: # noqa: N802
|
|
147
|
+
rect = super().boundingRect()
|
|
148
|
+
return rect.adjusted(-self.PAD, -1, self.PAD, 1) if self.backdrop else rect
|
|
149
|
+
|
|
150
|
+
def paint(self, painter: QPainter, option, widget=None) -> None: # noqa: N802
|
|
151
|
+
if self.backdrop and self.text():
|
|
152
|
+
colour = QColor(theme.palette().canvas)
|
|
153
|
+
colour.setAlphaF(0.86)
|
|
154
|
+
painter.setPen(Qt.NoPen)
|
|
155
|
+
painter.setBrush(colour)
|
|
156
|
+
painter.drawRoundedRect(self.boundingRect(), 3, 3)
|
|
157
|
+
super().paint(painter, option, widget)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _MarkingBox(QGraphicsSimpleTextItem):
|
|
161
|
+
"""The current marking of a place, in a pale green box with a shadow --
|
|
162
|
+
how CPN Tools and CPN IDE show it next to the token count."""
|
|
163
|
+
|
|
164
|
+
PAD_X, PAD_Y = 5.0, 2.0
|
|
165
|
+
|
|
166
|
+
def boundingRect(self) -> QRectF: # noqa: N802
|
|
167
|
+
return super().boundingRect().adjusted(-self.PAD_X, -self.PAD_Y,
|
|
168
|
+
self.PAD_X + 1.5, self.PAD_Y + 1.5)
|
|
169
|
+
|
|
170
|
+
def paint(self, painter: QPainter, option, widget=None) -> None: # noqa: N802
|
|
171
|
+
box = super().boundingRect().adjusted(-self.PAD_X, -self.PAD_Y,
|
|
172
|
+
self.PAD_X, self.PAD_Y)
|
|
173
|
+
dark = theme.is_dark()
|
|
174
|
+
painter.setPen(Qt.NoPen)
|
|
175
|
+
painter.setBrush(QColor(0, 0, 0, 60))
|
|
176
|
+
painter.drawRoundedRect(box.translated(1.5, 1.5), 3, 3)
|
|
177
|
+
painter.setBrush(QColor("#1F4D24" if dark else "#C9F5C4"))
|
|
178
|
+
painter.setPen(QPen(QColor("#3C8C44" if dark else "#7CCB76"), 0.8))
|
|
179
|
+
painter.drawRoundedRect(box, 3, 3)
|
|
180
|
+
super().paint(painter, option, widget)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class _CountBubble(QGraphicsPathItem):
|
|
184
|
+
"""The green token count. Clicking it shows or hides the marking box,
|
|
185
|
+
as in CPN Tools and CPN IDE."""
|
|
186
|
+
|
|
187
|
+
def __init__(self, parent: "PlaceItem") -> None:
|
|
188
|
+
super().__init__(parent)
|
|
189
|
+
self.setCursor(Qt.PointingHandCursor)
|
|
190
|
+
self.setAcceptedMouseButtons(Qt.LeftButton)
|
|
191
|
+
|
|
192
|
+
def mousePressEvent(self, event) -> None: # noqa: N802
|
|
193
|
+
event.accept() # not a drag of the place
|
|
194
|
+
self.parentItem().toggle_marking()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _label(parent: QGraphicsItem, colour: QColor, font: QFont,
|
|
198
|
+
backdrop: bool = True) -> QGraphicsSimpleTextItem:
|
|
199
|
+
item = _Annotation(parent, backdrop)
|
|
200
|
+
item.setBrush(QBrush(colour))
|
|
201
|
+
item.setFont(font)
|
|
202
|
+
return item
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _draggable_label(item: _Annotation, node: "_NodeItem", key: str) -> None:
|
|
206
|
+
"""Let the user drag a node's inscription; its spot is kept in the model
|
|
207
|
+
(as an offset from the node's centre, in model units -- the ``.cpn`` way)."""
|
|
208
|
+
def dragged(centre: QPointF) -> None:
|
|
209
|
+
offsets = node.graphics().label_offsets
|
|
210
|
+
offsets[key] = (centre.x(), -centre.y())
|
|
211
|
+
node.refresh()
|
|
212
|
+
|
|
213
|
+
item.on_dragged = dragged
|
|
214
|
+
item.setAcceptHoverEvents(True)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
#: Sizes of plain-net nodes (scene units): places are circles, transitions
|
|
218
|
+
#: squares that grow to fit their name, silent transitions black bars.
|
|
219
|
+
PLAIN_PLACE = 40.0
|
|
220
|
+
PLAIN_TRANSITION = 40.0
|
|
221
|
+
PLAIN_SILENT = (14.0, 40.0)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _paint_tokens(painter: QPainter, count: int, rect: QRectF) -> None:
|
|
225
|
+
"""Black tokens inside a place, arranged like dice up to five; a number
|
|
226
|
+
beyond that -- how the course's figures draw markings."""
|
|
227
|
+
painter.save()
|
|
228
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
229
|
+
colour = theme.palette().text
|
|
230
|
+
if count > 5:
|
|
231
|
+
painter.setPen(colour)
|
|
232
|
+
font = theme.ui_font(12, QFont.Bold)
|
|
233
|
+
painter.setFont(font)
|
|
234
|
+
painter.drawText(rect, Qt.AlignCenter, str(count))
|
|
235
|
+
painter.restore()
|
|
236
|
+
return
|
|
237
|
+
r = min(rect.width(), rect.height()) / 2
|
|
238
|
+
d = r * 0.36 # distance from the centre
|
|
239
|
+
spots = {1: [(0, 0)], 2: [(-d, 0), (d, 0)], 3: [(-d, d * 0.6), (d, d * 0.6), (0, -d * 0.8)],
|
|
240
|
+
4: [(-d, -d), (d, -d), (-d, d), (d, d)],
|
|
241
|
+
5: [(-d, -d), (d, -d), (0, 0), (-d, d), (d, d)]}[count]
|
|
242
|
+
dot = r * (0.2 if count > 1 else 0.24)
|
|
243
|
+
painter.setPen(Qt.NoPen)
|
|
244
|
+
painter.setBrush(colour)
|
|
245
|
+
centre = rect.center()
|
|
246
|
+
for x, y in spots:
|
|
247
|
+
painter.drawEllipse(QPointF(centre.x() + x, centre.y() + y), dot, dot)
|
|
248
|
+
painter.restore()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _paint_token_row(painter: QPainter, count: int, centre: QPointF) -> None:
|
|
252
|
+
"""Tokens as a small row of dots under a name (a number beyond five)."""
|
|
253
|
+
painter.save()
|
|
254
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
255
|
+
colour = theme.palette().text
|
|
256
|
+
if count > 5:
|
|
257
|
+
painter.setPen(colour)
|
|
258
|
+
painter.setFont(theme.ui_font(9, QFont.Bold))
|
|
259
|
+
painter.drawText(QRectF(centre.x() - 20, centre.y() - 7, 40, 14), Qt.AlignCenter,
|
|
260
|
+
f"{count} ●")
|
|
261
|
+
else:
|
|
262
|
+
painter.setPen(Qt.NoPen)
|
|
263
|
+
painter.setBrush(colour)
|
|
264
|
+
gap = 6.5
|
|
265
|
+
start = centre.x() - gap * (count - 1) / 2
|
|
266
|
+
for index in range(count):
|
|
267
|
+
painter.drawEllipse(QPointF(start + gap * index, centre.y()), 2.6, 2.6)
|
|
268
|
+
painter.restore()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _rect_of(item: "_NodeItem") -> tuple[float, float, float, float]:
|
|
272
|
+
"""A node's outline rectangle in scene coordinates (left, top, width, height)."""
|
|
273
|
+
rect = item.rect().translated(item.pos())
|
|
274
|
+
return rect.left(), rect.top(), rect.width(), rect.height()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _place_at_offset(item: QGraphicsSimpleTextItem, offsets: dict, key: str) -> bool:
|
|
278
|
+
"""Centre ``item`` where the model file put it (an offset in model units).
|
|
279
|
+
|
|
280
|
+
Returns False when the file gave no position, so the caller can fall back
|
|
281
|
+
to the automatic placement.
|
|
282
|
+
"""
|
|
283
|
+
offset = offsets.get(key)
|
|
284
|
+
if offset is None:
|
|
285
|
+
return False
|
|
286
|
+
bounds = item.boundingRect()
|
|
287
|
+
# Model y points up, scene y points down.
|
|
288
|
+
item.setPos(offset[0] - bounds.width() / 2, -offset[1] - bounds.height() / 2)
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _set_elided(item: QGraphicsSimpleTextItem, text: str,
|
|
293
|
+
max_width: float = MAX_ANNOTATION_WIDTH) -> None:
|
|
294
|
+
"""Set an annotation, shortening it with an ellipsis if it is too wide.
|
|
295
|
+
|
|
296
|
+
The untruncated string goes into the tooltip, so hovering always shows the
|
|
297
|
+
real inscription.
|
|
298
|
+
"""
|
|
299
|
+
metrics = QFontMetricsF(item.font())
|
|
300
|
+
if metrics.horizontalAdvance(text) > max_width:
|
|
301
|
+
item.setText(metrics.elidedText(text, Qt.ElideRight, max_width))
|
|
302
|
+
item.setToolTip(text)
|
|
303
|
+
else:
|
|
304
|
+
item.setText(text)
|
|
305
|
+
item.setToolTip("")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class _NodeItem(QGraphicsPathItem):
|
|
309
|
+
"""Shared behaviour for places and transitions.
|
|
310
|
+
|
|
311
|
+
Both are rounded shapes with a name inside, both are movable, and both
|
|
312
|
+
write their position back to the model as they move. Factoring that out
|
|
313
|
+
keeps the two concrete classes to just their own drawing and labels.
|
|
314
|
+
|
|
315
|
+
Selection is drawn by us, not by Qt: the default is a dashed marching-ants
|
|
316
|
+
rectangle, which looks dated and obscures the annotations. We strip the
|
|
317
|
+
selected state before delegating to the base paint and draw an accent ring
|
|
318
|
+
instead.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
def __init__(self, rect: QRectF) -> None:
|
|
322
|
+
super().__init__()
|
|
323
|
+
self._rect = rect
|
|
324
|
+
#: Draw a soft accent halo (the transition that fired last).
|
|
325
|
+
self.halo = False
|
|
326
|
+
#: Step numbers at which this transition fired (the trace badge).
|
|
327
|
+
self.trace_steps: list[int] = []
|
|
328
|
+
#: How recent its last firing is, 1 (just now) .. 0 (not in the trace).
|
|
329
|
+
self.trace = 0.0
|
|
330
|
+
self.setFlags(
|
|
331
|
+
QGraphicsItem.ItemIsMovable
|
|
332
|
+
| QGraphicsItem.ItemIsSelectable
|
|
333
|
+
| QGraphicsItem.ItemSendsGeometryChanges
|
|
334
|
+
)
|
|
335
|
+
self.setZValue(10)
|
|
336
|
+
|
|
337
|
+
def rect(self) -> QRectF:
|
|
338
|
+
"""The node's bounding shape, used by :class:`ArcItem` for clipping."""
|
|
339
|
+
return self._rect
|
|
340
|
+
|
|
341
|
+
def _build_path(self) -> QPainterPath:
|
|
342
|
+
"""The outline for the current :attr:`_rect`. Supplied by subclasses."""
|
|
343
|
+
raise NotImplementedError
|
|
344
|
+
|
|
345
|
+
def _grow_to_fit(self, content_width: float, padding: float) -> bool:
|
|
346
|
+
"""Widen the node so its name fits, and report whether it changed.
|
|
347
|
+
|
|
348
|
+
A node narrower than its own label looks broken, and CPN Tools sizes
|
|
349
|
+
nodes to their names too. The new width is written back to the model,
|
|
350
|
+
so it survives a save and the diagram opens the same way next time.
|
|
351
|
+
"""
|
|
352
|
+
needed = content_width + padding
|
|
353
|
+
if needed <= self._rect.width() + 0.5:
|
|
354
|
+
return False
|
|
355
|
+
height = self._rect.height()
|
|
356
|
+
self._rect = QRectF(-needed / 2, -height / 2, needed, height)
|
|
357
|
+
self.setPath(self._build_path())
|
|
358
|
+
return True
|
|
359
|
+
|
|
360
|
+
def boundingRect(self) -> QRectF: # noqa: N802
|
|
361
|
+
# Room for the halo and the selection ring outside the outline, and
|
|
362
|
+
# for the trace badge above the top-right corner.
|
|
363
|
+
extra = (24, 90) if self.trace_steps else (6, 6)
|
|
364
|
+
return super().boundingRect().adjusted(-6, -extra[0], extra[1], 6)
|
|
365
|
+
|
|
366
|
+
def set_halo(self, on: bool) -> None:
|
|
367
|
+
if on != self.halo:
|
|
368
|
+
self.halo = on
|
|
369
|
+
self.update()
|
|
370
|
+
|
|
371
|
+
def set_trace(self, steps: list[int], strength: float) -> None:
|
|
372
|
+
"""Mark the node as part of the step-through trace (see NetScene.set_trace)."""
|
|
373
|
+
if steps == self.trace_steps and abs(strength - self.trace) < 1e-9:
|
|
374
|
+
return
|
|
375
|
+
self.prepareGeometryChange()
|
|
376
|
+
self.trace_steps, self.trace = list(steps), strength
|
|
377
|
+
self.update()
|
|
378
|
+
|
|
379
|
+
def _paint_trace_badge(self, painter: QPainter) -> None:
|
|
380
|
+
"""The step numbers, in a small accent pill above the top-right corner."""
|
|
381
|
+
steps = self.trace_steps
|
|
382
|
+
text = ("… " if len(steps) > 3 else "") + ", ".join(str(s) for s in steps[-3:])
|
|
383
|
+
font = theme.ui_font(9, QFont.DemiBold)
|
|
384
|
+
painter.setFont(font)
|
|
385
|
+
metrics = QFontMetricsF(font)
|
|
386
|
+
width, height = metrics.horizontalAdvance(text) + 10, metrics.height() + 2
|
|
387
|
+
corner = self._rect.topRight()
|
|
388
|
+
pill = QRectF(corner.x() - width * 0.35, corner.y() - height - 3, width, height)
|
|
389
|
+
colour = QColor(theme.palette().accent)
|
|
390
|
+
colour.setAlphaF(0.45 + 0.55 * self.trace)
|
|
391
|
+
painter.setPen(Qt.NoPen)
|
|
392
|
+
painter.setBrush(colour)
|
|
393
|
+
painter.drawRoundedRect(pill, height / 2, height / 2)
|
|
394
|
+
painter.setPen(QColor("#ffffff"))
|
|
395
|
+
painter.drawText(pill, Qt.AlignCenter, text)
|
|
396
|
+
|
|
397
|
+
def paint(self, painter: QPainter, option, widget=None) -> None: # noqa: N802
|
|
398
|
+
selected = bool(option.state & QStyle.State_Selected)
|
|
399
|
+
option.state &= ~QStyle.State_Selected
|
|
400
|
+
if self.halo:
|
|
401
|
+
colour = QColor(theme.palette().accent)
|
|
402
|
+
colour.setAlphaF(0.30)
|
|
403
|
+
painter.setPen(QPen(colour, 8.0))
|
|
404
|
+
painter.setBrush(Qt.NoBrush)
|
|
405
|
+
painter.drawPath(self.path())
|
|
406
|
+
super().paint(painter, option, widget)
|
|
407
|
+
if selected:
|
|
408
|
+
palette = theme.palette()
|
|
409
|
+
pen = QPen(palette.accent, 2.0)
|
|
410
|
+
pen.setCosmetic(True)
|
|
411
|
+
painter.setPen(pen)
|
|
412
|
+
painter.setBrush(Qt.NoBrush)
|
|
413
|
+
painter.drawPath(self.path())
|
|
414
|
+
if self.trace_steps:
|
|
415
|
+
self._paint_trace_badge(painter)
|
|
416
|
+
|
|
417
|
+
def graphics(self):
|
|
418
|
+
"""The model's graphics record for this node."""
|
|
419
|
+
raise NotImplementedError
|
|
420
|
+
|
|
421
|
+
def name_anchor(self) -> tuple[QPointF, float]:
|
|
422
|
+
"""Where the name is shown (scene point) and how wide an editor for
|
|
423
|
+
it should be at least: over the node when the name is inside it,
|
|
424
|
+
over the label when it is outside."""
|
|
425
|
+
label = getattr(self, "name_label", None)
|
|
426
|
+
if getattr(self, "names_outside", False) and label is not None and label.isVisible():
|
|
427
|
+
return label.sceneBoundingRect().center(), 60.0
|
|
428
|
+
return self.scenePos(), max(60.0, self._rect.width())
|
|
429
|
+
|
|
430
|
+
def refresh(self) -> None:
|
|
431
|
+
raise NotImplementedError
|
|
432
|
+
|
|
433
|
+
def _notify_arcs(self) -> None:
|
|
434
|
+
"""Ask every arc touching this node to recompute its route."""
|
|
435
|
+
scene = self.scene()
|
|
436
|
+
if scene is None:
|
|
437
|
+
return
|
|
438
|
+
identifier = getattr(self, "model_id", None)
|
|
439
|
+
for item in scene.items():
|
|
440
|
+
if isinstance(item, ArcItem) and item.touches(identifier):
|
|
441
|
+
item.reroute()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
# Places
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
class PlaceItem(_NodeItem):
|
|
448
|
+
"""An ellipse with its name inside and four annotations around it."""
|
|
449
|
+
|
|
450
|
+
def __init__(self, place: "Place", plain: bool = False,
|
|
451
|
+
names_outside: bool = False) -> None:
|
|
452
|
+
#: Drawn as in the course's plain Petri nets: a circle with black
|
|
453
|
+
#: tokens inside.
|
|
454
|
+
self.plain = plain
|
|
455
|
+
#: The name under the node (draggable) rather than inside it.
|
|
456
|
+
self.names_outside = names_outside
|
|
457
|
+
if plain:
|
|
458
|
+
width = height = PLAIN_PLACE
|
|
459
|
+
else:
|
|
460
|
+
width = place.graphics.width or 76.0
|
|
461
|
+
height = place.graphics.height or 48.0
|
|
462
|
+
super().__init__(QRectF(-width / 2, -height / 2, width, height))
|
|
463
|
+
self.place = place
|
|
464
|
+
self.model_id = place.id
|
|
465
|
+
self.tokens = 0
|
|
466
|
+
|
|
467
|
+
self.setPath(self._build_path())
|
|
468
|
+
|
|
469
|
+
palette = theme.palette()
|
|
470
|
+
self.setBrush(QBrush(palette.surface))
|
|
471
|
+
self.setPen(QPen(palette.border_strong, 1.6))
|
|
472
|
+
|
|
473
|
+
self.name_label = _label(self, palette.text, theme.ui_font(12, QFont.DemiBold), False)
|
|
474
|
+
type_font = theme.ui_font(10)
|
|
475
|
+
type_font.setItalic(True)
|
|
476
|
+
self.type_label = _label(self, palette.text_muted, type_font)
|
|
477
|
+
self.initial_label = _label(self, palette.inscription, theme.mono_font(10))
|
|
478
|
+
_draggable_label(self.type_label, self, "type")
|
|
479
|
+
_draggable_label(self.initial_label, self, "initmark")
|
|
480
|
+
if names_outside:
|
|
481
|
+
self.name_label.backdrop = True
|
|
482
|
+
_draggable_label(self.name_label, self, "name")
|
|
483
|
+
|
|
484
|
+
# The live marking, as in CPN Tools: a green token count at the
|
|
485
|
+
# place's right-hand edge, and next to it the tokens themselves in a
|
|
486
|
+
# pale green box that clicking the count shows or hides.
|
|
487
|
+
self.count_pill = _CountBubble(self)
|
|
488
|
+
self.count_pill.setPen(QPen(Qt.NoPen))
|
|
489
|
+
self.count_pill.setBrush(QBrush(palette.success))
|
|
490
|
+
self.count_label = _label(self.count_pill, QColor("#FFFFFF"),
|
|
491
|
+
theme.ui_font(10, QFont.Bold), False)
|
|
492
|
+
self.marking_label = _MarkingBox(self)
|
|
493
|
+
self.marking_label.setFont(theme.mono_font(10))
|
|
494
|
+
self.marking_label.setBrush(QBrush(QColor("#D7F5D7" if theme.is_dark() else "#113311")))
|
|
495
|
+
self.marking_label.setZValue(2)
|
|
496
|
+
self._marking_state = (0, "", None)
|
|
497
|
+
self.set_marking(0, "")
|
|
498
|
+
|
|
499
|
+
self.setPos(to_scene(place.graphics.x, place.graphics.y))
|
|
500
|
+
self.refresh()
|
|
501
|
+
|
|
502
|
+
# -- presentation --------------------------------------------------------
|
|
503
|
+
def graphics(self):
|
|
504
|
+
return self.place.graphics
|
|
505
|
+
|
|
506
|
+
def _build_path(self) -> QPainterPath:
|
|
507
|
+
path = QPainterPath()
|
|
508
|
+
path.addEllipse(self._rect)
|
|
509
|
+
return path
|
|
510
|
+
|
|
511
|
+
def refresh(self) -> None:
|
|
512
|
+
"""Re-read the model object and lay the annotations out."""
|
|
513
|
+
self.name_label.setText(self.place.name)
|
|
514
|
+
bounds = self.name_label.boundingRect()
|
|
515
|
+
if self.names_outside:
|
|
516
|
+
# The name under the node (or where the user dragged it).
|
|
517
|
+
if not _place_at_offset(self.name_label, self.place.graphics.label_offsets, "name"):
|
|
518
|
+
self.name_label.setPos(-bounds.width() / 2, self._rect.bottom() + 1)
|
|
519
|
+
elif self.plain:
|
|
520
|
+
# A circle just big enough for the name.
|
|
521
|
+
diameter = max(PLAIN_PLACE, bounds.width() + 16)
|
|
522
|
+
if abs(diameter - self._rect.width()) > 0.5:
|
|
523
|
+
self.prepareGeometryChange()
|
|
524
|
+
self._rect = QRectF(-diameter / 2, -diameter / 2, diameter, diameter)
|
|
525
|
+
self.setPath(self._build_path())
|
|
526
|
+
self._layout_inside_name()
|
|
527
|
+
else:
|
|
528
|
+
# An ellipse only offers about 70 % of its width at the text's
|
|
529
|
+
# height, so the name needs proportionally more room here than in
|
|
530
|
+
# a rectangle.
|
|
531
|
+
if self._grow_to_fit(bounds.width() / 0.70, 12.0):
|
|
532
|
+
self.place.graphics.width = self._rect.width()
|
|
533
|
+
self.name_label.setPos(-bounds.width() / 2, -bounds.height() / 2)
|
|
534
|
+
if self.plain:
|
|
535
|
+
self.type_label.hide()
|
|
536
|
+
self.initial_label.hide()
|
|
537
|
+
return
|
|
538
|
+
rect = self._rect
|
|
539
|
+
|
|
540
|
+
# Inscriptions go where the model file put them; otherwise at CPN
|
|
541
|
+
# Tools' default spots: colour set below right, initial marking above
|
|
542
|
+
# right.
|
|
543
|
+
offsets = self.place.graphics.label_offsets
|
|
544
|
+
self.type_label.setText(self.place.colour_set_name)
|
|
545
|
+
if not _place_at_offset(self.type_label, offsets, "type"):
|
|
546
|
+
self.type_label.setPos(rect.right() - 4, rect.bottom() - 4)
|
|
547
|
+
|
|
548
|
+
_set_elided(self.initial_label, self.place.initial_marking_text,
|
|
549
|
+
MAX_AUTHORED_WIDTH if "initmark" in offsets else MAX_ANNOTATION_WIDTH)
|
|
550
|
+
if not _place_at_offset(self.initial_label, offsets, "initmark"):
|
|
551
|
+
initial_bounds = self.initial_label.boundingRect()
|
|
552
|
+
self.initial_label.setPos(rect.right() - 4, rect.top() - initial_bounds.height() + 4)
|
|
553
|
+
|
|
554
|
+
def set_marking(self, count: int, text: str, show_values: bool | None = None) -> None:
|
|
555
|
+
"""Show the simulator's current contents at the place.
|
|
556
|
+
|
|
557
|
+
``count`` goes in the green bubble at the place's right-hand edge;
|
|
558
|
+
``text`` (the multiset) in the box beside it, which is shown or
|
|
559
|
+
hidden per place -- the model file's own setting at first, then by
|
|
560
|
+
clicking the bubble -- unless ``show_values`` forces all on or off.
|
|
561
|
+
The tooltip always has the full marking. An empty place shows
|
|
562
|
+
nothing.
|
|
563
|
+
"""
|
|
564
|
+
self._marking_state = (count, text, show_values)
|
|
565
|
+
if self.plain:
|
|
566
|
+
self.tokens = count
|
|
567
|
+
self.count_pill.hide()
|
|
568
|
+
self.marking_label.hide()
|
|
569
|
+
self.setToolTip(f"{self.place.name}: {count} token(s)")
|
|
570
|
+
if not self.names_outside:
|
|
571
|
+
self._layout_inside_name()
|
|
572
|
+
self.update()
|
|
573
|
+
return
|
|
574
|
+
visible = count > 0
|
|
575
|
+
self.count_pill.setVisible(visible)
|
|
576
|
+
if show_values is None:
|
|
577
|
+
show_values = not self.place.graphics.marking_hidden
|
|
578
|
+
self.marking_label.setVisible(visible and show_values)
|
|
579
|
+
tip = "" if not visible else f"{self.place.name}: {count} token(s)\n" + (
|
|
580
|
+
text if len(text) < 1500 else text[:1500] + " …")
|
|
581
|
+
self.setToolTip(tip)
|
|
582
|
+
self.count_pill.setToolTip(tip + ("\n\nClick to show or hide the tokens" if tip else ""))
|
|
583
|
+
if not visible:
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
self.count_label.setText(str(count))
|
|
587
|
+
label_bounds = self.count_label.boundingRect()
|
|
588
|
+
pill_height = 16.0
|
|
589
|
+
pill_width = max(pill_height, label_bounds.width() + 9)
|
|
590
|
+
pill = QPainterPath()
|
|
591
|
+
pill.addRoundedRect(QRectF(0, 0, pill_width, pill_height),
|
|
592
|
+
pill_height / 2, pill_height / 2)
|
|
593
|
+
self.count_pill.setPath(pill)
|
|
594
|
+
self.count_label.setPos((pill_width - label_bounds.width()) / 2,
|
|
595
|
+
(pill_height - label_bounds.height()) / 2)
|
|
596
|
+
|
|
597
|
+
# CPN Tools' convention (as CPN IDE reads it): the count's corner is
|
|
598
|
+
# at the place's right edge, level with its centre, moved by the
|
|
599
|
+
# file's "token" offset; the marking box is to the right of the count,
|
|
600
|
+
# moved by the "marking" offset. Offsets from a file that would put
|
|
601
|
+
# them far away are ignored.
|
|
602
|
+
tx, ty = self.place.graphics.label_offsets.get("token", (0.0, 0.0))
|
|
603
|
+
if abs(tx) > 60 or abs(ty) > 60:
|
|
604
|
+
tx = ty = 0.0
|
|
605
|
+
left, top = self._rect.right() + tx - 4, -ty - pill_height / 2
|
|
606
|
+
self.count_pill.setPos(left, top)
|
|
607
|
+
|
|
608
|
+
_set_elided(self.marking_label, text, 260.0)
|
|
609
|
+
mx, my = self.place.graphics.label_offsets.get("marking", (0.0, 0.0))
|
|
610
|
+
if abs(mx) > 120 or abs(my) > 120:
|
|
611
|
+
mx = my = 0.0
|
|
612
|
+
box_left = max(left + pill_width + 6, left + pill_width + 6 + mx)
|
|
613
|
+
text_bounds = self.marking_label.boundingRect()
|
|
614
|
+
self.marking_label.setPos(box_left + _MarkingBox.PAD_X,
|
|
615
|
+
top - my + (pill_height - text_bounds.height()) / 2
|
|
616
|
+
+ _MarkingBox.PAD_Y)
|
|
617
|
+
|
|
618
|
+
def _name_inside_with_tokens(self) -> bool:
|
|
619
|
+
return self.plain and not self.names_outside and bool(self.place.name) and \
|
|
620
|
+
self.tokens > 0
|
|
621
|
+
|
|
622
|
+
def _layout_inside_name(self) -> None:
|
|
623
|
+
"""Centre the name in the circle; with tokens, the name moves up and
|
|
624
|
+
the tokens go in a row underneath it."""
|
|
625
|
+
bounds = self.name_label.boundingRect()
|
|
626
|
+
lift = 6.0 if self._name_inside_with_tokens() else 0.0
|
|
627
|
+
self.name_label.setPos(-bounds.width() / 2, -bounds.height() / 2 - lift)
|
|
628
|
+
|
|
629
|
+
def paint(self, painter: QPainter, option, widget=None) -> None: # noqa: N802
|
|
630
|
+
super().paint(painter, option, widget)
|
|
631
|
+
if not (self.plain and self.tokens):
|
|
632
|
+
return
|
|
633
|
+
if self._name_inside_with_tokens():
|
|
634
|
+
_paint_token_row(painter, self.tokens, QPointF(0, 9.0))
|
|
635
|
+
else:
|
|
636
|
+
_paint_tokens(painter, self.tokens, self._rect)
|
|
637
|
+
|
|
638
|
+
def toggle_marking(self) -> None:
|
|
639
|
+
"""Clicking the count: show or hide this place's tokens."""
|
|
640
|
+
self.place.graphics.marking_hidden = self.marking_label.isVisible()
|
|
641
|
+
count, text, _forced = self._marking_state
|
|
642
|
+
self.set_marking(count, text, None)
|
|
643
|
+
|
|
644
|
+
def set_error(self, has_error: bool) -> None:
|
|
645
|
+
palette = theme.palette()
|
|
646
|
+
self.setPen(QPen(palette.danger if has_error else palette.border_strong, 1.6))
|
|
647
|
+
|
|
648
|
+
def itemChange(self, change, value): # noqa: N802 - Qt naming
|
|
649
|
+
if change == QGraphicsItem.ItemPositionHasChanged:
|
|
650
|
+
self.place.graphics.x, self.place.graphics.y = to_model(self.pos())
|
|
651
|
+
self._notify_arcs()
|
|
652
|
+
return super().itemChange(change, value)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# ---------------------------------------------------------------------------
|
|
656
|
+
# Transitions
|
|
657
|
+
# ---------------------------------------------------------------------------
|
|
658
|
+
class TransitionItem(_NodeItem):
|
|
659
|
+
"""A rounded rectangle with name, guard and time labels, plus the enabling cue."""
|
|
660
|
+
|
|
661
|
+
def __init__(self, transition: "Transition", plain: bool = False,
|
|
662
|
+
names_outside: bool = False) -> None:
|
|
663
|
+
#: Plain nets: a square with the name inside; a silent (τ) transition
|
|
664
|
+
#: is a black bar, as in the course's figures.
|
|
665
|
+
self.plain = plain
|
|
666
|
+
#: The name under the box (draggable) rather than inside it.
|
|
667
|
+
self.names_outside = names_outside
|
|
668
|
+
self.silent = plain and getattr(transition, "silent", False)
|
|
669
|
+
if self.silent:
|
|
670
|
+
width, height = PLAIN_SILENT
|
|
671
|
+
elif plain:
|
|
672
|
+
width = height = PLAIN_TRANSITION
|
|
673
|
+
else:
|
|
674
|
+
width = transition.graphics.width or 82.0
|
|
675
|
+
height = transition.graphics.height or 48.0
|
|
676
|
+
super().__init__(QRectF(-width / 2, -height / 2, width, height))
|
|
677
|
+
self.transition = transition
|
|
678
|
+
self.model_id = transition.id
|
|
679
|
+
self.enabled_count = 0
|
|
680
|
+
self._has_error = False
|
|
681
|
+
|
|
682
|
+
self.setPath(self._build_path())
|
|
683
|
+
|
|
684
|
+
palette = theme.palette()
|
|
685
|
+
self.name_label = _label(self, palette.text, theme.ui_font(12, QFont.DemiBold), False)
|
|
686
|
+
self.guard_label = _label(self, palette.inscription, theme.mono_font(10))
|
|
687
|
+
self.time_label = _label(self, palette.inscription, theme.mono_font(10))
|
|
688
|
+
_draggable_label(self.guard_label, self, "cond")
|
|
689
|
+
_draggable_label(self.time_label, self, "time")
|
|
690
|
+
if names_outside:
|
|
691
|
+
self.name_label.backdrop = True
|
|
692
|
+
_draggable_label(self.name_label, self, "name")
|
|
693
|
+
|
|
694
|
+
# The binding count sits in its own pill, mirroring the place's marking
|
|
695
|
+
# pill so the two read as the same kind of information.
|
|
696
|
+
self.count_pill = QGraphicsPathItem(self)
|
|
697
|
+
self.count_pill.setPen(QPen(Qt.NoPen))
|
|
698
|
+
self.count_pill.setBrush(QBrush(palette.success))
|
|
699
|
+
self.count_label = _label(self.count_pill, QColor("#FFFFFF"),
|
|
700
|
+
theme.ui_font(10, QFont.Bold), False)
|
|
701
|
+
self.count_pill.setVisible(False)
|
|
702
|
+
|
|
703
|
+
self.setPos(to_scene(transition.graphics.x, transition.graphics.y))
|
|
704
|
+
self.refresh()
|
|
705
|
+
self.set_enabled_count(0)
|
|
706
|
+
|
|
707
|
+
def graphics(self):
|
|
708
|
+
return self.transition.graphics
|
|
709
|
+
|
|
710
|
+
def _build_path(self) -> QPainterPath:
|
|
711
|
+
path = QPainterPath()
|
|
712
|
+
radius = 2.0 if getattr(self, "plain", False) else theme.RADIUS
|
|
713
|
+
path.addRoundedRect(self._rect, radius, radius)
|
|
714
|
+
return path
|
|
715
|
+
|
|
716
|
+
def refresh(self) -> None:
|
|
717
|
+
palette = theme.palette()
|
|
718
|
+
|
|
719
|
+
self.name_label.setText(self.transition.name)
|
|
720
|
+
bounds = self.name_label.boundingRect()
|
|
721
|
+
if self.names_outside:
|
|
722
|
+
if not _place_at_offset(self.name_label, self.transition.graphics.label_offsets,
|
|
723
|
+
"name"):
|
|
724
|
+
self.name_label.setPos(-bounds.width() / 2, self._rect.bottom() + 1)
|
|
725
|
+
if self.plain:
|
|
726
|
+
self.guard_label.hide()
|
|
727
|
+
self.time_label.hide()
|
|
728
|
+
self.name_label.setVisible(not self.silent)
|
|
729
|
+
self.setToolTip(f"τ (silent){': ' + self.transition.name if self.transition.name else ''}"
|
|
730
|
+
if self.silent else "")
|
|
731
|
+
if not self.silent and not self.names_outside:
|
|
732
|
+
self._grow_to_fit(bounds.width(), 14.0)
|
|
733
|
+
self.name_label.setPos(-bounds.width() / 2, -bounds.height() / 2)
|
|
734
|
+
return
|
|
735
|
+
if not self.names_outside:
|
|
736
|
+
if self._grow_to_fit(bounds.width(), 26.0):
|
|
737
|
+
self.transition.graphics.width = self._rect.width()
|
|
738
|
+
self.name_label.setPos(-bounds.width() / 2, -bounds.height() / 2)
|
|
739
|
+
rect = self._rect
|
|
740
|
+
|
|
741
|
+
# Guards are conventionally shown in square brackets, to the left.
|
|
742
|
+
offsets = self.transition.graphics.label_offsets
|
|
743
|
+
guard = self.transition.guard_text.strip()
|
|
744
|
+
if guard and not guard.startswith("["):
|
|
745
|
+
guard = f"[{guard}]"
|
|
746
|
+
_set_elided(self.guard_label, guard, MAX_AUTHORED_WIDTH if "cond" in offsets else 200)
|
|
747
|
+
if not _place_at_offset(self.guard_label, offsets, "cond"):
|
|
748
|
+
# CPN Tools' default spot: above, to the left.
|
|
749
|
+
bounds = self.guard_label.boundingRect()
|
|
750
|
+
self.guard_label.setPos(rect.left() - 5 - bounds.width() + 8,
|
|
751
|
+
rect.top() - bounds.height() + 2)
|
|
752
|
+
|
|
753
|
+
# The file may store "@+5" or just "5"; show it once as "@+5".
|
|
754
|
+
delay = self.transition.time_text.strip()
|
|
755
|
+
if delay.startswith("@+"):
|
|
756
|
+
delay = delay[2:].strip()
|
|
757
|
+
_set_elided(self.time_label, f"@+{delay}" if delay else "",
|
|
758
|
+
MAX_AUTHORED_WIDTH if "time" in offsets else 200)
|
|
759
|
+
if not _place_at_offset(self.time_label, offsets, "time"):
|
|
760
|
+
# Above, to the right.
|
|
761
|
+
bounds = self.time_label.boundingRect()
|
|
762
|
+
self.time_label.setPos(rect.right() - 3, rect.top() - bounds.height() + 2)
|
|
763
|
+
|
|
764
|
+
def set_enabled_count(self, count: int) -> None:
|
|
765
|
+
"""Colour the transition according to how many bindings are enabled."""
|
|
766
|
+
self.enabled_count = count
|
|
767
|
+
palette = theme.palette()
|
|
768
|
+
|
|
769
|
+
if self.silent:
|
|
770
|
+
self.setBrush(QBrush(palette.text))
|
|
771
|
+
self.setPen(QPen(palette.success if count else palette.text, 2.4 if count else 1.2))
|
|
772
|
+
elif self._has_error:
|
|
773
|
+
self.setBrush(QBrush(palette.surface))
|
|
774
|
+
self.setPen(QPen(palette.danger, 1.8))
|
|
775
|
+
elif count:
|
|
776
|
+
self.setBrush(QBrush(palette.success_soft))
|
|
777
|
+
self.setPen(QPen(palette.success, 2.2))
|
|
778
|
+
else:
|
|
779
|
+
self.setBrush(QBrush(palette.surface))
|
|
780
|
+
# A substitution transition gets a heavier border, as in CPN Tools,
|
|
781
|
+
# because it stands for a whole subpage rather than an event.
|
|
782
|
+
self.setPen(QPen(palette.border_strong,
|
|
783
|
+
3.0 if self.transition.is_substitution else 1.6))
|
|
784
|
+
|
|
785
|
+
# Only show the pill when there is a genuine choice to make.
|
|
786
|
+
self.count_pill.setVisible(count > 1 and not self.plain)
|
|
787
|
+
if count > 1 and not self.plain:
|
|
788
|
+
self.count_label.setText(str(count))
|
|
789
|
+
bounds = self.count_label.boundingRect()
|
|
790
|
+
width = max(18.0, bounds.width() + 10)
|
|
791
|
+
height = 16.0
|
|
792
|
+
pill = QPainterPath()
|
|
793
|
+
pill.addRoundedRect(QRectF(0, 0, width, height), height / 2, height / 2)
|
|
794
|
+
self.count_pill.setPath(pill)
|
|
795
|
+
self.count_label.setPos((width - bounds.width()) / 2,
|
|
796
|
+
(height - bounds.height()) / 2)
|
|
797
|
+
self.count_pill.setPos(self._rect.right() - width / 2,
|
|
798
|
+
self._rect.bottom() - height / 2 - 1)
|
|
799
|
+
|
|
800
|
+
def set_error(self, has_error: bool) -> None:
|
|
801
|
+
self._has_error = has_error
|
|
802
|
+
self.set_enabled_count(self.enabled_count)
|
|
803
|
+
|
|
804
|
+
def itemChange(self, change, value): # noqa: N802 - Qt naming
|
|
805
|
+
if change == QGraphicsItem.ItemPositionHasChanged:
|
|
806
|
+
self.transition.graphics.x, self.transition.graphics.y = to_model(self.pos())
|
|
807
|
+
self._notify_arcs()
|
|
808
|
+
return super().itemChange(change, value)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# ---------------------------------------------------------------------------
|
|
812
|
+
# Arcs
|
|
813
|
+
# ---------------------------------------------------------------------------
|
|
814
|
+
class ArcItem(QGraphicsPathItem):
|
|
815
|
+
"""A directed arc with an arrowhead and an inscription label.
|
|
816
|
+
|
|
817
|
+
Three routing features, each fixing a way that a naive straight line
|
|
818
|
+
between two centres goes wrong:
|
|
819
|
+
|
|
820
|
+
**Endpoint clipping.** The line stops at the boundary of each shape, so
|
|
821
|
+
the arrowhead lands on the edge of the transition rather than at its centre.
|
|
822
|
+
|
|
823
|
+
**Bendpoints.** ``.cpn`` files store polyline bendpoints, and real models
|
|
824
|
+
use them heavily to route around nodes. Ignoring them would make an
|
|
825
|
+
imported model look nothing like it does in CPN Tools, so the path follows
|
|
826
|
+
them when present.
|
|
827
|
+
|
|
828
|
+
**Parallel arcs.** Two arcs between the *same* place and transition -- a
|
|
829
|
+
consume-and-return pair, say -- would otherwise be drawn exactly on top of
|
|
830
|
+
each other. When the scene reports that an arc has siblings, it is
|
|
831
|
+
shifted sideways (``bow``) so both are visible, as in CPN IDE.
|
|
832
|
+
|
|
833
|
+
Editing (CPN IDE's rules, see :mod:`openprocess.gui.arc_editing`) is driven by
|
|
834
|
+
the scene; this item draws the handles: a small circle on every waypoint
|
|
835
|
+
while the arc is hovered or selected, one more following the mouse along
|
|
836
|
+
the line, and a bar in the middle of each horizontal or vertical segment.
|
|
837
|
+
"""
|
|
838
|
+
|
|
839
|
+
#: Trace highlight strength, 0 (off) .. 1 (the latest step).
|
|
840
|
+
trace = 0.0
|
|
841
|
+
|
|
842
|
+
ARROW_LENGTH = 10.0
|
|
843
|
+
ARROW_HALF_WIDTH = 4.2
|
|
844
|
+
#: Where along the arc the inscription sits. Not 0.5: see the module
|
|
845
|
+
#: docstring -- opposing arcs would collide at the midpoint.
|
|
846
|
+
LABEL_POSITION = 0.35
|
|
847
|
+
#: Handle radius on screen, in pixels (diagram-js: 4).
|
|
848
|
+
HANDLE_RADIUS = 4.0
|
|
849
|
+
|
|
850
|
+
def __init__(self, arc: "Arc", place_item: PlaceItem,
|
|
851
|
+
transition_item: TransitionItem, bow: float = 0.0,
|
|
852
|
+
plain: bool = False) -> None:
|
|
853
|
+
super().__init__()
|
|
854
|
+
self.plain = plain
|
|
855
|
+
self.arc = arc
|
|
856
|
+
self.place_item = place_item
|
|
857
|
+
self.transition_item = transition_item
|
|
858
|
+
self.bow = bow
|
|
859
|
+
|
|
860
|
+
palette = theme.palette()
|
|
861
|
+
self.setPen(QPen(palette.border_strong, 1.5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
|
|
862
|
+
self.setBrush(QBrush(palette.border_strong))
|
|
863
|
+
self.setZValue(5)
|
|
864
|
+
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
|
|
865
|
+
self.setAcceptHoverEvents(True)
|
|
866
|
+
|
|
867
|
+
self.inscription_label = _label(self, palette.inscription, theme.mono_font(10))
|
|
868
|
+
self.inscription_label.on_dragged = self._inscription_dragged
|
|
869
|
+
self.inscription_label.setAcceptHoverEvents(True)
|
|
870
|
+
#: Where the modeller put the inscription, relative to the midpoint
|
|
871
|
+
#: of the two nodes (so it follows them when they move).
|
|
872
|
+
self._label_offset: QPointF | None = None
|
|
873
|
+
absolute = arc.graphics.label_offsets.get("annot")
|
|
874
|
+
if absolute is not None and arc.graphics.label_offsets:
|
|
875
|
+
self._label_offset = to_scene(*absolute) - self._node_midpoint()
|
|
876
|
+
#: While nodes are dragged: the arc as it was when the drag began
|
|
877
|
+
#: (transition position, place position, bends, both end points).
|
|
878
|
+
self.drag_origin: tuple | None = None
|
|
879
|
+
#: While one end is dragged to another node: which end and where.
|
|
880
|
+
self.loose_end: tuple[str, QPointF] | None = None
|
|
881
|
+
#: Hover state for the handles.
|
|
882
|
+
self.hovered = False
|
|
883
|
+
self.floating: QPointF | None = None
|
|
884
|
+
#: True while the scene drags part of this arc (handles hidden).
|
|
885
|
+
self.editing = False
|
|
886
|
+
self._waypoints: list[QPointF] = []
|
|
887
|
+
self.reroute()
|
|
888
|
+
|
|
889
|
+
def _node_midpoint(self) -> QPointF:
|
|
890
|
+
return (self.place_item.pos() + self.transition_item.pos()) / 2
|
|
891
|
+
|
|
892
|
+
def touches(self, element_id: str | None) -> bool:
|
|
893
|
+
return element_id is not None and element_id in (
|
|
894
|
+
self.arc.place_id, self.arc.transition_id
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
def waypoints(self) -> list[QPointF]:
|
|
898
|
+
"""The drawn route, from the transition end to the place end: where
|
|
899
|
+
it leaves one node, the bends, where it meets the other."""
|
|
900
|
+
return list(self._waypoints)
|
|
901
|
+
|
|
902
|
+
# -- geometry ------------------------------------------------------------
|
|
903
|
+
#: A bendpoint within this distance (horizontally or vertically) of a
|
|
904
|
+
#: node's centre makes the arc leave the node straight across or straight
|
|
905
|
+
#: down -- CPN Tools' tidy right-angled arcs.
|
|
906
|
+
SNAP = 20.0
|
|
907
|
+
|
|
908
|
+
def reroute(self) -> None:
|
|
909
|
+
"""Recompute the path, the arrowhead(s) and the label position.
|
|
910
|
+
|
|
911
|
+
The rules are those of CPN Tools and CPN IDE:
|
|
912
|
+
|
|
913
|
+
* an input arc runs place -> transition, every other one (output
|
|
914
|
+
and double-headed) transition -> place;
|
|
915
|
+
* bendpoints are stored from the transition end to the place end,
|
|
916
|
+
and are joined by straight segments;
|
|
917
|
+
* a first or last bendpoint roughly level with (or above) its node
|
|
918
|
+
pulls the end of the arc level with it, so it leaves the node at a
|
|
919
|
+
right angle instead of heading for the exact centre;
|
|
920
|
+
* arcs between the same place and transition without bendpoints are
|
|
921
|
+
drawn side by side as parallel lines.
|
|
922
|
+
"""
|
|
923
|
+
self._repair_after_node_move()
|
|
924
|
+
stored = [to_scene(x, y) for x, y in self.arc.bendpoints] # transition -> place
|
|
925
|
+
loose = self.loose_end
|
|
926
|
+
if loose is not None:
|
|
927
|
+
# One end follows the mouse (reconnecting): no cropping there.
|
|
928
|
+
which, point = loose
|
|
929
|
+
if which == "place":
|
|
930
|
+
towards = stored[0] if stored else point
|
|
931
|
+
t_end = self._crop(self.transition_item,
|
|
932
|
+
self._anchor(self.transition_item, towards), towards)
|
|
933
|
+
p_end = point
|
|
934
|
+
else:
|
|
935
|
+
towards = stored[-1] if stored else point
|
|
936
|
+
p_end = self._crop(self.place_item,
|
|
937
|
+
self._anchor(self.place_item, towards), towards)
|
|
938
|
+
t_end = point
|
|
939
|
+
elif stored:
|
|
940
|
+
t_end = self._crop(self.transition_item,
|
|
941
|
+
self._anchor(self.transition_item, stored[0]), stored[0])
|
|
942
|
+
p_end = self._crop(self.place_item,
|
|
943
|
+
self._anchor(self.place_item, stored[-1]), stored[-1])
|
|
944
|
+
else:
|
|
945
|
+
# Parallel arcs: shift both ends sideways by the same amount.
|
|
946
|
+
# The side is measured in one fixed direction (place ->
|
|
947
|
+
# transition), so an input and an output arc between the same
|
|
948
|
+
# nodes go to opposite sides instead of on top of each other.
|
|
949
|
+
chord = self.transition_item.pos() - self.place_item.pos()
|
|
950
|
+
length = math.hypot(chord.x(), chord.y()) or 1.0
|
|
951
|
+
shift = QPointF(-chord.y() / length, chord.x() / length) * self.bow
|
|
952
|
+
a = self.transition_item.pos() + shift
|
|
953
|
+
b = self.place_item.pos() + shift
|
|
954
|
+
t_end = self._crop(self.transition_item, a, b)
|
|
955
|
+
p_end = self._crop(self.place_item, b, a)
|
|
956
|
+
self._waypoints = [t_end, *stored, p_end]
|
|
957
|
+
|
|
958
|
+
route = self._waypoints if not (self.arc.is_input and not self.arc.is_output) \
|
|
959
|
+
else self._waypoints[::-1]
|
|
960
|
+
path = QPainterPath(route[0])
|
|
961
|
+
for point in route[1:]:
|
|
962
|
+
path.lineTo(point)
|
|
963
|
+
|
|
964
|
+
# The line is stroked and the arrowheads are filled, so they live in
|
|
965
|
+
# separate paths. (One combined, filled path would also fill the area
|
|
966
|
+
# enclosed by a bent line -- the "black triangles" bug.)
|
|
967
|
+
self.prepareGeometryChange()
|
|
968
|
+
self.line_path = QPainterPath(path)
|
|
969
|
+
arrows = QPainterPath()
|
|
970
|
+
self._append_arrowhead(arrows, self._tangent_source(path, 0.97), route[-1])
|
|
971
|
+
if self.arc.orientation == "BOTHDIR":
|
|
972
|
+
self._append_arrowhead(arrows, self._tangent_source(path, 0.03), route[0])
|
|
973
|
+
self.arrow_path = arrows
|
|
974
|
+
combined = QPainterPath(path)
|
|
975
|
+
combined.addPath(arrows)
|
|
976
|
+
self.setPath(combined)
|
|
977
|
+
|
|
978
|
+
self._place_label(path)
|
|
979
|
+
self.update()
|
|
980
|
+
|
|
981
|
+
def _repair_after_node_move(self) -> None:
|
|
982
|
+
"""While nodes are dragged: CPN IDE's rule for the bends.
|
|
983
|
+
|
|
984
|
+
Moving both ends moves the whole arc. Moving one end leaves the
|
|
985
|
+
bends where they are, except that the bend next to the moved node
|
|
986
|
+
stays level with (or in line with) the arc's end if it was, and
|
|
987
|
+
bends the node now covers are dropped
|
|
988
|
+
(:func:`~openprocess.gui.arc_editing.repair_after_move`).
|
|
989
|
+
"""
|
|
990
|
+
if self.drag_origin is None:
|
|
991
|
+
return
|
|
992
|
+
t0, p0, bends, t_end, p_end = self.drag_origin
|
|
993
|
+
dt = self.transition_item.pos() - t0
|
|
994
|
+
dp = self.place_item.pos() - p0
|
|
995
|
+
t_moved, p_moved = dt.manhattanLength() > 1e-6, dp.manhattanLength() > 1e-6
|
|
996
|
+
if not bends or not (t_moved or p_moved):
|
|
997
|
+
return
|
|
998
|
+
if t_moved and p_moved:
|
|
999
|
+
self.arc.bendpoints = [to_model(QPointF(x, y) + dt) for x, y in bends]
|
|
1000
|
+
return
|
|
1001
|
+
from .arc_editing import repair_after_move
|
|
1002
|
+
t_rect, p_rect = _rect_of(self.transition_item), _rect_of(self.place_item)
|
|
1003
|
+
if t_moved:
|
|
1004
|
+
points = [t_end, *bends, p_end]
|
|
1005
|
+
moved_end = (t_end[0] + dt.x(), t_end[1] + dt.y())
|
|
1006
|
+
result = repair_after_move(points, moved_end, t_rect, p_rect)
|
|
1007
|
+
else:
|
|
1008
|
+
points = [p_end, *bends[::-1], t_end]
|
|
1009
|
+
moved_end = (p_end[0] + dp.x(), p_end[1] + dp.y())
|
|
1010
|
+
result = repair_after_move(points, moved_end, p_rect, t_rect)[::-1]
|
|
1011
|
+
self.arc.bendpoints = [to_model(QPointF(*p)) for p in result[1:-1]]
|
|
1012
|
+
|
|
1013
|
+
def start_node_drag(self) -> None:
|
|
1014
|
+
"""Remember the arc before its nodes are dragged (see above)."""
|
|
1015
|
+
ends = self._waypoints
|
|
1016
|
+
self.drag_origin = (self.transition_item.pos(), self.place_item.pos(),
|
|
1017
|
+
[(p.x(), p.y()) for p in (to_scene(x, y)
|
|
1018
|
+
for x, y in self.arc.bendpoints)],
|
|
1019
|
+
(ends[0].x(), ends[0].y()), (ends[-1].x(), ends[-1].y()))
|
|
1020
|
+
|
|
1021
|
+
def _inscription_dragged(self, centre: QPointF) -> None:
|
|
1022
|
+
self._label_offset = centre - self._node_midpoint()
|
|
1023
|
+
self.reroute()
|
|
1024
|
+
|
|
1025
|
+
# -- handles ---------------------------------------------------------------
|
|
1026
|
+
def handles_visible(self) -> bool:
|
|
1027
|
+
scene = self.scene()
|
|
1028
|
+
return (not self.editing and (self.isSelected() or self.hovered)
|
|
1029
|
+
and getattr(scene, "editable", False)
|
|
1030
|
+
and getattr(scene, "tool", "") == "select")
|
|
1031
|
+
|
|
1032
|
+
def hoverEnterEvent(self, event) -> None: # noqa: N802
|
|
1033
|
+
self.hovered = True
|
|
1034
|
+
self.hoverMoveEvent(event)
|
|
1035
|
+
|
|
1036
|
+
def hoverMoveEvent(self, event) -> None: # noqa: N802
|
|
1037
|
+
from .arc_editing import closest_on_segment, distance
|
|
1038
|
+
p = (event.scenePos().x(), event.scenePos().y())
|
|
1039
|
+
points = [(w.x(), w.y()) for w in self._waypoints]
|
|
1040
|
+
feet = [closest_on_segment(p, a, b) for a, b in zip(points, points[1:])]
|
|
1041
|
+
foot = min(feet, key=lambda f: distance(f, p)) if feet else None
|
|
1042
|
+
self.floating = QPointF(*foot) if foot else None
|
|
1043
|
+
self.update()
|
|
1044
|
+
|
|
1045
|
+
def hoverLeaveEvent(self, event) -> None: # noqa: N802
|
|
1046
|
+
self.hovered = False
|
|
1047
|
+
self.floating = None
|
|
1048
|
+
self.update()
|
|
1049
|
+
|
|
1050
|
+
def itemChange(self, change, value): # noqa: N802 - Qt naming
|
|
1051
|
+
if change == QGraphicsItem.ItemSelectedHasChanged:
|
|
1052
|
+
self.update()
|
|
1053
|
+
return super().itemChange(change, value)
|
|
1054
|
+
|
|
1055
|
+
@staticmethod
|
|
1056
|
+
def _tangent_source(path: QPainterPath, percent: float) -> QPointF:
|
|
1057
|
+
"""A point just short of an end, giving the direction to point the head."""
|
|
1058
|
+
return path.pointAtPercent(max(0.0, min(1.0, percent)))
|
|
1059
|
+
|
|
1060
|
+
def _place_label(self, path: QPainterPath) -> None:
|
|
1061
|
+
"""Put the inscription a third of the way along, clear of the line."""
|
|
1062
|
+
if self.plain:
|
|
1063
|
+
# Plain nets: only a weight above 1 is written, as a number.
|
|
1064
|
+
from ..model.plain import weight_of
|
|
1065
|
+
weight = weight_of(self.arc)
|
|
1066
|
+
self.inscription_label.setText(str(weight) if weight > 1 else "")
|
|
1067
|
+
if weight <= 1:
|
|
1068
|
+
return
|
|
1069
|
+
if self._label_offset is None:
|
|
1070
|
+
point = path.pointAtPercent(0.5)
|
|
1071
|
+
angle = math.radians(path.angleAtPercent(0.5))
|
|
1072
|
+
normal = QPointF(math.sin(angle), math.cos(angle)) * 9.0
|
|
1073
|
+
bounds = self.inscription_label.boundingRect()
|
|
1074
|
+
if normal.y() > 0:
|
|
1075
|
+
normal = -normal
|
|
1076
|
+
self.inscription_label.setPos(point.x() + normal.x() - bounds.width() / 2,
|
|
1077
|
+
point.y() + normal.y() - bounds.height() / 2)
|
|
1078
|
+
return
|
|
1079
|
+
centre = self._node_midpoint() + self._label_offset
|
|
1080
|
+
bounds = self.inscription_label.boundingRect()
|
|
1081
|
+
self.inscription_label.setPos(centre.x() - bounds.width() / 2,
|
|
1082
|
+
centre.y() - bounds.height() / 2)
|
|
1083
|
+
self.arc.graphics.label_offsets["annot"] = to_model(centre)
|
|
1084
|
+
return
|
|
1085
|
+
_set_elided(self.inscription_label, self.arc.expression_text,
|
|
1086
|
+
MAX_AUTHORED_WIDTH if self._label_offset is not None
|
|
1087
|
+
else MAX_ANNOTATION_WIDTH)
|
|
1088
|
+
if not self.arc.expression_text:
|
|
1089
|
+
return
|
|
1090
|
+
if self._label_offset is not None:
|
|
1091
|
+
centre = self._node_midpoint() + self._label_offset
|
|
1092
|
+
bounds = self.inscription_label.boundingRect()
|
|
1093
|
+
self.inscription_label.setPos(centre.x() - bounds.width() / 2,
|
|
1094
|
+
centre.y() - bounds.height() / 2)
|
|
1095
|
+
# Keep the model in step, so saving writes where it is drawn.
|
|
1096
|
+
self.arc.graphics.label_offsets["annot"] = to_model(centre)
|
|
1097
|
+
return
|
|
1098
|
+
|
|
1099
|
+
anchor = path.pointAtPercent(self.LABEL_POSITION)
|
|
1100
|
+
angle = math.radians(path.angleAtPercent(self.LABEL_POSITION))
|
|
1101
|
+
# Qt reports the angle counter-clockwise from the x axis in a
|
|
1102
|
+
# y-down scene, hence the negated sine.
|
|
1103
|
+
tangent = QPointF(math.cos(angle), -math.sin(angle))
|
|
1104
|
+
normal = QPointF(-tangent.y(), tangent.x())
|
|
1105
|
+
|
|
1106
|
+
bounds = self.inscription_label.boundingRect()
|
|
1107
|
+
if abs(self.bow) > 0.01 and not self.arc.bendpoints:
|
|
1108
|
+
# One of a pair of parallel arcs: write on its own side, so the
|
|
1109
|
+
# two inscriptions of a pair end up on opposite sides.
|
|
1110
|
+
chord = self.transition_item.pos() - self.place_item.pos()
|
|
1111
|
+
length = math.hypot(chord.x(), chord.y()) or 1.0
|
|
1112
|
+
side = QPointF(-chord.y() / length, chord.x() / length) * math.copysign(1, self.bow)
|
|
1113
|
+
# Far enough that the text box clears the line, whatever the angle.
|
|
1114
|
+
reach = 6.0 + abs(side.x()) * bounds.width() / 2 + abs(side.y()) * bounds.height() / 2
|
|
1115
|
+
offset = side * reach
|
|
1116
|
+
else:
|
|
1117
|
+
# Offset to whichever side of the line points "up" on screen, so
|
|
1118
|
+
# the text never sits on top of the arc.
|
|
1119
|
+
offset = normal * (12.0 if normal.y() < 0 else -12.0)
|
|
1120
|
+
self.inscription_label.setPos(
|
|
1121
|
+
anchor.x() + offset.x() - bounds.width() / 2,
|
|
1122
|
+
anchor.y() + offset.y() - bounds.height() / 2,
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
def _anchor(self, item: _NodeItem, towards: QPointF) -> QPointF:
|
|
1126
|
+
"""Where inside ``item`` the arc starts: its centre, moved level with
|
|
1127
|
+
(or in line with) the neighbouring bendpoint when that is close, as
|
|
1128
|
+
long as the point stays inside the node."""
|
|
1129
|
+
centre = item.pos()
|
|
1130
|
+
rect = item.rect()
|
|
1131
|
+
x, y = centre.x(), centre.y()
|
|
1132
|
+
if abs(towards.y() - y) < self.SNAP and abs(towards.y() - y) < rect.height() / 2 - 3:
|
|
1133
|
+
y = towards.y()
|
|
1134
|
+
if abs(towards.x() - x) < self.SNAP and abs(towards.x() - x) < rect.width() / 2 - 3:
|
|
1135
|
+
x = towards.x()
|
|
1136
|
+
return QPointF(x, y)
|
|
1137
|
+
|
|
1138
|
+
@staticmethod
|
|
1139
|
+
def _inside(item: _NodeItem, point: QPointF) -> bool:
|
|
1140
|
+
local = point - item.pos()
|
|
1141
|
+
rect = item.rect()
|
|
1142
|
+
a, b = rect.width() / 2, rect.height() / 2
|
|
1143
|
+
if isinstance(item, PlaceItem):
|
|
1144
|
+
return (local.x() / a) ** 2 + (local.y() / b) ** 2 <= 1.0
|
|
1145
|
+
return abs(local.x()) <= a and abs(local.y()) <= b
|
|
1146
|
+
|
|
1147
|
+
def _crop(self, item: _NodeItem, inside: QPointF, outside: QPointF) -> QPointF:
|
|
1148
|
+
"""The point where the segment ``inside`` -> ``outside`` leaves the node,
|
|
1149
|
+
plus two pixels of air so the arrowhead does not touch the outline."""
|
|
1150
|
+
if not self._inside(item, inside):
|
|
1151
|
+
inside = item.pos()
|
|
1152
|
+
if self._inside(item, outside):
|
|
1153
|
+
return outside # overlapping nodes: nothing to crop
|
|
1154
|
+
low, high = 0.0, 1.0
|
|
1155
|
+
for _ in range(24): # bisection: exact to well under a pixel
|
|
1156
|
+
middle = (low + high) / 2
|
|
1157
|
+
if self._inside(item, inside + (outside - inside) * middle):
|
|
1158
|
+
low = middle
|
|
1159
|
+
else:
|
|
1160
|
+
high = middle
|
|
1161
|
+
direction = outside - inside
|
|
1162
|
+
length = math.hypot(direction.x(), direction.y()) or 1.0
|
|
1163
|
+
return inside + direction * high + direction * (2.0 / length)
|
|
1164
|
+
|
|
1165
|
+
def _append_arrowhead(self, path: QPainterPath, tail: QPointF, tip: QPointF) -> None:
|
|
1166
|
+
"""Add a filled triangle at ``tip``, pointing away from ``tail``."""
|
|
1167
|
+
direction = tip - tail
|
|
1168
|
+
length = math.hypot(direction.x(), direction.y())
|
|
1169
|
+
if length < 1e-6:
|
|
1170
|
+
return
|
|
1171
|
+
unit = QPointF(direction.x() / length, direction.y() / length)
|
|
1172
|
+
normal = QPointF(-unit.y(), unit.x())
|
|
1173
|
+
|
|
1174
|
+
base = tip - unit * self.ARROW_LENGTH
|
|
1175
|
+
path.addPolygon(
|
|
1176
|
+
QPolygonF([
|
|
1177
|
+
tip,
|
|
1178
|
+
base + normal * self.ARROW_HALF_WIDTH,
|
|
1179
|
+
base - normal * self.ARROW_HALF_WIDTH,
|
|
1180
|
+
tip,
|
|
1181
|
+
])
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
def shape(self) -> QPainterPath:
|
|
1185
|
+
"""Widen the clickable region (a 1.5 px line is very hard to hit), but
|
|
1186
|
+
only around the line and the arrowheads, not the area a bent arc
|
|
1187
|
+
encloses. With its handles showing, the circles around the
|
|
1188
|
+
waypoints count too, so the ends can be grabbed."""
|
|
1189
|
+
stroker = QPainterPathStroker()
|
|
1190
|
+
stroker.setWidth(14.0) # diagram-js: a 15 px hit line
|
|
1191
|
+
shape = stroker.createStroke(self.line_path).united(self.arrow_path)
|
|
1192
|
+
if self.handles_visible():
|
|
1193
|
+
for point in self._waypoints:
|
|
1194
|
+
shape.addEllipse(point, 10.0, 10.0)
|
|
1195
|
+
return shape
|
|
1196
|
+
|
|
1197
|
+
def boundingRect(self) -> QRectF: # noqa: N802
|
|
1198
|
+
# Room for the handles, which are drawn a fixed size on screen.
|
|
1199
|
+
return super().boundingRect().adjusted(-24, -24, 24, 24)
|
|
1200
|
+
|
|
1201
|
+
def _paint_handles(self, painter: QPainter) -> None:
|
|
1202
|
+
"""CPN IDE's handles: a circle on every waypoint, one following the
|
|
1203
|
+
mouse along the line, a bar across each straight segment's middle."""
|
|
1204
|
+
from .arc_editing import aligned
|
|
1205
|
+
scale = painter.worldTransform().m11() or 1.0
|
|
1206
|
+
radius = self.HANDLE_RADIUS / scale
|
|
1207
|
+
accent = theme.palette().accent
|
|
1208
|
+
fill = QColor(accent)
|
|
1209
|
+
fill.setAlphaF(0.25)
|
|
1210
|
+
outline = QPen(accent, 1.2 / scale)
|
|
1211
|
+
points = self._waypoints
|
|
1212
|
+
painter.setPen(outline)
|
|
1213
|
+
painter.setBrush(fill)
|
|
1214
|
+
for a, b in zip(points, points[1:]):
|
|
1215
|
+
direction = aligned((a.x(), a.y()), (b.x(), b.y()))
|
|
1216
|
+
if direction is None:
|
|
1217
|
+
continue
|
|
1218
|
+
middle = (a + b) / 2
|
|
1219
|
+
long_side, short_side = 22.0 / scale, 6.0 / scale
|
|
1220
|
+
# A bar across the segment, pointing the way it can be slid.
|
|
1221
|
+
if direction == "vertical":
|
|
1222
|
+
width, height = long_side, short_side
|
|
1223
|
+
else:
|
|
1224
|
+
width, height = short_side, long_side
|
|
1225
|
+
painter.drawRoundedRect(QRectF(middle.x() - width / 2, middle.y() - height / 2,
|
|
1226
|
+
width, height), 2 / scale, 2 / scale)
|
|
1227
|
+
painter.setBrush(QColor(theme.palette().canvas))
|
|
1228
|
+
for point in points:
|
|
1229
|
+
painter.drawEllipse(point, radius, radius)
|
|
1230
|
+
if self.hovered and self.floating is not None:
|
|
1231
|
+
painter.setBrush(fill)
|
|
1232
|
+
painter.drawEllipse(self.floating, radius, radius)
|
|
1233
|
+
|
|
1234
|
+
def set_trace(self, strength: float) -> None:
|
|
1235
|
+
"""Underlay the arc in the accent colour: tokens travelled along it
|
|
1236
|
+
in the trace, ``strength`` 1 for the latest step fading to 0."""
|
|
1237
|
+
if abs(strength - self.trace) > 1e-9:
|
|
1238
|
+
self.trace = strength
|
|
1239
|
+
self.update()
|
|
1240
|
+
|
|
1241
|
+
def paint(self, painter: QPainter, option, widget=None) -> None: # noqa: N802
|
|
1242
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
1243
|
+
trace = self.trace
|
|
1244
|
+
if trace > 0:
|
|
1245
|
+
glow = QColor(theme.palette().accent)
|
|
1246
|
+
glow.setAlphaF(0.15 + 0.45 * trace)
|
|
1247
|
+
painter.setPen(QPen(glow, 3.0 + 4.0 * trace, Qt.SolidLine, Qt.RoundCap,
|
|
1248
|
+
Qt.RoundJoin))
|
|
1249
|
+
painter.setBrush(Qt.NoBrush)
|
|
1250
|
+
painter.drawPath(self.line_path)
|
|
1251
|
+
selected = bool(option.state & QStyle.State_Selected)
|
|
1252
|
+
if selected:
|
|
1253
|
+
colour = theme.palette().accent
|
|
1254
|
+
pen = QPen(colour, 2.4, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
|
|
1255
|
+
else:
|
|
1256
|
+
pen = QPen(self.pen())
|
|
1257
|
+
colour = pen.color()
|
|
1258
|
+
painter.setPen(pen)
|
|
1259
|
+
painter.setBrush(Qt.NoBrush) # never fill the line itself
|
|
1260
|
+
painter.drawPath(self.line_path)
|
|
1261
|
+
painter.setPen(Qt.NoPen)
|
|
1262
|
+
painter.setBrush(QBrush(colour))
|
|
1263
|
+
painter.drawPath(self.arrow_path)
|
|
1264
|
+
if self.handles_visible():
|
|
1265
|
+
self._paint_handles(painter)
|
|
1266
|
+
|
|
1267
|
+
def set_error(self, has_error: bool) -> None:
|
|
1268
|
+
"""Draw the arc in red when its inscription failed to compile."""
|
|
1269
|
+
palette = theme.palette()
|
|
1270
|
+
colour = palette.danger if has_error else palette.border_strong
|
|
1271
|
+
self.setPen(QPen(colour, 1.5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
|
|
1272
|
+
self.setBrush(QBrush(colour))
|
|
1273
|
+
self.inscription_label.setBrush(
|
|
1274
|
+
QBrush(palette.danger if has_error else palette.inscription)
|
|
1275
|
+
)
|