openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,854 @@
1
+ """The workflow canvas: boxes, wires, and dragging a wire to connect.
2
+
3
+ A :class:`BoxItem` draws one node of the workflow the way the sketch does:
4
+ the group as a small eyebrow, the name, a line about its result, a status
5
+ dot, and its connection points as coloured dots (inputs on the left,
6
+ outputs on the right). A :class:`WireItem` is a curve between an output
7
+ and an input. The :class:`WorkflowScene` keeps the items in step with a
8
+ :class:`~openprocess.flow.workflow.Workflow` and handles the gestures:
9
+
10
+ * drag a box to move it (snapped to the grid, wires follow);
11
+ * drag from an output dot to connect it: while dragging, only the inputs
12
+ it may go to light up, the others fade, and letting go near one makes
13
+ the edge (the workflow refuses the wrong type, a loop, a duplicate);
14
+ * click a wire to select it, Delete removes it;
15
+ * double-click the background to add a box there (the page shows a list);
16
+ * a :class:`GroupItem` shows several boxes as one, with the connection
17
+ points that reach outside the group; double-click it to open its own
18
+ canvas (the members, with stubs for the wires that come from outside).
19
+
20
+ The scene only edits the workflow and says what changed (``edited``); the
21
+ page runs the boxes and shows the results.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+
28
+ from PySide6.QtCore import QPointF, QRectF, Qt, Signal
29
+ from PySide6.QtGui import QColor, QFont, QFontMetricsF, QPainter, QPainterPath, QPen
30
+ from PySide6.QtWidgets import QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QGraphicsScene
31
+
32
+ from ...flow.box import Port
33
+ from ...flow.runner import BLOCKED, DONE, FAILED, IDLE, RUNNING, WAITING
34
+ from ...flow.types import type_info
35
+ from ...flow.workflow import Edge, Group, Node, Workflow
36
+ from .. import theme
37
+ from ..studio import style
38
+ from ..studio.graph_view import GraphView
39
+
40
+ NODE_W = 178.0
41
+ GRID = 10.0
42
+ PORT_R = 6.0
43
+ STATUS_COLOUR = {DONE: "good", RUNNING: "accent", FAILED: "critical", BLOCKED: "warning",
44
+ WAITING: "muted", IDLE: "muted"}
45
+
46
+
47
+ def port_colour(port: Port) -> str:
48
+ info = type_info(port.type) if port.type is not None else None
49
+ return info.colour if info else style.tokens().text_muted
50
+
51
+
52
+ def _font(size: float, bold: bool = False) -> QFont:
53
+ font = theme.ui_font(size)
54
+ font.setBold(bold)
55
+ return font
56
+
57
+
58
+ def node_height(inputs: int, outputs: int) -> float:
59
+ rows = max(inputs, outputs)
60
+ return 72 + 20 * (rows - 1) + 14 if rows > 1 else 66
61
+
62
+
63
+ class BoxItem(QGraphicsObject):
64
+ """One box on the canvas."""
65
+
66
+ def __init__(self, scene: "WorkflowScene", node: Node) -> None:
67
+ super().__init__()
68
+ self.flow_scene = scene
69
+ self.node = node
70
+ self.spec = scene.workflow.spec(node)
71
+ self.status = WAITING
72
+ self.subtitle = ""
73
+ self.highlight: set[str] = set() # inputs that fit the wire being dragged
74
+ self.dimmed = False
75
+ self.locked = False
76
+ self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable
77
+ | QGraphicsItem.ItemSendsGeometryChanges)
78
+ self.setAcceptHoverEvents(True)
79
+ self.setZValue(2)
80
+ self.setPos(*node.position)
81
+ self._moving = False
82
+
83
+ # -- geometry ------------------------------------------------------------------
84
+ @property
85
+ def height(self) -> float:
86
+ return node_height(len(self.spec.inputs), len(self.spec.outputs))
87
+
88
+ def boundingRect(self) -> QRectF: # noqa: N802
89
+ return QRectF(-PORT_R - 2, -14, NODE_W + 2 * PORT_R + 4, self.height + 18)
90
+
91
+ def rect(self) -> QRectF:
92
+ return QRectF(0, 0, NODE_W, self.height)
93
+
94
+ def port_point(self, side: str, name: str) -> QPointF:
95
+ """Where a port's dot is, in item coordinates."""
96
+ ports = self.spec.inputs if side == "in" else self.spec.outputs
97
+ index = max(0, next((i for i, p in enumerate(ports) if p.name == name), 0))
98
+ y = 72 + 20 * index if len(ports) > 1 else self.height / 2
99
+ return QPointF(0 if side == "in" else NODE_W, y)
100
+
101
+ def scene_port(self, side: str, name: str) -> QPointF:
102
+ return self.mapToScene(self.port_point(side, name))
103
+
104
+ def drop_inputs(self) -> list[tuple[str, str, Port]]:
105
+ """``(id, node id, Port)`` for every input a wire may be dropped on."""
106
+ return [(p.name, self.node.id, p) for p in self.spec.inputs]
107
+
108
+ def port_at(self, point: QPointF) -> tuple[str, Port] | None:
109
+ """The (side, port) whose dot is under ``point`` (item coordinates)."""
110
+ for side, ports in (("out", self.spec.outputs), ("in", self.spec.inputs)):
111
+ for port in ports:
112
+ centre = self.port_point(side, port.name)
113
+ if math.hypot(point.x() - centre.x(), point.y() - centre.y()) <= PORT_R + 4:
114
+ return side, port
115
+ return None
116
+
117
+ # -- state ---------------------------------------------------------------------------
118
+ def set_status(self, status: str, subtitle: str, tooltip: str = "") -> None:
119
+ self.status, self.subtitle = status, subtitle
120
+ self.setToolTip(tooltip)
121
+ self.update()
122
+
123
+ def set_highlight(self, inputs: set[str], dimmed: bool) -> None:
124
+ self.highlight, self.dimmed = inputs, dimmed
125
+ self.update()
126
+
127
+ # -- Qt --------------------------------------------------------------------------------
128
+ def itemChange(self, change, value): # noqa: N802
129
+ if change == QGraphicsItem.ItemPositionChange and not self.locked:
130
+ snapped = QPointF(round(value.x() / GRID) * GRID, round(value.y() / GRID) * GRID)
131
+ return snapped
132
+ if change == QGraphicsItem.ItemPositionHasChanged:
133
+ self.node.position = (self.pos().x(), self.pos().y())
134
+ self.flow_scene.wires_of(self.node.id, rebuild=True)
135
+ self._moving = True
136
+ return super().itemChange(change, value)
137
+
138
+ def mousePressEvent(self, event) -> None: # noqa: N802
139
+ hit = self.port_at(event.pos())
140
+ if hit is not None and hit[0] == "out" and event.button() == Qt.LeftButton:
141
+ self.flow_scene.begin_wire(self.node.id, hit[1], self.scene_port("out", hit[1].name), event.scenePos())
142
+ event.accept()
143
+ return
144
+ self._moving = False
145
+ super().mousePressEvent(event)
146
+
147
+ def mouseReleaseEvent(self, event) -> None: # noqa: N802
148
+ super().mouseReleaseEvent(event)
149
+ if self._moving:
150
+ self._moving = False
151
+ self.flow_scene.moved.emit(self.node.id)
152
+
153
+ def hoverMoveEvent(self, event) -> None: # noqa: N802
154
+ hit = self.port_at(event.pos())
155
+ self.setCursor(Qt.CrossCursor if hit and hit[0] == "out" else Qt.SizeAllCursor
156
+ if not self.locked else Qt.ArrowCursor)
157
+
158
+ def hoverLeaveEvent(self, event) -> None: # noqa: N802
159
+ self.unsetCursor()
160
+
161
+ def paint(self, painter: QPainter, option, widget=None) -> None:
162
+ t = style.tokens()
163
+ painter.setRenderHint(QPainter.Antialiasing)
164
+ rect = self.rect()
165
+ failed = self.status == FAILED
166
+ if self.dimmed:
167
+ painter.setOpacity(0.32)
168
+ fill = QColor(style.qc(style.STATUS["critical"], 0.10)) if failed else QColor(t.surface)
169
+ stroke = QColor(t.selection) if self.isSelected() else \
170
+ QColor(style.STATUS["critical"]) if failed else QColor(t.border)
171
+ pen = QPen(stroke, 2.0 if self.isSelected() else 1.5 if failed else 1.0)
172
+ if self.locked:
173
+ pen.setStyle(Qt.DashLine)
174
+ painter.setPen(pen)
175
+ painter.setBrush(fill)
176
+ painter.drawRoundedRect(rect, 9, 9)
177
+ # Eyebrow: the group (and "Yours" for a custom box).
178
+ painter.setPen(QColor(t.text_muted))
179
+ painter.setFont(_font(8.5, True))
180
+ eyebrow = ("Yours · " if self.spec.custom else "") + self.spec.group.upper()
181
+ painter.drawText(QPointF(12, 16), eyebrow)
182
+ # Status dot.
183
+ colour = STATUS_COLOUR.get(self.status, "muted")
184
+ dot = QColor({"good": style.STATUS["good"], "accent": t.accent, "critical": style.STATUS["critical"],
185
+ "warning": style.STATUS["warning"], "muted": t.text_muted}[colour])
186
+ painter.setPen(QPen(dot, 1.5))
187
+ painter.setBrush(dot if self.status not in (WAITING, IDLE) else Qt.NoBrush)
188
+ painter.drawEllipse(QPointF(NODE_W - 14, 13), 4.5, 4.5)
189
+ # Title and subtitle.
190
+ painter.setPen(QColor(t.text))
191
+ painter.setFont(_font(12.5, True))
192
+ metrics = QFontMetricsF(painter.font())
193
+ title = self.node.title or self.spec.name
194
+ painter.drawText(QPointF(12, 34), metrics.elidedText(title, Qt.ElideRight, NODE_W - 36))
195
+ painter.setPen(QColor(style.STATUS["critical"] if failed else t.text_muted))
196
+ painter.setFont(_font(10.5))
197
+ metrics = QFontMetricsF(painter.font())
198
+ painter.drawText(QPointF(12, 52), metrics.elidedText(self.subtitle, Qt.ElideRight, NODE_W - 24))
199
+ # Ports.
200
+ for side, ports in (("in", self.spec.inputs), ("out", self.spec.outputs)):
201
+ for port in ports:
202
+ centre = self.port_point(side, port.name)
203
+ lit = side == "in" and port.name in self.highlight
204
+ painter.setPen(QPen(QColor(t.selection if lit else t.surface), 3 if lit else 2))
205
+ painter.setBrush(QColor(port_colour(port)))
206
+ painter.drawEllipse(centre, PORT_R + (2 if lit else 0), PORT_R + (2 if lit else 0))
207
+ if len(ports) > 1:
208
+ painter.setPen(QColor(t.text_secondary))
209
+ painter.setFont(_font(9.5))
210
+ text = port.label or port.name
211
+ if side == "in":
212
+ painter.drawText(QPointF(centre.x() + 10, centre.y() + 3.5), text)
213
+ else:
214
+ width = QFontMetricsF(painter.font()).horizontalAdvance(text)
215
+ painter.drawText(QPointF(centre.x() - 10 - width, centre.y() + 3.5), text)
216
+ painter.setOpacity(1.0)
217
+
218
+
219
+ class GroupItem(QGraphicsObject):
220
+ """Several boxes shown as one: a stacked box with the derived connection points."""
221
+
222
+ def __init__(self, scene: "WorkflowScene", group: Group) -> None:
223
+ super().__init__()
224
+ self.flow_scene = scene
225
+ self.group = group
226
+ self.inputs, self.outputs = scene.workflow.group_ports(group)
227
+ self.status = WAITING
228
+ self.subtitle = ""
229
+ self.highlight: set[str] = set()
230
+ self.dimmed = False
231
+ self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable
232
+ | QGraphicsItem.ItemSendsGeometryChanges)
233
+ self.setAcceptHoverEvents(True)
234
+ self.setZValue(2)
235
+ self._last = QPointF(*group.position)
236
+ self.setPos(*group.position)
237
+ self._moving = False
238
+
239
+ @property
240
+ def height(self) -> float:
241
+ return node_height(len(self.inputs), len(self.outputs))
242
+
243
+ def boundingRect(self) -> QRectF: # noqa: N802
244
+ return QRectF(-PORT_R - 2, -14, NODE_W + 2 * PORT_R + 12, self.height + 26)
245
+
246
+ def rect(self) -> QRectF:
247
+ return QRectF(0, 0, NODE_W, self.height)
248
+
249
+ @staticmethod
250
+ def port_id(node_id: str, port_name: str) -> str:
251
+ return f"{node_id}:{port_name}"
252
+
253
+ def port_point(self, side: str, port_id: str) -> QPointF:
254
+ ports = self.inputs if side == "in" else self.outputs
255
+ ids = [self.port_id(n, p) for n, p, _ in ports]
256
+ index = max(0, ids.index(port_id) if port_id in ids else 0)
257
+ y = 72 + 20 * index if len(ports) > 1 else self.height / 2
258
+ return QPointF(0 if side == "in" else NODE_W, y)
259
+
260
+ def scene_port(self, side: str, port_id: str) -> QPointF:
261
+ return self.mapToScene(self.port_point(side, port_id))
262
+
263
+ def drop_inputs(self) -> list[tuple[str, str, Port]]:
264
+ return [(self.port_id(n, p), n, port) for n, p, port in self.inputs]
265
+
266
+ def port_at(self, point: QPointF):
267
+ for side, ports in (("out", self.outputs), ("in", self.inputs)):
268
+ for node_id, name, port in ports:
269
+ centre = self.port_point(side, self.port_id(node_id, name))
270
+ if math.hypot(point.x() - centre.x(), point.y() - centre.y()) <= PORT_R + 4:
271
+ return side, node_id, port
272
+ return None
273
+
274
+ def set_status(self, status: str, subtitle: str, tooltip: str = "") -> None:
275
+ self.status, self.subtitle = status, subtitle
276
+ self.setToolTip(tooltip)
277
+ self.update()
278
+
279
+ def set_highlight(self, inputs: set[str], dimmed: bool) -> None:
280
+ self.highlight, self.dimmed = inputs, dimmed
281
+ self.update()
282
+
283
+ def itemChange(self, change, value): # noqa: N802
284
+ if change == QGraphicsItem.ItemPositionChange:
285
+ return QPointF(round(value.x() / GRID) * GRID, round(value.y() / GRID) * GRID)
286
+ if change == QGraphicsItem.ItemPositionHasChanged:
287
+ delta = self.pos() - self._last
288
+ self._last = QPointF(self.pos())
289
+ self.group.position = (self.pos().x(), self.pos().y())
290
+ for member in self.group.members:
291
+ node = self.flow_scene.workflow.nodes.get(member)
292
+ if node is not None:
293
+ node.position = (node.position[0] + delta.x(), node.position[1] + delta.y())
294
+ self.flow_scene.wires_of(self.group.id, rebuild=True)
295
+ self._moving = True
296
+ return super().itemChange(change, value)
297
+
298
+ def mousePressEvent(self, event) -> None: # noqa: N802
299
+ hit = self.port_at(event.pos())
300
+ if hit is not None and hit[0] == "out" and event.button() == Qt.LeftButton:
301
+ _side, node_id, port = hit
302
+ self.flow_scene.begin_wire(node_id, port, self.scene_port("out", self.port_id(node_id, port.name)),
303
+ event.scenePos())
304
+ event.accept()
305
+ return
306
+ self._moving = False
307
+ super().mousePressEvent(event)
308
+
309
+ def mouseReleaseEvent(self, event) -> None: # noqa: N802
310
+ super().mouseReleaseEvent(event)
311
+ if self._moving:
312
+ self._moving = False
313
+ self.flow_scene.moved.emit(self.group.id)
314
+
315
+ def mouseDoubleClickEvent(self, event) -> None: # noqa: N802
316
+ self.flow_scene.open_group.emit(self.group.id)
317
+ event.accept()
318
+
319
+ def hoverMoveEvent(self, event) -> None: # noqa: N802
320
+ hit = self.port_at(event.pos())
321
+ self.setCursor(Qt.CrossCursor if hit and hit[0] == "out" else Qt.SizeAllCursor)
322
+
323
+ def hoverLeaveEvent(self, event) -> None: # noqa: N802
324
+ self.unsetCursor()
325
+
326
+ def paint(self, painter: QPainter, option, widget=None) -> None:
327
+ t = style.tokens()
328
+ painter.setRenderHint(QPainter.Antialiasing)
329
+ rect = self.rect()
330
+ if self.dimmed:
331
+ painter.setOpacity(0.32)
332
+ painter.setPen(QPen(QColor(t.border), 1.0))
333
+ painter.setBrush(QColor(t.surface_alt))
334
+ painter.drawRoundedRect(rect.translated(6, 6), 9, 9) # the stack behind
335
+ stroke = QColor(t.selection) if self.isSelected() else QColor(t.text_secondary)
336
+ painter.setPen(QPen(stroke, 2.0 if self.isSelected() else 1.2))
337
+ painter.setBrush(QColor(t.surface))
338
+ painter.drawRoundedRect(rect, 9, 9)
339
+ painter.setPen(QColor(t.text_muted))
340
+ painter.setFont(_font(8.5, True))
341
+ painter.drawText(QPointF(12, 16), f"WORKFLOW · {len(self.group.members)} BOXES")
342
+ colour = STATUS_COLOUR.get(self.status, "muted")
343
+ dot = QColor({"good": style.STATUS["good"], "accent": t.accent, "critical": style.STATUS["critical"],
344
+ "warning": style.STATUS["warning"], "muted": t.text_muted}[colour])
345
+ painter.setPen(QPen(dot, 1.5))
346
+ painter.setBrush(dot if self.status not in (WAITING, IDLE) else Qt.NoBrush)
347
+ painter.drawEllipse(QPointF(NODE_W - 14, 13), 4.5, 4.5)
348
+ painter.setPen(QColor(t.text))
349
+ painter.setFont(_font(12.5, True))
350
+ metrics = QFontMetricsF(painter.font())
351
+ painter.drawText(QPointF(12, 34), metrics.elidedText(self.group.name, Qt.ElideRight, NODE_W - 36))
352
+ painter.setPen(QColor(t.text_muted))
353
+ painter.setFont(_font(10.5))
354
+ metrics = QFontMetricsF(painter.font())
355
+ painter.drawText(QPointF(12, 52), metrics.elidedText(self.subtitle or "Double-click to open",
356
+ Qt.ElideRight, NODE_W - 24))
357
+ for side, ports in (("in", self.inputs), ("out", self.outputs)):
358
+ for node_id, name, port in ports:
359
+ port_id = self.port_id(node_id, name)
360
+ centre = self.port_point(side, port_id)
361
+ lit = side == "in" and port_id in self.highlight
362
+ painter.setPen(QPen(QColor(t.selection if lit else t.surface), 3 if lit else 2))
363
+ painter.setBrush(QColor(port_colour(port)))
364
+ painter.drawEllipse(centre, PORT_R + (2 if lit else 0), PORT_R + (2 if lit else 0))
365
+ if len(ports) > 1:
366
+ painter.setPen(QColor(t.text_secondary))
367
+ painter.setFont(_font(9.5))
368
+ text = (port.label or port.name) if side == "in" else port.type_name.lower()
369
+ if side == "in":
370
+ painter.drawText(QPointF(centre.x() + 10, centre.y() + 3.5), text)
371
+ else:
372
+ width = QFontMetricsF(painter.font()).horizontalAdvance(text)
373
+ painter.drawText(QPointF(centre.x() - 10 - width, centre.y() + 3.5), text)
374
+ painter.setOpacity(1.0)
375
+
376
+
377
+ class WireItem(QGraphicsPathItem):
378
+ """A curve from an output dot to an input dot."""
379
+
380
+ def __init__(self, scene: "WorkflowScene", edge: Edge | None, colour: str, faint: bool = False) -> None:
381
+ super().__init__()
382
+ self.flow_scene = scene
383
+ self.edge = edge
384
+ self.colour = colour
385
+ self.faint = faint
386
+ self.hovered = False
387
+ self.setZValue(1)
388
+ self.setAcceptHoverEvents(edge is not None)
389
+ if edge is not None:
390
+ self.setFlag(QGraphicsItem.ItemIsSelectable)
391
+ self.setToolTip("Click to select, Delete to remove this connection")
392
+ self.start = QPointF()
393
+ self.end = QPointF()
394
+ #: "from X" / "to Y" on a wire that crosses an open group's boundary.
395
+ self.stub: str | None = None
396
+
397
+ def set_points(self, start: QPointF, end: QPointF) -> None:
398
+ self.start, self.end = start, end
399
+ dx = max(40.0, abs(end.x() - start.x()) / 2)
400
+ path = QPainterPath(start)
401
+ path.cubicTo(QPointF(start.x() + dx, start.y()), QPointF(end.x() - dx, end.y()), end)
402
+ self.setPath(path)
403
+
404
+ def shape(self) -> QPainterPath:
405
+ from PySide6.QtGui import QPainterPathStroker
406
+ stroker = QPainterPathStroker()
407
+ stroker.setWidth(12)
408
+ return stroker.createStroke(self.path())
409
+
410
+ def hoverEnterEvent(self, event) -> None: # noqa: N802
411
+ self.hovered = True
412
+ self.update()
413
+
414
+ def hoverLeaveEvent(self, event) -> None: # noqa: N802
415
+ self.hovered = False
416
+ self.update()
417
+
418
+ def paint(self, painter: QPainter, option, widget=None) -> None:
419
+ painter.setRenderHint(QPainter.Antialiasing)
420
+ colour = QColor(style.tokens().selection) if self.isSelected() else QColor(self.colour)
421
+ if self.faint and not self.isSelected():
422
+ colour.setAlphaF(0.45)
423
+ pen = QPen(colour, 4.0 if self.hovered or self.isSelected() else 2.2)
424
+ if self.edge is None or self.stub:
425
+ pen.setStyle(Qt.DashLine)
426
+ painter.setPen(pen)
427
+ painter.setBrush(Qt.NoBrush)
428
+ painter.drawPath(self.path())
429
+ if self.stub:
430
+ painter.setPen(QColor(style.tokens().text_secondary))
431
+ painter.setFont(_font(9.5))
432
+ if self.stub.startswith("from"):
433
+ painter.drawText(QPointF(self.start.x(), self.start.y() - 6), self.stub)
434
+ else:
435
+ width = QFontMetricsF(painter.font()).horizontalAdvance(self.stub)
436
+ painter.drawText(QPointF(self.end.x() - width, self.end.y() - 6), self.stub)
437
+
438
+
439
+ class WorkflowScene(QGraphicsScene):
440
+ """The items of one workflow, kept in step with it."""
441
+
442
+ #: A node was selected (its id), or nothing (None).
443
+ selected = Signal(object)
444
+ #: The workflow changed: the ids of the nodes to run from.
445
+ edited = Signal(list)
446
+ #: A node was moved (position only: nothing to re-run).
447
+ moved = Signal(str)
448
+ #: Double-click on empty canvas, at this scene point.
449
+ add_here = Signal(QPointF)
450
+ #: A context menu was asked for: ("node", id) / ("group", id) / ("edge", edge) / ("canvas", point).
451
+ menu = Signal(str, object, object)
452
+ #: Something the user tried and the canvas refused, in plain words.
453
+ refused = Signal(str)
454
+ #: A group was double-clicked: show its own canvas.
455
+ open_group = Signal(str)
456
+
457
+ def __init__(self, workflow: Workflow) -> None:
458
+ super().__init__()
459
+ self.workflow = workflow
460
+ self.boxes: dict[str, BoxItem] = {}
461
+ self.groups: dict[str, GroupItem] = {}
462
+ self.wires: list[WireItem] = []
463
+ #: The group whose canvas is shown (None: the whole workflow).
464
+ self.view_group: str | None = None
465
+ #: The last status of every node, kept across rebuilds.
466
+ self.statuses: dict[str, tuple[str, str, str]] = {}
467
+ self._drag: dict | None = None
468
+ self.selectionChanged.connect(self._selection_changed)
469
+ self.rebuild()
470
+
471
+ # -- building ----------------------------------------------------------------------
472
+ def rebuild(self) -> None:
473
+ """Items from the workflow (positions and statuses are kept for known nodes).
474
+
475
+ At the top level, the nodes outside any group and one item per group;
476
+ inside a group (``view_group``), its members, with stubs for the wires
477
+ that cross its boundary."""
478
+ selected = [b.node.id for b in self.boxes.values() if b.isSelected()] + \
479
+ [g.group.id for g in self.groups.values() if g.isSelected()]
480
+ if self.view_group is not None and self.view_group not in self.workflow.groups:
481
+ self.view_group = None
482
+ self.blockSignals(True)
483
+ self.clear()
484
+ self.boxes, self.groups, self.wires = {}, {}, []
485
+ shown = self.workflow.groups[self.view_group].members if self.view_group else \
486
+ [n for n in self.workflow.nodes if self.workflow.group_of(n) is None]
487
+ for node_id in shown:
488
+ node = self.workflow.nodes.get(node_id)
489
+ if node is None:
490
+ continue
491
+ item = BoxItem(self, node)
492
+ if node.id in self.statuses:
493
+ item.set_status(*self.statuses[node.id])
494
+ self.addItem(item)
495
+ self.boxes[node.id] = item
496
+ if self.view_group is None:
497
+ for group in self.workflow.groups.values():
498
+ item = GroupItem(self, group)
499
+ self.addItem(item)
500
+ self.groups[group.id] = item
501
+ self._group_status(group.id)
502
+ for edge in self.workflow.edges:
503
+ self._add_wire(edge)
504
+ for key in selected:
505
+ if key in self.boxes:
506
+ self.boxes[key].setSelected(True)
507
+ elif key in self.groups:
508
+ self.groups[key].setSelected(True)
509
+ self.blockSignals(False)
510
+ self.setSceneRect(self.itemsBoundingRect().adjusted(-400, -300, 400, 300))
511
+
512
+ def owner(self, node_id: str):
513
+ """The visible item standing for a node: its box, or its group's item."""
514
+ if node_id in self.boxes:
515
+ return self.boxes[node_id]
516
+ group = self.workflow.group_of(node_id)
517
+ return self.groups.get(group.id) if group is not None else None
518
+
519
+ def _endpoint(self, item, side: str, node_id: str, port_name: str) -> QPointF:
520
+ if isinstance(item, GroupItem):
521
+ return item.scene_port(side, GroupItem.port_id(node_id, port_name))
522
+ return item.scene_port(side, port_name)
523
+
524
+ def _add_wire(self, edge: Edge) -> WireItem | None:
525
+ source, target = self.owner(edge.source), self.owner(edge.target)
526
+ if source is None and target is None:
527
+ return None
528
+ if source is target and isinstance(source, GroupItem):
529
+ return None # hidden inside the group
530
+ port = self.workflow.spec(edge.source).output(edge.output)
531
+ colour = port_colour(port) if port else style.tokens().text_muted
532
+ faint = edge.input in ("log", "ref", "true_net") and len(self.workflow.spec(edge.target).inputs) > 1
533
+ wire = WireItem(self, edge, colour, faint or source is None or target is None)
534
+ self._route(wire, source, target)
535
+ self.addItem(wire)
536
+ self.wires.append(wire)
537
+ return wire
538
+
539
+ def _route(self, wire: WireItem, source, target) -> None:
540
+ edge = wire.edge
541
+ if source is not None and target is not None:
542
+ wire.set_points(self._endpoint(source, "out", edge.source, edge.output),
543
+ self._endpoint(target, "in", edge.target, edge.input))
544
+ wire.stub = None
545
+ elif target is not None: # from outside the open group
546
+ end = self._endpoint(target, "in", edge.target, edge.input)
547
+ wire.set_points(QPointF(end.x() - 120, end.y()), end)
548
+ wire.stub = f"from {self.workflow.title(edge.source)}"
549
+ else: # to outside the open group
550
+ start = self._endpoint(source, "out", edge.source, edge.output)
551
+ wire.set_points(start, QPointF(start.x() + 120, start.y()))
552
+ wire.stub = f"to {self.workflow.title(edge.target)}"
553
+
554
+ def wires_of(self, key: str, rebuild: bool = False) -> list[WireItem]:
555
+ """The wires touching a node or a group (by id)."""
556
+ members = set(self.workflow.groups[key].members) if key in self.workflow.groups else {key}
557
+ found = [w for w in self.wires if w.edge is not None and (w.edge.source in members or w.edge.target in members)]
558
+ if rebuild:
559
+ for wire in found:
560
+ self._route(wire, self.owner(wire.edge.source), self.owner(wire.edge.target))
561
+ return found
562
+
563
+ def set_status(self, node_id: str, status: str, subtitle: str, tooltip: str = "") -> None:
564
+ self.statuses[node_id] = (status, subtitle, tooltip)
565
+ item = self.boxes.get(node_id)
566
+ if item is not None:
567
+ item.set_status(status, subtitle, tooltip)
568
+ group = self.workflow.group_of(node_id)
569
+ if group is not None and group.id in self.groups:
570
+ self._group_status(group.id)
571
+
572
+ def _group_status(self, group_id: str) -> None:
573
+ group, item = self.workflow.groups.get(group_id), self.groups.get(group_id)
574
+ if group is None or item is None:
575
+ return
576
+ statuses = [self.statuses.get(m, (WAITING, "", ""))[0] for m in group.members]
577
+ for status, text in ((FAILED, "A box inside failed"), (RUNNING, "Running…"),
578
+ (BLOCKED, "Waiting for your OK")):
579
+ if status in statuses:
580
+ item.set_status(status, text)
581
+ return
582
+ if statuses and all(s == DONE for s in statuses):
583
+ item.set_status(DONE, "Double-click to open")
584
+ else:
585
+ item.set_status(IDLE if IDLE in statuses else WAITING, "Waiting for an earlier box")
586
+
587
+ # -- groups ---------------------------------------------------------------------------
588
+ def group_selected(self, name: str = "") -> Group | None:
589
+ """Group the selected top-level boxes (two or more)."""
590
+ if self.view_group is not None:
591
+ self.refused.emit("Groups are made on the main canvas")
592
+ return None
593
+ ids = [b.node.id for b in self.boxes.values() if b.isSelected()]
594
+ if len(ids) < 2:
595
+ self.refused.emit("Select two or more boxes (drag around them, or shift-click), then group them")
596
+ return None
597
+ group = self.workflow.group(ids, name or f"Group of {len(ids)} boxes")
598
+ self.rebuild()
599
+ self.clearSelection()
600
+ self.groups[group.id].setSelected(True)
601
+ self.moved.emit(group.id)
602
+ return group
603
+
604
+ def ungroup(self, group_id: str) -> None:
605
+ if group_id not in self.workflow.groups:
606
+ return
607
+ members = list(self.workflow.groups[group_id].members)
608
+ self.workflow.ungroup(group_id)
609
+ if self.view_group == group_id:
610
+ self.view_group = None
611
+ self.rebuild()
612
+ self.clearSelection()
613
+ for member in members:
614
+ if member in self.boxes:
615
+ self.boxes[member].setSelected(True)
616
+ self.moved.emit(group_id)
617
+
618
+ def show_group(self, group_id: str | None) -> None:
619
+ self.view_group = group_id if group_id in self.workflow.groups else None
620
+ self.rebuild()
621
+ self.clearSelection()
622
+
623
+ def add_node(self, box, position: QPointF, settings: dict | None = None) -> Node:
624
+ node = self.workflow.add(box, settings, (round(position.x() / GRID) * GRID,
625
+ round(position.y() / GRID) * GRID))
626
+ if self.view_group is not None: # added inside an open group
627
+ self.workflow.groups[self.view_group].members.append(node.id)
628
+ item = BoxItem(self, node)
629
+ self.addItem(item)
630
+ self.boxes[node.id] = item
631
+ self.clearSelection()
632
+ item.setSelected(True)
633
+ self.setSceneRect(self.itemsBoundingRect().adjusted(-400, -300, 400, 300))
634
+ self.edited.emit([node.id])
635
+ return node
636
+
637
+ def remove_node(self, node_id: str) -> None:
638
+ after = [e.target for e in self.workflow.edges if e.source == node_id]
639
+ self.workflow.remove(node_id)
640
+ self.rebuild()
641
+ self.edited.emit(after)
642
+
643
+ def remove_edge(self, edge: Edge) -> None:
644
+ self.workflow.disconnect(edge)
645
+ self.rebuild()
646
+ self.edited.emit([edge.target])
647
+
648
+ def remove_group(self, group_id: str) -> None:
649
+ """Remove a group and every box in it."""
650
+ group = self.workflow.groups.get(group_id)
651
+ if group is None:
652
+ return
653
+ after = [e.target for e in self.workflow.edges
654
+ if e.source in group.members and e.target not in group.members]
655
+ for member in list(group.members):
656
+ self.workflow.remove(member)
657
+ self.workflow.groups.pop(group_id, None)
658
+ self.rebuild()
659
+ self.edited.emit([n for n in after if n in self.workflow.nodes])
660
+
661
+ def remove_selected(self) -> None:
662
+ nodes = [item.node.id for item in self.selectedItems() if isinstance(item, BoxItem) and not item.locked]
663
+ edges = [item.edge for item in self.selectedItems() if isinstance(item, WireItem) and item.edge]
664
+ for item in self.selectedItems():
665
+ if isinstance(item, GroupItem):
666
+ nodes.extend(item.group.members)
667
+ if not nodes and not edges:
668
+ return
669
+ after: list[str] = []
670
+ for edge in edges:
671
+ after.append(edge.target)
672
+ self.workflow.disconnect(edge)
673
+ for node_id in nodes:
674
+ after.extend(e.target for e in self.workflow.edges if e.source == node_id)
675
+ self.workflow.remove(node_id)
676
+ self.rebuild()
677
+ self.edited.emit([n for n in after if n in self.workflow.nodes])
678
+
679
+ def duplicate(self, node_id: str) -> Node | None:
680
+ node = self.workflow.nodes.get(node_id)
681
+ if node is None:
682
+ return None
683
+ import copy
684
+ copy_node = self.workflow.add(node.box, copy.deepcopy(node.settings),
685
+ (node.position[0] + 30, node.position[1] + node_height(
686
+ len(self.workflow.spec(node).inputs),
687
+ len(self.workflow.spec(node).outputs)) + 20))
688
+ for edge in [e for e in self.workflow.edges if e.target == node_id]:
689
+ self.workflow.connect(edge.source, copy_node, edge.input, edge.output)
690
+ self.rebuild()
691
+ self.clearSelection()
692
+ self.boxes[copy_node.id].setSelected(True)
693
+ self.edited.emit([copy_node.id])
694
+ return copy_node
695
+
696
+ def disconnect_all(self, node_id: str) -> None:
697
+ touching = [e for e in self.workflow.edges if node_id in (e.source, e.target)]
698
+ if not touching:
699
+ return
700
+ for edge in touching:
701
+ self.workflow.disconnect(edge)
702
+ self.rebuild()
703
+ self.edited.emit([node_id] + [e.target for e in touching if e.source == node_id])
704
+
705
+ # -- selection ---------------------------------------------------------------------
706
+ def _selection_changed(self) -> None:
707
+ self.selected.emit(self.selected_node())
708
+
709
+ def selected_node(self) -> str | None:
710
+ """The id of the selected box or group (the last one), or None."""
711
+ items = [item for item in self.selectedItems() if isinstance(item, (BoxItem, GroupItem))]
712
+ if not items:
713
+ return None
714
+ item = items[-1]
715
+ return item.node.id if isinstance(item, BoxItem) else item.group.id
716
+
717
+ def select(self, key: str | None) -> None:
718
+ self.clearSelection()
719
+ if key in self.boxes:
720
+ self.boxes[key].setSelected(True)
721
+ elif key in self.groups:
722
+ self.groups[key].setSelected(True)
723
+
724
+ # -- dragging a wire -----------------------------------------------------------------
725
+ def _items(self):
726
+ return [*self.boxes.values(), *self.groups.values()]
727
+
728
+ def begin_wire(self, source_id: str, port: Port, start: QPointF, at: QPointF) -> None:
729
+ """Start dragging a wire from node ``source_id``'s output ``port``."""
730
+ wire = WireItem(self, None, port_colour(port))
731
+ wire.setZValue(3)
732
+ self.addItem(wire)
733
+ source = self.workflow.nodes[source_id]
734
+ self._drag = {"from": source_id, "port": port, "wire": wire, "start": start}
735
+ for other in self._items():
736
+ fitting = {pid for pid, node_id, p in other.drop_inputs()
737
+ if self.workflow.can_connect(source, node_id, p.name, port.name)[0]}
738
+ own = other is self.owner(source_id)
739
+ other.set_highlight(fitting, dimmed=not own and not fitting)
740
+ self._drag_to(at)
741
+
742
+ def _drag_to(self, at: QPointF) -> None:
743
+ drag = self._drag
744
+ drag["wire"].set_points(drag["start"], at)
745
+
746
+ def _drop_target(self, at: QPointF) -> tuple[str, Port] | None:
747
+ """``(target node id, Port)`` of the lit input the wire was dropped on."""
748
+ best = None
749
+ for other in self._items():
750
+ local = other.mapFromScene(at)
751
+ inside = other.rect().contains(local)
752
+ inputs = other.drop_inputs()
753
+ for pid, node_id, port in inputs:
754
+ if pid not in other.highlight:
755
+ continue
756
+ centre = other.port_point("in", pid)
757
+ distance = math.hypot(local.x() - centre.x(), local.y() - centre.y())
758
+ if distance < 30 or (inside and len(inputs) == 1):
759
+ if best is None or distance < best[2]:
760
+ best = (node_id, port, distance)
761
+ return (best[0], best[1]) if best else None
762
+
763
+ def mouseMoveEvent(self, event) -> None: # noqa: N802
764
+ if self._drag is not None:
765
+ self._drag_to(event.scenePos())
766
+ event.accept()
767
+ return
768
+ super().mouseMoveEvent(event)
769
+
770
+ def mouseReleaseEvent(self, event) -> None: # noqa: N802
771
+ if self._drag is not None:
772
+ drag, self._drag = self._drag, None
773
+ self.removeItem(drag["wire"])
774
+ target = self._drop_target(event.scenePos()) # before the highlights go
775
+ for other in self._items():
776
+ other.set_highlight(set(), False)
777
+ source = self.workflow.nodes[drag["from"]]
778
+ if target is not None:
779
+ node_id, port = target
780
+ ok, detail = self.workflow.can_connect(source, node_id, port.name, drag["port"].name)
781
+ if ok:
782
+ self.workflow.connect(source, node_id, port.name, drag["port"].name)
783
+ self.rebuild()
784
+ self.edited.emit([node_id])
785
+ else:
786
+ self.refused.emit(detail)
787
+ else:
788
+ # Dropped on a box that does not take it: say why, about the nearest input.
789
+ for other in self._items():
790
+ local = other.mapFromScene(event.scenePos())
791
+ inputs = other.drop_inputs()
792
+ if other.rect().contains(local) and other is not self.owner(drag["from"]) and inputs:
793
+ pid, node_id, nearest = min(inputs, key=lambda i: abs(other.port_point("in", i[0]).y() - local.y()))
794
+ ok, detail = self.workflow.can_connect(source, node_id, nearest.name, drag["port"].name)
795
+ if not ok:
796
+ self.refused.emit(detail)
797
+ event.accept()
798
+ return
799
+ super().mouseReleaseEvent(event)
800
+
801
+ def mouseDoubleClickEvent(self, event) -> None: # noqa: N802
802
+ if self.itemAt(event.scenePos(), self.views()[0].transform() if self.views() else None) is None:
803
+ self.add_here.emit(event.scenePos())
804
+ event.accept()
805
+ return
806
+ super().mouseDoubleClickEvent(event)
807
+
808
+ def contextMenuEvent(self, event) -> None: # noqa: N802
809
+ item = self.itemAt(event.scenePos(), self.views()[0].transform() if self.views() else None)
810
+ while item is not None and not isinstance(item, (BoxItem, GroupItem, WireItem)):
811
+ item = item.parentItem()
812
+ if isinstance(item, BoxItem):
813
+ self.select(item.node.id)
814
+ self.menu.emit("node", item.node.id, event.screenPos())
815
+ elif isinstance(item, GroupItem):
816
+ self.select(item.group.id)
817
+ self.menu.emit("group", item.group.id, event.screenPos())
818
+ elif isinstance(item, WireItem) and item.edge is not None:
819
+ self.menu.emit("edge", item.edge, event.screenPos())
820
+ else:
821
+ self.menu.emit("canvas", event.scenePos(), event.screenPos())
822
+ event.accept()
823
+
824
+
825
+ class WorkflowView(GraphView):
826
+ """The canvas view: panning, zooming and the floating zoom bar, from GraphView."""
827
+
828
+ drawing_name = "The workflow"
829
+
830
+ def __init__(self, scene: WorkflowScene, parent=None) -> None:
831
+ super().__init__(scene, parent)
832
+ self.setDragMode(self.DragMode.RubberBandDrag)
833
+
834
+ def fit(self) -> None:
835
+ self.auto_fit = True
836
+ rect = self.graph.itemsBoundingRect()
837
+ if rect.isEmpty():
838
+ return
839
+ self.fitInView(rect.adjusted(-30, -30, 30, 60), Qt.KeepAspectRatio)
840
+ if self.transform().m11() > 1.25:
841
+ self.resetTransform()
842
+ self.scale(1.25, 1.25)
843
+ self.centerOn(rect.center())
844
+ self.zoom_changed.emit(self.transform().m11())
845
+
846
+ def mousePressEvent(self, event) -> None: # noqa: N802
847
+ # Empty canvas: drag pans (as the net editor); on a box, the box moves.
848
+ if event.button() == Qt.LeftButton and self.itemAt(event.position().toPoint()) is None:
849
+ self.setDragMode(self.DragMode.ScrollHandDrag)
850
+ super().mousePressEvent(event)
851
+
852
+ def mouseReleaseEvent(self, event) -> None: # noqa: N802
853
+ super().mouseReleaseEvent(event)
854
+ self.setDragMode(self.DragMode.RubberBandDrag)