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,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> <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
|