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,972 @@
1
+ """The Workflows page: the box list, the canvas and the side panel.
2
+
3
+ Click a box to see its result in the side panel, with four tabs: *Result*
4
+ (the viewer for its output type), *How* (what it reported while it ran:
5
+ notes, intermediate values, the derivation), *Code* (its source) and
6
+ *Settings* (one control per setting). Change a setting and only the boxes
7
+ after it run again. Every box shows a status dot: waiting, running, done,
8
+ failed, or waiting for your OK (a custom box).
9
+
10
+ Boxes run on a worker thread (:mod:`openprocess.gui.studio.workers`); the runner
11
+ reports each box's status through a signal, so the canvas updates while
12
+ the rest keeps working. An exception stays in its box.
13
+
14
+ The page edits a :class:`~openprocess.flow.workflow.Workflow` and saves it as a
15
+ ``.cpnflow`` file with its record (:mod:`openprocess.flow.record`).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import textwrap
21
+ import re
22
+ import json
23
+ import threading
24
+ from pathlib import Path
25
+
26
+ from PySide6.QtCore import QEvent, QObject, QPoint, QPointF, Qt, QTimer, Signal
27
+ from PySide6.QtGui import QKeySequence, QShortcut
28
+ from PySide6.QtWidgets import (
29
+ QDialog, QFileDialog, QFrame, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
30
+ QSplitter, QStackedWidget, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
31
+ )
32
+
33
+ from ...flow import record as records
34
+ from ...flow.library import GROUP_ORDER, Library
35
+ from ...flow.runner import BLOCKED, DONE, FAILED, IDLE, RUNNING, WAITING, Cache, Result, Run, Runner
36
+ from ...flow.types import EventLog, Figure, PetriNet, Scores, Table
37
+ from ...flow.workflow import Edge, Workflow, group_to_python, to_python
38
+ from .. import theme
39
+ from ..studio import style
40
+ from ..studio.widgets import Card, NoticeBar, PageHeader, SegmentedControl, button, hbox, label, scroll, vbox
41
+ from ..studio.workers import run_in_background
42
+ from .canvas import WorkflowScene, WorkflowView
43
+ from .viewers import SettingsWidget, code_widget, how_widget, pop_out, result_widget, status_text
44
+
45
+ TABS = ["Result", "How", "Code", "Settings"]
46
+
47
+
48
+ class _Relay(QObject):
49
+ """Carries a box's status from the worker thread to the page (queued)."""
50
+
51
+ status = Signal(object)
52
+ finished = Signal(object)
53
+
54
+
55
+ def brief(value) -> str:
56
+ """One line about a result, for the box's subtitle."""
57
+ if isinstance(value, Scores):
58
+ first = next(((k, v) for k, v in value.metrics.items()), None)
59
+ if first is None:
60
+ return value.model
61
+ key, number = first
62
+ return f"{key} {number:.2f}" if isinstance(number, float) else f"{key} {number}"
63
+ if isinstance(value, Table):
64
+ return f"table {len(value.rows)} × {len(value.columns)}"
65
+ if isinstance(value, Figure):
66
+ return value.caption[:40] or "figure"
67
+ if isinstance(value, PetriNet):
68
+ return f"{len(value.places)} places · {len(value.transitions)} transitions"
69
+ if isinstance(value, EventLog):
70
+ return f"{len(value)} cases · {value.event_count} events"
71
+ summary = getattr(value, "summary", None)
72
+ if callable(summary):
73
+ try:
74
+ return str(summary())[:48]
75
+ except Exception: # noqa: BLE001
76
+ pass
77
+ if value is None:
78
+ return "done"
79
+ if hasattr(value, "__len__"):
80
+ try:
81
+ return f"{len(value)} {type(value).__name__.lower()}"
82
+ except TypeError:
83
+ pass
84
+ return type(value).__name__
85
+
86
+
87
+ class WorkflowPage(QWidget):
88
+ status = Signal(str)
89
+ saved = Signal()
90
+ #: Something changed that should be saved (the window autosaves in a folder).
91
+ edited = Signal()
92
+ #: A result should open as a page of its own (a LogDocument, ModelDocument, CpnDocument).
93
+ open_document = Signal(object)
94
+ #: "Edit a copy" of a net: open it on the net canvas.
95
+ edit_requested = Signal(object)
96
+ keep_requested = Signal()
97
+ #: The user allowed the folder's custom boxes to run.
98
+ custom_allowed_changed = Signal()
99
+ #: The panels were shown, hidden or resized (the window remembers the layout).
100
+ layout_changed = Signal()
101
+
102
+ #: The layout every new Workflows page starts with; the window restores a
103
+ #: saved one into it. ``sizes`` are the splitter's: box list, canvas, panel.
104
+ LAYOUT: dict = {"box_list": True, "panel": True, "sizes": [220, 760, 380]}
105
+
106
+ def __init__(self, document, library: Library, folder: str | Path | None = None, parent=None) -> None:
107
+ super().__init__(parent)
108
+ self.document = document
109
+ self.workflow: Workflow = document.workflow
110
+ self.library = library
111
+ self.folder = Path(folder) if folder else None
112
+ self.runner = Runner(library, Cache(), folder=self.folder)
113
+ self.run: Run | None = None
114
+ self._running = False
115
+ self._queued: list[str] | None = None
116
+ self._stop = threading.Event()
117
+ self._relay = _Relay()
118
+ self._relay.status.connect(self._status_arrived)
119
+ self._relay.finished.connect(self._run_finished)
120
+ self.selected: str | None = None
121
+ self.tab = 0
122
+ self.custom_allowed = False
123
+ self._setting_timer = QTimer(self)
124
+ self._setting_timer.setSingleShot(True)
125
+ self._setting_timer.setInterval(400)
126
+ self._setting_timer.timeout.connect(self._apply_pending_settings)
127
+ self._pending: dict[str, dict] = {}
128
+
129
+ root = QVBoxLayout(self)
130
+ root.setContentsMargins(0, 0, 0, 0)
131
+ root.setSpacing(0)
132
+ self.header = PageHeader(self.workflow.name, self._subtitle())
133
+ self.keep_button = button("Keep", self.keep_requested.emit, kind="primary",
134
+ tooltip="Save this workflow into the folder")
135
+ self.keep_button.setVisible(False)
136
+ self.header.actions.addWidget(self.keep_button)
137
+ self.run_button = button("Run ▶", lambda: self.run_from(None), tooltip="Run every box")
138
+ self.header.actions.addWidget(self.run_button)
139
+ self.header.actions.addWidget(button("Re-run", self.rerun,
140
+ tooltip="Run again, after checking the record for changed inputs"))
141
+ self.header.actions.addWidget(button("Record", self.show_record,
142
+ tooltip="The workflow as Python, and the file with its record"))
143
+ self.header.actions.addWidget(button("Export experiment…", self.export_experiment,
144
+ tooltip="A zip with the workflow, its inputs, your boxes, every "
145
+ "result and a README: supplementary material for a paper"))
146
+ self.header.actions.addWidget(button("Save", self.save, tooltip="Save the workflow and its record"))
147
+ root.addWidget(self.header)
148
+ self.custom_bar = NoticeBar()
149
+ root.addWidget(self.custom_bar)
150
+
151
+ self.scene = WorkflowScene(self.workflow)
152
+ self.view = WorkflowView(self.scene)
153
+ self.scene.selected.connect(self._select)
154
+ self.scene.edited.connect(self._edited)
155
+ self.scene.moved.connect(lambda _id: self._mark_edited())
156
+ self.scene.add_here.connect(self.quick_add)
157
+ self.scene.menu.connect(self._menu)
158
+ self.scene.refused.connect(lambda text: self.status.emit(text))
159
+ self.scene.open_group.connect(self.open_group)
160
+
161
+ # Three panels on a splitter: drag the gaps to resize, the toggles (or
162
+ # the View menu) hide a side panel, double-click a gap for the default.
163
+ splitter = _Splitter()
164
+ splitter.addWidget(self._build_box_list())
165
+ canvas = Card()
166
+ self.box_list_toggle = _tool("⇤ Boxes", "Show or hide the box list", self.toggle_box_list)
167
+ self.panel_toggle = _tool("Panel ⇥", "Show or hide the side panel (Result, How, Code, Settings)",
168
+ self.toggle_panel)
169
+ self.back_button = button("← Back", self.close_group, tooltip="Back to the whole workflow")
170
+ self.back_button.setVisible(False)
171
+ self.group_crumb = label("", "muted")
172
+ canvas.add(hbox(self.box_list_toggle, 6, self.back_button, self.group_crumb, None, self.panel_toggle))
173
+ canvas.add(self.view, 1)
174
+ self.legend = label("", "muted", wrap=True)
175
+ canvas.add(self.legend)
176
+ splitter.addWidget(canvas)
177
+ self.panel_host = QWidget()
178
+ self.panel_layout = QVBoxLayout(self.panel_host)
179
+ self.panel_layout.setContentsMargins(0, 0, 0, 0)
180
+ self.panel = scroll(self.panel_host)
181
+ self.panel.setMinimumWidth(280)
182
+ splitter.addWidget(self.panel)
183
+ splitter.setStretchFactor(1, 1)
184
+ splitter.setCollapsible(1, False)
185
+ self.splitter = splitter
186
+ self.box_list_card.setVisible(bool(self.LAYOUT.get("box_list", True)))
187
+ self.panel.setVisible(bool(self.LAYOUT.get("panel", True)))
188
+ splitter.setSizes([int(v) for v in self.LAYOUT.get("sizes") or [220, 760, 380]])
189
+ splitter.splitterMoved.connect(lambda *_: self._layout_changed())
190
+ splitter.reset_requested.connect(self.reset_layout)
191
+ self._sync_toggles()
192
+ wrapper = QWidget()
193
+ wrapper.setLayout(vbox(splitter, margins=(20, 0, 20, 16)))
194
+ root.addWidget(wrapper, 1)
195
+
196
+ QShortcut(QKeySequence(Qt.Key_Delete), self.view, self.scene.remove_selected)
197
+ QShortcut(QKeySequence(Qt.Key_Backspace), self.view, self.scene.remove_selected)
198
+ QShortcut(QKeySequence("Ctrl+D"), self.view, lambda: self.scene.duplicate(self.selected) if self.selected else None)
199
+ QShortcut(QKeySequence("Ctrl+G"), self.view, self.group_selected)
200
+ QShortcut(QKeySequence("Ctrl+Shift+G"), self.view, lambda: self.scene.ungroup(self.selected)
201
+ if self.selected in self.workflow.groups else None)
202
+ self._refresh_custom_bar()
203
+ self._render_panel()
204
+ QTimer.singleShot(0, self.view.fit)
205
+ if not self.workflow.nodes:
206
+ self.status.emit("An empty workflow: double-click the canvas or click a box in the list to add one")
207
+
208
+ # -- the layout: panels shown, hidden, resized ---------------------------------------
209
+ def toggle_box_list(self, show: bool | None = None) -> None:
210
+ """Show or hide the box list (hidden, the canvas takes its width)."""
211
+ if show is None:
212
+ show = not self.box_list_card.isVisible()
213
+ self.box_list_card.setVisible(show)
214
+ self._layout_changed()
215
+
216
+ def toggle_panel(self, show: bool | None = None) -> None:
217
+ """Show or hide the side panel."""
218
+ if show is None:
219
+ show = not self.panel.isVisible()
220
+ self.panel.setVisible(show)
221
+ self._layout_changed()
222
+
223
+ def reset_layout(self) -> None:
224
+ """Both side panels back, at the default widths."""
225
+ self.box_list_card.setVisible(True)
226
+ self.panel.setVisible(True)
227
+ self.splitter.setSizes([220, max(400, self.splitter.width() - 600), 380])
228
+ self._layout_changed()
229
+
230
+ def layout_state(self) -> dict:
231
+ return {"box_list": self.box_list_card.isVisible(), "panel": self.panel.isVisible(),
232
+ "sizes": [max(s, 1) if i != 1 else s for i, s in enumerate(self.splitter.sizes())]}
233
+
234
+ def _layout_changed(self) -> None:
235
+ self._sync_toggles()
236
+ state = self.layout_state()
237
+ sizes = state["sizes"]
238
+ if not state["box_list"]: # keep the width the panel had, for when it is back
239
+ sizes[0] = WorkflowPage.LAYOUT.get("sizes", [220])[0]
240
+ if not state["panel"]:
241
+ sizes[2] = WorkflowPage.LAYOUT.get("sizes", [220, 760, 380])[2]
242
+ WorkflowPage.LAYOUT = {**state, "sizes": sizes}
243
+ if self.view.auto_fit:
244
+ QTimer.singleShot(0, self.view.fit)
245
+ self.layout_changed.emit()
246
+
247
+ def _sync_toggles(self) -> None:
248
+ boxes, panel = self.box_list_card.isVisible(), self.panel.isVisible()
249
+ self.box_list_toggle.setText("⇤ Boxes" if boxes else "Boxes ⇥")
250
+ self.panel_toggle.setText("Panel ⇥" if panel else "⇤ Panel")
251
+
252
+ # -- header --------------------------------------------------------------------------
253
+ def _subtitle(self) -> str:
254
+ count = len(self.workflow.nodes)
255
+ text = f"{count} box{'es' if count != 1 else ''}"
256
+ if self.run is not None:
257
+ text += " · " + self.run.summary()
258
+ return text
259
+
260
+ def refresh_title(self) -> None:
261
+ self.header.set_text(self.workflow.name, self._subtitle())
262
+
263
+ # -- the box list -----------------------------------------------------------------------
264
+ def _build_box_list(self) -> QWidget:
265
+ self.box_list_card = self._make_box_list()
266
+ return self.box_list_card
267
+
268
+ def _make_box_list(self) -> QWidget:
269
+ card = Card("Boxes")
270
+ self.search = QLineEdit()
271
+ self.search.setPlaceholderText("Search boxes")
272
+ self.search.setClearButtonEnabled(True)
273
+ self.search.textChanged.connect(self._fill_box_list)
274
+ card.add(self.search)
275
+ self.box_tree = QTreeWidget()
276
+ self.box_tree.setObjectName("boxList")
277
+ self.box_tree.setHeaderHidden(True)
278
+ self.box_tree.setIndentation(6)
279
+ self.box_tree.setRootIsDecorated(False)
280
+ self.box_tree.itemClicked.connect(self._box_list_clicked)
281
+ self.box_tree.setToolTip("Click a box to add it to the canvas")
282
+ card.add(self.box_tree, 1)
283
+ card.add(label("Click a box to add it. Your own boxes come from the folder's boxes/ subfolder.",
284
+ "muted", wrap=True))
285
+ self._fill_box_list()
286
+ return card
287
+
288
+ def _fill_box_list(self) -> None:
289
+ wanted = self.search.text().strip().lower()
290
+ self.box_tree.clear()
291
+ t = style.tokens()
292
+ from PySide6.QtGui import QBrush, QColor
293
+ for group, specs in self.library.by_group().items():
294
+ shown = [s for s in specs if not wanted or wanted in s.name.lower() or wanted in group.lower()]
295
+ if not shown:
296
+ continue
297
+ head = QTreeWidgetItem([group.upper()])
298
+ head.setFlags(Qt.ItemIsEnabled)
299
+ head.setForeground(0, QBrush(QColor(t.text_muted)))
300
+ font = theme.ui_font(10, theme.QFont.DemiBold)
301
+ head.setFont(0, font)
302
+ self.box_tree.addTopLevelItem(head)
303
+ for spec in shown:
304
+ item = QTreeWidgetItem([spec.name + (" (Yours)" if spec.custom else "")])
305
+ item.setData(0, Qt.UserRole, spec.id)
306
+ tip = _paragraphs(spec.help).split("\n\n")[0]
307
+ if not spec.available:
308
+ tip += f"\n({spec.unavailable_reason})"
309
+ item.setForeground(0, QBrush(QColor(t.text_muted)))
310
+ item.setToolTip(0, tip)
311
+ head.addChild(item)
312
+ head.setExpanded(True)
313
+ for broken in self.library.broken:
314
+ head = QTreeWidgetItem([f"{Path(broken.file).name}: {broken.reason}"])
315
+ head.setFlags(Qt.ItemIsEnabled)
316
+ head.setForeground(0, QBrush(QColor(style.STATUS["critical"])))
317
+ head.setToolTip(0, "This box file could not be loaded; fix it and it reloads when saved")
318
+ self.box_tree.addTopLevelItem(head)
319
+
320
+ def _box_list_clicked(self, item: QTreeWidgetItem, _column: int) -> None:
321
+ box_id = item.data(0, Qt.UserRole)
322
+ if not box_id:
323
+ return
324
+ count = len(self.workflow.nodes) % 6
325
+ rect = self.view.mapToScene(self.view.viewport().rect()).boundingRect()
326
+ self.add_box(box_id, QPointF(rect.left() + 30 + count * 24, rect.bottom() - 150 - count * 12))
327
+
328
+ def add_box(self, box_id: str, where: QPointF) -> None:
329
+ node = self.scene.add_node(box_id, where)
330
+ self.status.emit(f"Added {self.workflow.spec(node).name}: drag from a dot on the right of a box to "
331
+ "connect it")
332
+ self.refresh_title()
333
+
334
+ def reload_library(self) -> None:
335
+ """The folder's boxes changed: load them again and refresh the list."""
336
+ if self.folder is not None:
337
+ self.library.load_folder(self.folder / "boxes")
338
+ self._fill_box_list()
339
+ self._refresh_custom_bar()
340
+ self.scene.rebuild()
341
+ self.run_from(None)
342
+
343
+ # -- custom boxes ----------------------------------------------------------------------------
344
+ def custom_specs(self) -> list:
345
+ return [s for s in self.library if s.custom]
346
+
347
+ def _refresh_custom_bar(self) -> None:
348
+ customs = self.custom_specs()
349
+ if not customs or self.custom_allowed:
350
+ self.custom_bar.clear("custom")
351
+ return
352
+ names = ", ".join(sorted({Path(s.file).name for s in customs if s.file}))
353
+ self.custom_bar.show_notice(
354
+ "custom", f"<b>This folder has {len(customs)} custom box{'es' if len(customs) != 1 else ''}</b> "
355
+ f"({names}). They run Python code from the folder. Run them?",
356
+ [("Run them", self.allow_custom), ("Not now", lambda: self.custom_bar.clear("custom"))])
357
+
358
+ def allow_custom(self) -> None:
359
+ self.custom_allowed = True
360
+ self.custom_bar.clear("custom")
361
+ self.custom_allowed_changed.emit()
362
+ self.run_from([n.id for n in self.workflow.nodes.values() if self.workflow.spec(n).custom])
363
+
364
+ # -- running ---------------------------------------------------------------------------------
365
+ def run_from(self, changed: list[str] | None) -> None:
366
+ """Run the boxes from ``changed`` on (all of them: None), in the background."""
367
+ if self._running:
368
+ self._queued = None if changed is None or self._queued is None else sorted(set(self._queued) | set(changed))
369
+ if changed is None:
370
+ self._queued = None
371
+ self._stop.set()
372
+ return
373
+ problems = [p for p in self.workflow.validate() if "connect" not in p.lower()]
374
+ for problem in problems[:1]:
375
+ self.status.emit(problem)
376
+ customs = {n.id for n in self.workflow.nodes.values() if self.workflow.spec(n).custom}
377
+ self._running = True
378
+ self._stop = threading.Event()
379
+ previous = self.run
380
+ library, workflow, relay, stop, allowed = self.library, self.workflow, self._relay, self._stop, self.custom_allowed
381
+ blocked = set() if allowed else customs
382
+ for node in workflow.order():
383
+ if changed is None or node.id in workflow.descendants(changed):
384
+ self.scene.set_status(node.id, WAITING, "Waiting…")
385
+
386
+ def work():
387
+ if blocked:
388
+ # Custom boxes wait for the user's OK: run with them marked unavailable.
389
+ return self._run_with_blocked(workflow, changed, previous, stop, relay, blocked)
390
+ return self.runner.run(workflow, changed, previous, stop, on_status=relay.status.emit)
391
+
392
+ run_in_background(work, relay.finished.emit, lambda message: relay.finished.emit(message))
393
+
394
+ def _run_with_blocked(self, workflow, changed, previous, stop, relay, blocked):
395
+ """Like the runner, but custom boxes (not yet allowed) are blocked."""
396
+ original = {node_id: workflow.spec(node_id).needs for node_id in blocked}
397
+ for node_id in blocked:
398
+ spec = workflow.spec(node_id)
399
+ spec.needs = ("your OK",)
400
+ try:
401
+ run = self.runner.run(workflow, changed, previous, stop, on_status=relay.status.emit)
402
+ finally:
403
+ for node_id, needs in original.items():
404
+ workflow.spec(node_id).needs = needs
405
+ for (node_id, _variant), result in run.results.items():
406
+ if node_id in blocked and result.status == BLOCKED:
407
+ result.message = "Waiting for your OK (a custom box)"
408
+ return run
409
+
410
+ def _status_arrived(self, result: Result) -> None:
411
+ self._show_status(result)
412
+ if self.selected == result.node:
413
+ self._render_panel()
414
+
415
+ def _show_status(self, result: Result) -> None:
416
+ if result.variant != 0:
417
+ return
418
+ if result.status == DONE:
419
+ subtitle = brief(result.value)
420
+ elif result.status == FAILED:
421
+ subtitle = "Failed. Click for details"
422
+ elif result.status == RUNNING:
423
+ subtitle = "Running…"
424
+ elif result.status == BLOCKED:
425
+ subtitle = result.message or "Waiting for your OK"
426
+ else:
427
+ subtitle = result.message or "Waiting…"
428
+ self.scene.set_status(result.node, result.status, subtitle, result.error or result.message)
429
+
430
+ def _run_finished(self, outcome) -> None:
431
+ self._running = False
432
+ if isinstance(outcome, Run):
433
+ self.run = outcome
434
+ for result in outcome.results.values():
435
+ self._show_status(result)
436
+ failed = outcome.failed()
437
+ if failed:
438
+ self.status.emit(f"{len(failed)} box{'es' if len(failed) != 1 else ''} failed: "
439
+ + ", ".join(self.workflow.title(r.node) for r in failed))
440
+ elif not outcome.stopped:
441
+ self.status.emit(outcome.summary())
442
+ else:
443
+ self.status.emit(f"The run stopped: {outcome}")
444
+ self.refresh_title()
445
+ self._render_panel()
446
+ if self._queued is not None or (isinstance(outcome, Run) and outcome.stopped):
447
+ queued, self._queued = self._queued, None
448
+ self.run_from(queued)
449
+
450
+ def rerun(self) -> None:
451
+ """Run everything again, after saying what differs from the record."""
452
+ record = getattr(self.document, "record", None)
453
+ differences = records.differences(record, self.workflow, self.folder) if record and record.results else []
454
+ if differences:
455
+ box = QMessageBox(self)
456
+ box.setWindowTitle("This is not the recorded experiment")
457
+ box.setText("Re-running now would not reproduce the saved results, because:")
458
+ box.setInformativeText("\n".join("• " + d for d in differences))
459
+ box.setStandardButtons(QMessageBox.Cancel | QMessageBox.Ok)
460
+ box.button(QMessageBox.Ok).setText("Run anyway")
461
+ if box.exec() != QMessageBox.Ok:
462
+ return
463
+ self.runner.cache.clear()
464
+ self.run = None
465
+ self.run_from(None)
466
+
467
+ # -- edits ----------------------------------------------------------------------------------
468
+ def _edited(self, changed: list[str]) -> None:
469
+ self._mark_edited()
470
+ self.refresh_title()
471
+ self.run_from(changed if changed else None)
472
+
473
+ def _mark_edited(self) -> None:
474
+ self.document.dirty = True
475
+ self.edited.emit()
476
+
477
+ def _setting_changed(self, node_id: str, name: str, value) -> None:
478
+ self._pending.setdefault(node_id, {})[name] = value
479
+ self._setting_timer.start()
480
+
481
+ def _apply_pending_settings(self) -> None:
482
+ pending, self._pending = self._pending, {}
483
+ changed = []
484
+ for node_id, values in pending.items():
485
+ if node_id not in self.workflow.nodes:
486
+ continue
487
+ try:
488
+ self.workflow.set(node_id, **values)
489
+ except (ValueError, TypeError) as error:
490
+ self.status.emit(str(error))
491
+ continue
492
+ changed.append(node_id)
493
+ if changed:
494
+ self._mark_edited()
495
+ self.run_from(changed)
496
+
497
+ # -- groups ------------------------------------------------------------------------------
498
+ def group_selected(self) -> None:
499
+ group = self.scene.group_selected()
500
+ if group is not None:
501
+ self._mark_edited()
502
+ self.select(group.id)
503
+ self.status.emit(f"Grouped {len(group.members)} boxes. Double-click the group to open it; "
504
+ "⇧⌘G ungroups it")
505
+
506
+ def open_group(self, group_id: str) -> None:
507
+ if group_id not in self.workflow.groups:
508
+ return
509
+ self.scene.show_group(group_id)
510
+ self.back_button.setVisible(True)
511
+ self.group_crumb.setText(f"{self.workflow.name} › {self.workflow.groups[group_id].name}")
512
+ self.selected = None
513
+ self._render_panel()
514
+ QTimer.singleShot(0, self.view.fit)
515
+
516
+ def close_group(self) -> None:
517
+ group_id = self.scene.view_group
518
+ self.scene.show_group(None)
519
+ self.back_button.setVisible(False)
520
+ self.group_crumb.setText("")
521
+ self.select(group_id if group_id in self.workflow.groups else None)
522
+ QTimer.singleShot(0, self.view.fit)
523
+
524
+ def save_group_as_box(self, group_id: str) -> None:
525
+ """Write the group as a @workflow function into the folder's boxes/."""
526
+ group = self.workflow.groups.get(group_id)
527
+ if group is None:
528
+ return
529
+ if self.folder is None:
530
+ QMessageBox.information(self, "Save as a box", "Open a folder first: the box is saved as a "
531
+ "Python file in its boxes/ subfolder.")
532
+ return
533
+ from ...flow.workflow import _identifier
534
+ source = group_to_python(self.workflow, group)
535
+ header = _box_file_header(self.workflow, group)
536
+ boxes = self.folder / "boxes"
537
+ boxes.mkdir(exist_ok=True)
538
+ target = boxes / f"{_identifier(group.name)}.py"
539
+ if target.exists():
540
+ answer = QMessageBox.question(self, "Save as a box", f"{target.name} exists in boxes/. Replace it?")
541
+ if answer != QMessageBox.Yes:
542
+ return
543
+ target.write_text(header + source, encoding="utf-8")
544
+ self.reload_library()
545
+ self.status.emit(f"Saved {target.name}: “{group.name}” is in the box list under Yours")
546
+
547
+ def _select(self, node_id: str | None) -> None:
548
+ if node_id != self.selected:
549
+ self.selected = node_id
550
+ self._render_panel()
551
+
552
+ def select(self, node_id: str | None, tab: int | None = None) -> None:
553
+ if tab is not None:
554
+ self.tab = tab
555
+ self.scene.select(node_id)
556
+ self.selected = node_id
557
+ self._render_panel()
558
+
559
+ def _render_panel(self) -> None:
560
+ layout = self.panel_layout
561
+ while layout.count():
562
+ item = layout.takeAt(0)
563
+ widget = item.widget()
564
+ if widget is not None:
565
+ widget.hide()
566
+ widget.setParent(None)
567
+ widget.deleteLater()
568
+ elif item.layout() is not None:
569
+ _delete_layout(item.layout())
570
+ node = self.workflow.nodes.get(self.selected) if self.selected else None
571
+ group = self.workflow.groups.get(self.selected) if self.selected else None
572
+ if group is None and node is None and self.scene.view_group:
573
+ group = self.workflow.groups.get(self.scene.view_group)
574
+ if group is not None:
575
+ self._render_group_panel(group)
576
+ return
577
+ if node is None:
578
+ layout.addWidget(label("Click a box to see its result, how it got there, its code and its settings. "
579
+ "Shift-click several boxes and press ⌘G to make them one.", "muted", wrap=True))
580
+ layout.addStretch(1)
581
+ return
582
+ spec = self.workflow.spec(node)
583
+ result = self.run.result(node) if self.run else None
584
+ title = label(node.title or spec.name, "pageTitle")
585
+ layout.addWidget(title)
586
+ chip_text, chip_kind = status_text(result)
587
+ colour = {"good": style.STATUS["good"], "critical": style.STATUS["critical"], "warning": style.STATUS["warning"],
588
+ "accent": style.tokens().accent, "muted": style.tokens().text_muted}[chip_kind]
589
+ chips = label(f"<span style='color: {style.tokens().text_muted}'>{'Yours · ' if spec.custom else ''}{spec.group}"
590
+ f"</span> &nbsp; <span style='color: {colour}; font-weight: 600'>{chip_text}</span>")
591
+ chips.setTextFormat(Qt.RichText)
592
+ layout.addWidget(chips)
593
+ help_text = label(_paragraphs(spec.help), "muted", wrap=True)
594
+ layout.addWidget(help_text)
595
+ tabs = SegmentedControl(TABS, compact=True)
596
+ tabs.set_index(self.tab)
597
+ tabs.changed.connect(self._tab_changed)
598
+ pop = button("⤢", lambda: self.pop_out_tab(), tooltip="Open this tab in its own window")
599
+ pop.setFixedWidth(34)
600
+ layout.addLayout(hbox(tabs, None, pop))
601
+ self.tabs = tabs
602
+ layout.addWidget(self._tab_widget(node, spec, result, self.tab))
603
+ connections = ", ".join(f"{p.label or p.name}: {p.type_name}" for p in spec.inputs) or "nothing"
604
+ gives = ", ".join(p.type_name for p in spec.outputs)
605
+ layout.addWidget(label(f"Takes {connections}." + (f" Gives a {gives}." if gives else ""), "muted", wrap=True))
606
+ layout.addLayout(hbox(button("Duplicate", lambda: self.scene.duplicate(node.id)),
607
+ button("Disconnect all", lambda: self.scene.disconnect_all(node.id)),
608
+ button("Remove box", lambda: self.scene.remove_node(node.id), kind="danger"), None))
609
+ layout.addStretch(1)
610
+
611
+ def _render_group_panel(self, group) -> None:
612
+ layout = self.panel_layout
613
+ inside = self.scene.view_group == group.id
614
+ layout.addWidget(label(group.name, "pageTitle"))
615
+ statuses = [self.scene.statuses.get(m, ("waiting", "", ""))[0] for m in group.members]
616
+ state = ("failed" if "failed" in statuses else "running" if "running" in statuses else
617
+ "done" if statuses and all(s == "done" for s in statuses) else "waiting")
618
+ layout.addWidget(label(f"Workflow · {len(group.members)} boxes · {state}", "muted"))
619
+ layout.addWidget(label("You are inside this group: boxes you add here become part of it." if inside else
620
+ "A sub-workflow shown as one box. Its inputs and outputs are the connections that "
621
+ "reach outside it. Double-click it to open its own canvas.", "muted", wrap=True))
622
+ name = QLineEdit(group.name)
623
+ name.setPlaceholderText("Name")
624
+ name.editingFinished.connect(lambda g=group, n=name: self._rename_group(g, n.text()))
625
+ layout.addWidget(label("NAME", "sectionLabel"))
626
+ layout.addWidget(name)
627
+ layout.addWidget(label("INSIDE", "sectionLabel"))
628
+ for member in group.members:
629
+ if member in self.workflow.nodes:
630
+ status = self.scene.statuses.get(member, ("waiting", "", ""))
631
+ row = button(f"{self.workflow.title(member)} · {status[0]}",
632
+ lambda _=False, m=member, g=group: (self.open_group(g.id), self.select(m)))
633
+ layout.addWidget(row)
634
+ inputs, outputs = self.workflow.group_ports(group)
635
+ takes = ", ".join(f"{p.label or p.name} ({p.type_name})" for _, _, p in inputs) or "nothing"
636
+ gives = ", ".join(p.type_name for _, _, p in outputs) or "nothing"
637
+ layout.addWidget(label(f"Takes {takes}. Gives {gives}.", "muted", wrap=True))
638
+ layout.addWidget(label("AS PYTHON", "sectionLabel"))
639
+ code = QPlainTextEdit(group_to_python(self.workflow, group))
640
+ code.setReadOnly(True)
641
+ code.setFont(theme.mono_font(10.5))
642
+ code.setMaximumHeight(220)
643
+ from .viewers import PythonHighlighter
644
+ PythonHighlighter(code.document())
645
+ layout.addWidget(code)
646
+ layout.addWidget(label("The group and this function are the same thing: Save as a box writes it to the "
647
+ "folder's boxes/ and it appears in the box list; a file like it opens as this group.",
648
+ "muted", wrap=True))
649
+ layout.addLayout(hbox(button("Close" if inside else "Open", self.close_group if inside else
650
+ (lambda: self.open_group(group.id)), kind="primary"),
651
+ button("Save as a box", lambda: self.save_group_as_box(group.id)),
652
+ button("Ungroup", lambda: (self.scene.ungroup(group.id), self._mark_edited(),
653
+ self.select(None))),
654
+ button("Remove", lambda: self.scene.remove_group(group.id), kind="danger"), None))
655
+ layout.addStretch(1)
656
+
657
+ def _rename_group(self, group, name: str) -> None:
658
+ name = name.strip()
659
+ if name and name != group.name:
660
+ group.name = name
661
+ self._mark_edited()
662
+ self.scene.rebuild()
663
+ self.group_crumb.setText(f"{self.workflow.name} › {name}" if self.scene.view_group == group.id else "")
664
+
665
+ def _tab_changed(self, index: int) -> None:
666
+ self.tab = index
667
+ self._render_panel()
668
+
669
+ def _tab_widget(self, node, spec, result: Result | None, index: int) -> QWidget:
670
+ if index == 0:
671
+ if result is None:
672
+ return label("Runs first; the result shows here.", "muted", wrap=True)
673
+ if result.status == FAILED:
674
+ host = QWidget()
675
+ host.setLayout(vbox(label("WHAT WENT WRONG", "sectionLabel"), _error_box(result.traceback or result.error),
676
+ label("The rest of the workflow keeps working. Fix the setting or the file, and "
677
+ "it runs again on its own.", "muted", wrap=True)))
678
+ return host
679
+ if result.status == BLOCKED:
680
+ host = QWidget()
681
+ host.setLayout(vbox(label(result.message or "Not allowed to run yet", wrap=True),
682
+ hbox(button("Run it", self.allow_custom, kind="primary"), None)
683
+ if spec.custom and not self.custom_allowed else label("")))
684
+ return host
685
+ if result.status != DONE:
686
+ return label((result.message or "Waiting for the boxes before it") + ".", "muted", wrap=True)
687
+ if spec.result_type is not None and len(result.values) > 1:
688
+ host = QWidget()
689
+ column = QVBoxLayout(host)
690
+ column.setContentsMargins(0, 0, 0, 0)
691
+ for port in spec.outputs:
692
+ column.addWidget(label(port.name.upper(), "sectionLabel"))
693
+ column.addWidget(result_widget(result.values.get(port.name), self))
694
+ return host
695
+ widget = result_widget(result.value, self)
696
+ if self.run and len(self.run.variants) > 1 and self.run.result(node, 1) is not None:
697
+ host = QWidget()
698
+ host.setLayout(vbox(label(f"The first of {len(self.run.variants)} sweep values; the collecting "
699
+ "boxes after it show all of them.", "muted", wrap=True), widget))
700
+ return host
701
+ return widget
702
+ if index == 1:
703
+ if result is None or result.status not in (DONE, FAILED):
704
+ return label("Runs first, then shows how it got there.", "muted", wrap=True)
705
+ return how_widget(result.explanation, self)
706
+ if index == 2:
707
+ return code_widget(spec, self)
708
+ settings = SettingsWidget(spec, node.settings, self.folder)
709
+ settings.changed.connect(lambda name, value, nid=node.id: self._setting_changed(nid, name, value))
710
+ return settings
711
+
712
+ def pop_out_tab(self) -> None:
713
+ node = self.workflow.nodes.get(self.selected) if self.selected else None
714
+ if node is None:
715
+ return
716
+ spec, index = self.workflow.spec(node), self.tab
717
+ result = self.run.result(node) if self.run else None
718
+ title = f"{node.title or spec.name} · {TABS[index]}"
719
+ if index == 2:
720
+ pop_out(title, lambda: code_widget(spec, self, compact=False), self.window())
721
+ else:
722
+ pop_out(title, lambda: self._tab_widget(node, spec, result, index), self.window())
723
+
724
+ # -- results opening elsewhere ------------------------------------------------------------
725
+ def open_as_log(self, log: EventLog) -> None:
726
+ from ..studio.documents import LogDocument
727
+ self.open_document.emit(LogDocument(log))
728
+
729
+ def open_as_model(self, net: PetriNet) -> None:
730
+ from ..studio.documents import ModelDocument
731
+ self.open_document.emit(ModelDocument(net, origin=f"from {self.workflow.name}"))
732
+
733
+ def open_as_cpn(self, net) -> None:
734
+ from ..studio.documents import CpnDocument
735
+ self.open_document.emit(CpnDocument(net))
736
+
737
+ def edit_copy(self, net: PetriNet) -> None:
738
+ self.edit_requested.emit(net)
739
+
740
+ def save_figure(self, figure: Figure) -> None:
741
+ from ..studio.widgets import suggested_path
742
+ path, _ = QFileDialog.getSaveFileName(self, "Save figure", suggested_path(f"{figure.name}.svg"),
743
+ "SVG (*.svg);;PNG (*.png)")
744
+ if path:
745
+ try:
746
+ figure.save(path)
747
+ self.status.emit(f"Saved {Path(path).name}")
748
+ except ValueError as error:
749
+ QMessageBox.warning(self, "Could not save the figure", str(error))
750
+
751
+ # -- menus and quick add --------------------------------------------------------------
752
+ def _menu(self, kind: str, what, screen_pos) -> None:
753
+ menu = QMenu(self)
754
+ if kind == "node":
755
+ node_id = what
756
+ menu.addAction("Run from here", lambda: self.run_from([node_id]))
757
+ menu.addAction("Duplicate", lambda: self.scene.duplicate(node_id))
758
+ menu.addAction("Disconnect all", lambda: self.scene.disconnect_all(node_id))
759
+ menu.addSeparator()
760
+ menu.addAction("Remove", lambda: self.scene.remove_node(node_id))
761
+ elif kind == "group":
762
+ group_id = what
763
+ menu.addAction("Open", lambda: self.open_group(group_id))
764
+ menu.addAction("Save as a box", lambda: self.save_group_as_box(group_id))
765
+ menu.addAction("Ungroup", lambda: (self.scene.ungroup(group_id), self._mark_edited(), self.select(None)))
766
+ menu.addSeparator()
767
+ menu.addAction("Remove", lambda: self.scene.remove_group(group_id))
768
+ elif kind == "edge":
769
+ edge: Edge = what
770
+ menu.addAction("Remove connection", lambda: self.scene.remove_edge(edge))
771
+ else:
772
+ point: QPointF = what
773
+ menu.addAction("Add box here…", lambda: self.quick_add(point))
774
+ selected = [i for i in self.scene.selectedItems() if hasattr(i, "node")]
775
+ if len(selected) >= 2 and self.scene.view_group is None:
776
+ menu.addAction(f"Group {len(selected)} boxes", self.group_selected)
777
+ menu.addAction("Fit to window", self.view.fit)
778
+ menu.exec(QPoint(int(screen_pos.x()), int(screen_pos.y())) if hasattr(screen_pos, "x") else screen_pos)
779
+
780
+ def quick_add(self, where: QPointF) -> None:
781
+ """A small search list at ``where`` (scene coordinates): pick a box to add."""
782
+ popup = QFrame(self.view, Qt.Popup)
783
+ popup.setObjectName("card")
784
+ edit = QLineEdit()
785
+ edit.setPlaceholderText("Add a box…")
786
+ listing = QListWidget()
787
+ listing.setMinimumWidth(240)
788
+ popup.setLayout(vbox(edit, listing, spacing=4, margins=(6, 6, 6, 6)))
789
+
790
+ def fill() -> None:
791
+ listing.clear()
792
+ wanted = edit.text().strip().lower()
793
+ for spec in self.library:
794
+ if not wanted or wanted in spec.name.lower() or wanted in spec.group.lower():
795
+ item = QListWidgetItem(f"{spec.name} · {spec.group}")
796
+ item.setData(Qt.UserRole, spec.id)
797
+ listing.addItem(item)
798
+ if listing.count():
799
+ listing.setCurrentRow(0)
800
+
801
+ def choose(item=None) -> None:
802
+ item = item or listing.currentItem()
803
+ popup.close()
804
+ if item is not None:
805
+ self.add_box(item.data(Qt.UserRole), QPointF(where.x() - 89, where.y() - 30))
806
+
807
+ edit.textChanged.connect(fill)
808
+ edit.returnPressed.connect(choose)
809
+ listing.itemClicked.connect(choose)
810
+ fill()
811
+ point = self.view.mapFromScene(where)
812
+ popup.move(self.view.mapToGlobal(point))
813
+ popup.show()
814
+ edit.setFocus()
815
+ self._quick = popup
816
+
817
+ # -- the record, saving ------------------------------------------------------------------
818
+ def show_record(self) -> None:
819
+ python = to_python(self.workflow)
820
+ record = records.make_record(self.workflow, self.run, self.folder, lock=False)
821
+ data = {"format": records.FORMAT, **self.workflow.to_dict(), "record": record.to_dict()}
822
+ text = json.dumps(data, indent=2, ensure_ascii=False, default=str)
823
+ dialog = QDialog(self.window())
824
+ dialog.setWindowTitle(f"{self.workflow.name}: the record")
825
+ dialog.resize(900, 700)
826
+ code = QPlainTextEdit(python)
827
+ code.setReadOnly(True)
828
+ code.setFont(theme.mono_font(11))
829
+ from .viewers import PythonHighlighter
830
+ PythonHighlighter(code.document())
831
+ file_text = QPlainTextEdit(text)
832
+ file_text.setReadOnly(True)
833
+ file_text.setFont(theme.mono_font(11))
834
+ from PySide6.QtWidgets import QApplication
835
+ dialog.setLayout(vbox(
836
+ label("Everything needed to run this experiment again and get the same numbers. The Python and the "
837
+ "canvas are two views of the same workflow.", "muted", wrap=True),
838
+ label("AS PYTHON", "sectionLabel"), code, label("THE WORKFLOW FILE", "sectionLabel"), file_text,
839
+ hbox(button("Copy Python", lambda: QApplication.clipboard().setText(python)),
840
+ button("Copy file", lambda: QApplication.clipboard().setText(text)), None,
841
+ button("Done", dialog.accept, kind="primary")), margins=(16, 16, 16, 16)))
842
+ dialog.show()
843
+ self._record_dialog = dialog
844
+
845
+ def write_to(self, path: str, quiet: bool = False) -> None:
846
+ """Save the workflow and its record to ``path`` (the window's Keep / Save As)."""
847
+ target = Path(path)
848
+ self.workflow.name = target.stem if target.stem else self.workflow.name
849
+ self.document.record = records.save(self.workflow, target, self.run, self.folder or target.parent)
850
+ self.document.path = str(target)
851
+ self.document.dirty = False
852
+ self.refresh_title()
853
+ if not quiet:
854
+ self.status.emit(f"Saved {target.name}")
855
+ self.saved.emit()
856
+
857
+ def save(self) -> bool:
858
+ if self.document.path:
859
+ self.write_to(self.document.path)
860
+ return True
861
+ self.export()
862
+ return bool(self.document.path)
863
+
864
+ def autosave(self) -> bool:
865
+ try:
866
+ self.write_to(self.document.path, quiet=True)
867
+ return True
868
+ except OSError as error:
869
+ self.save_error = str(error)
870
+ return False
871
+
872
+ def export(self) -> None:
873
+ from ..studio.widgets import suggested_path
874
+ path, _ = QFileDialog.getSaveFileName(self, "Save workflow", suggested_path(f"{self.workflow.name}.cpnflow"),
875
+ "Workflow (*.cpnflow)")
876
+ if path:
877
+ if not path.lower().endswith(".cpnflow"):
878
+ path += ".cpnflow"
879
+ self.write_to(path)
880
+
881
+ def export_experiment(self) -> None:
882
+ from ..studio.widgets import suggested_path
883
+ path, _ = QFileDialog.getSaveFileName(self, "Export experiment", suggested_path(f"{self.workflow.name}.zip"),
884
+ "Zip archive (*.zip)")
885
+ if not path:
886
+ return
887
+ try:
888
+ target = records.export_experiment(self.workflow, self.run, path, self.folder, self.library)
889
+ except OSError as error:
890
+ QMessageBox.warning(self, "Could not export", str(error))
891
+ return
892
+ self.status.emit(f"Exported the experiment to {Path(target).name}")
893
+
894
+ def stop(self) -> None:
895
+ self._stop.set()
896
+
897
+
898
+ def _box_file_header(workflow, group) -> str:
899
+ """The imports a group's Python needs, as a file in boxes/."""
900
+ imports: dict[str, set[str]] = {}
901
+ types: set[str] = set()
902
+ for member in group.members:
903
+ spec = workflow.spec(member)
904
+ imports.setdefault(spec.module, set()).add(spec.function.__name__)
905
+ for port in [*spec.inputs, *spec.outputs]:
906
+ if port.info is not None:
907
+ types.add(port.info.python)
908
+ lines = ['"""' + f"{group.name}: a workflow saved as a box from OpenProcess.\n\nEdit it as any box file: "
909
+ 'the app reloads it when you save."""', "", "from openprocess.flow import workflow"
910
+ + (", " + ", ".join(sorted(types)) if types else "")]
911
+ for module, functions in sorted(imports.items()):
912
+ lines.append(f"from {module} import {', '.join(sorted(functions))}")
913
+ return "\n".join(lines) + "\n\n\n"
914
+
915
+
916
+ def _tool(text: str, tooltip: str, slot) -> QToolButton:
917
+ tool = QToolButton()
918
+ tool.setObjectName("canvasTool")
919
+ tool.setText(text)
920
+ tool.setToolTip(tooltip)
921
+ tool.setCursor(Qt.PointingHandCursor)
922
+ tool.clicked.connect(lambda: slot())
923
+ return tool
924
+
925
+
926
+ class _Splitter(QSplitter):
927
+ """The page's splitter: wide, quiet handles that light up under the
928
+ mouse; double-click one to get the default layout back."""
929
+ reset_requested = Signal()
930
+
931
+ def __init__(self) -> None:
932
+ super().__init__()
933
+ self.setHandleWidth(14)
934
+ self.setChildrenCollapsible(False)
935
+
936
+ def createHandle(self): # noqa: N802
937
+ handle = super().createHandle()
938
+ handle.installEventFilter(self)
939
+ return handle
940
+
941
+ def eventFilter(self, watched, event): # noqa: N802
942
+ if event.type() == QEvent.MouseButtonDblClick:
943
+ self.reset_requested.emit()
944
+ return True
945
+ return super().eventFilter(watched, event)
946
+
947
+
948
+ def _paragraphs(text: str | None) -> str:
949
+ """A docstring as paragraphs: the source's line breaks joined, blank lines kept."""
950
+ blocks = re.split(r"\n\s*\n", textwrap.dedent(text or "").strip())
951
+ return "\n\n".join(" ".join(line.strip() for line in block.splitlines()) for block in blocks)
952
+
953
+
954
+ def _delete_layout(layout) -> None:
955
+ while layout.count():
956
+ item = layout.takeAt(0)
957
+ widget = item.widget()
958
+ if widget is not None:
959
+ widget.hide()
960
+ widget.setParent(None)
961
+ widget.deleteLater()
962
+ elif item.layout() is not None:
963
+ _delete_layout(item.layout())
964
+
965
+
966
+ def _error_box(text: str) -> QPlainTextEdit:
967
+ box = QPlainTextEdit(text)
968
+ box.setReadOnly(True)
969
+ box.setFont(theme.mono_font(10))
970
+ box.setMaximumHeight(220)
971
+ box.setStyleSheet(f"color: {style.STATUS['critical']};")
972
+ return box