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