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,1858 @@
1
+ """The coloured-Petri-net workspace: edit, simulate and analyse a CPN model.
2
+
3
+ This is the CPN Tools / CPN IDE half of the application, in the same visual
4
+ language as the process-mining pages::
5
+
6
+ ┌ header: model name · summary ·············· Save Save As… Export image… ┐
7
+ │ ┌ Model ──────┐ ┌ canvas ─────────────────────────┐ ┌ inspector ─────────┐ │
8
+ │ │ Pages │ Decl│ │ Edit | Step through | Simulate │ │ Simulation │ │
9
+ │ │ • Top │ │ tools / simulation bar │ │ Element │ │
10
+ │ │ • Sub │ │ │ │ Problems │ │
11
+ │ │ │ │ the net │ │ State space │ │
12
+ │ └─────────────┘ └─────────────────── − 100% + Fit ┘ └────────────────────┘ │
13
+ └──────────────────────────────────────────────────────────────────────────────┘
14
+
15
+ Modes (the same three as for Petri nets in the mining half):
16
+
17
+ * **Edit** -- draw places, transitions and arcs; select one to edit its
18
+ inscriptions in the *Element* tab. Clicking never fires anything.
19
+ * **Step through** -- enabled transitions are green; click one to fire a
20
+ random enabled binding of it, pick a specific binding in the inspector,
21
+ press Step for a random one, or Back to undo.
22
+ * **Simulate** -- Play fires by itself at the chosen speed; Fast-forward
23
+ runs many steps as fast as possible. The firing history can be turned
24
+ into an event log (*Export as event log…*) and mined straight away.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import time
30
+ from datetime import datetime, timedelta, timezone
31
+ from pathlib import Path
32
+
33
+ from PySide6.QtCore import QSize, Qt, QTimer, Signal
34
+ from PySide6.QtGui import QColor, QFont, QKeySequence, QPainter, QShortcut
35
+ from PySide6.QtWidgets import (
36
+ QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
37
+ QHeaderView, QInputDialog, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
38
+ QPlainTextEdit, QSizePolicy, QSpinBox, QSplitter, QStackedWidget, QStyle,
39
+ QStyledItemDelegate,
40
+ QTableWidget, QTableWidgetItem, QToolButton, QVBoxLayout, QWidget,
41
+ )
42
+ from html import escape
43
+
44
+ from ...io.cpn_writer import write_cpn
45
+ from ...ml.values import format_value
46
+ from ...model.net import Arc, CPNet, Place, Transition
47
+ from ...sim.simulator import DeadMarkingError, Simulator
48
+ from .. import theme
49
+ from ..canvas import NetScene, NetView
50
+ from . import style
51
+ from .derivation_view import HtmlDelegate
52
+ from .documents import CpnDocument
53
+ from .graph_view import EdgeSpec, GraphView, NodeSpec
54
+ from .workspace import atomic_write
55
+ from .ml_highlighter import MlHighlighter
56
+ from .widgets import (
57
+ suggested_path,
58
+ Card, ElidedLabel, PageHeader, SegmentedControl, StatTile, Verdict, button, flow, hbox, label,
59
+ scroll, shortcut_text, vbox,
60
+ )
61
+
62
+
63
+ def _tool(text: str, tooltip: str, checkable: bool = False) -> QToolButton:
64
+ tool = QToolButton()
65
+ tool.setObjectName("canvasTool")
66
+ tool.setText(text)
67
+ tool.setToolTip(tooltip)
68
+ tool.setCheckable(checkable)
69
+ tool.setCursor(Qt.PointingHandCursor)
70
+ return tool
71
+
72
+
73
+ def format_time(value) -> str:
74
+ """Model time without float noise: 12, 12.5, 3.142."""
75
+ if isinstance(value, float):
76
+ return f"{value:.3f}".rstrip("0").rstrip(".") if value != int(value) else str(int(value))
77
+ return str(value)
78
+
79
+
80
+ def _mono() -> str:
81
+ # The first one installed wins: macOS, Windows, Linux, then any.
82
+ return ("'SF Mono', Menlo, Consolas, 'Cascadia Mono', 'DejaVu Sans Mono', "
83
+ "'Liberation Mono', 'Courier New', monospace")
84
+
85
+
86
+ def binding_html(net: CPNet, element) -> str:
87
+ """``Check in i = 1, r = 0`` with the name in bold and values in mono."""
88
+ transition = net.find_transition(element.transition_id)
89
+ name = escape(transition.name if transition else element.transition_id)
90
+ muted = style.tokens().text_muted
91
+ def value(v) -> str:
92
+ text = format_value(v)
93
+ return text if len(text) <= 48 else text[:46] + " …"
94
+ parts = [f"<span style='color:{muted}'>{escape(k)}</span>&nbsp;=&nbsp;"
95
+ f"<span style='font-family:{_mono()}'>{escape(value(v))}</span>"
96
+ for k, v in element.assignments]
97
+ return f"<b>{name}</b>&nbsp;&nbsp;" + ", ".join(parts)
98
+
99
+
100
+ class _TwoLineDelegate(QStyledItemDelegate):
101
+ """List rows with a title and a muted second line (UserRole + 1)."""
102
+
103
+ def paint(self, painter: QPainter, option, index) -> None:
104
+ t = style.tokens()
105
+ painter.save()
106
+ selected = bool(option.state & QStyle.State_Selected)
107
+ rect = option.rect.adjusted(4, 2, -4, -2)
108
+ if selected:
109
+ painter.setPen(Qt.NoPen)
110
+ painter.setBrush(QColor(t.accent))
111
+ painter.drawRoundedRect(rect, 7, 7)
112
+ elif option.state & QStyle.State_MouseOver:
113
+ painter.setPen(Qt.NoPen)
114
+ hover = QColor(t.text)
115
+ hover.setAlphaF(0.06)
116
+ painter.setBrush(hover)
117
+ painter.drawRoundedRect(rect, 7, 7)
118
+ font = QFont(option.font)
119
+ font.setWeight(QFont.DemiBold)
120
+ painter.setFont(font)
121
+ painter.setPen(QColor(t.accent_text if selected else t.text))
122
+ text_rect = rect.adjusted(10, 5, -8, 0)
123
+ painter.drawText(text_rect, Qt.AlignLeft | Qt.AlignTop, index.data(Qt.DisplayRole) or "")
124
+ font.setWeight(QFont.Normal)
125
+ theme.set_px(font, theme.px(font) * 0.88)
126
+ painter.setFont(font)
127
+ painter.setPen(QColor(t.accent_text if selected else t.text_muted))
128
+ painter.drawText(text_rect.adjusted(0, 18, 0, 0), Qt.AlignLeft | Qt.AlignTop,
129
+ index.data(Qt.UserRole + 1) or "")
130
+ painter.restore()
131
+
132
+ def sizeHint(self, option, index) -> QSize: # noqa: N802
133
+ return QSize(160, 46)
134
+
135
+
136
+ class CaseVariableDialog(QDialog):
137
+ """Options for turning the firing history into an event log."""
138
+
139
+ def __init__(self, variables: list[str], preferred: str | None, parent=None) -> None:
140
+ super().__init__(parent)
141
+ self.setWindowTitle("Export simulation as event log")
142
+ layout = QVBoxLayout(self)
143
+ layout.setSpacing(12)
144
+ layout.addWidget(label(
145
+ "Every firing becomes an event: the activity is the transition's name and the "
146
+ "timestamp is the model time. Choose the variable whose value identifies the case "
147
+ "(e.g. the passenger id); firings that do not bind it are left out.", "muted",
148
+ wrap=True))
149
+ form = QFormLayout()
150
+ self.variable = QComboBox()
151
+ self.variable.addItems(variables)
152
+ if preferred in variables:
153
+ self.variable.setCurrentText(preferred)
154
+ self.unit = QComboBox()
155
+ self.unit.addItems(["seconds", "minutes", "hours", "days"])
156
+ self.unit.setCurrentText("minutes")
157
+ form.addRow("Case identifier", self.variable)
158
+ form.addRow("One time unit is", self.unit)
159
+ layout.addLayout(form)
160
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
161
+ buttons.accepted.connect(self.accept)
162
+ buttons.rejected.connect(self.reject)
163
+ layout.addWidget(buttons)
164
+
165
+
166
+ class CpnPage(QWidget):
167
+ status = Signal(str)
168
+ saved = Signal()
169
+ #: The model was edited (True) or saved (False).
170
+ dirty_changed = Signal(bool)
171
+ #: Any edit at all (the window's autosave timer restarts on each one).
172
+ edited = Signal()
173
+ #: "Use in a workflow": the window puts this net on a workflow canvas.
174
+ workflow_requested = Signal()
175
+ #: A simulation run was exported as an event log.
176
+ log_generated = Signal(object)
177
+ #: The Snap to grid box was ticked or unticked (one setting for every editor).
178
+ snap_changed = Signal(bool)
179
+
180
+ MODES = ["Edit", "Step through", "Simulate"]
181
+ #: Highlight the fired path while stepping (the Trace box; shared by all nets)
182
+ show_trace = True
183
+ TOOLS = [("select", "Select", "Select and move things (drag on empty canvas to select "
184
+ "several); double-click a place or transition to rename it"),
185
+ ("place", "Place", "Click on the canvas to add a place"),
186
+ ("transition", "Transition", "Click on the canvas to add a transition"),
187
+ ("arc", "Arc", "Drag from a place to a transition (or the other way) to "
188
+ "connect them")]
189
+ INSPECTOR = ["Simulation", "Element", "Problems", "State space"]
190
+ MAX_BINDINGS_SHOWN = 400
191
+
192
+ def __init__(self, document: CpnDocument, parent=None) -> None:
193
+ super().__init__(parent)
194
+ self.document = document
195
+ self.net: CPNet = document.net
196
+ self.net.compile()
197
+ self.simulator = Simulator(self.net)
198
+ self.element = None
199
+ self.busy = False # a state space is being computed
200
+ self.fast_forward_left = 0
201
+ self.runs_completed = 0
202
+
203
+ root = QVBoxLayout(self)
204
+ root.setContentsMargins(0, 0, 0, 0)
205
+ root.setSpacing(0)
206
+ self.header = PageHeader(self.net.name, "")
207
+ self.save_button = button("Save", self.save, tooltip=f"Save the model ({shortcut_text('Ctrl+S')})")
208
+ self.header.actions.addWidget(self.save_button)
209
+ self.header.actions.addWidget(button("Save As…", self.export))
210
+ self.header.actions.addWidget(button("Export image…", self._export_image))
211
+ self.header.actions.addWidget(button("Use in a workflow ⧉", self.workflow_requested.emit,
212
+ tooltip="Put this net on a workflow canvas, with a log and a "
213
+ "Check fit box (or a Simulate box for a coloured net)"))
214
+ root.addWidget(self.header)
215
+
216
+ splitter = QSplitter()
217
+ splitter.setHandleWidth(14)
218
+ splitter.setStyleSheet("QSplitter::handle { background: transparent; }")
219
+ splitter.addWidget(self._build_structure())
220
+ splitter.addWidget(self._build_canvas())
221
+ splitter.addWidget(self._build_inspector())
222
+ splitter.setStretchFactor(1, 1)
223
+ splitter.setCollapsible(1, False)
224
+ splitter.setSizes([250, 780, 400])
225
+ self.splitter = splitter
226
+ wrapper = QWidget()
227
+ wrapper.setLayout(vbox(splitter, margins=(20, 0, 20, 16)))
228
+ root.addWidget(wrapper, 1)
229
+
230
+ self.scene.show_page(self.net.pages[0] if self.net.pages else None)
231
+ self._mode_changed(0)
232
+ self.refresh_all()
233
+ QTimer.singleShot(0, self.view.zoom_to_fit)
234
+
235
+ # =====================================================================
236
+ # Construction
237
+ # =====================================================================
238
+ def _build_structure(self) -> QWidget:
239
+ card = Card()
240
+ self.structure_panel = card
241
+ self.structure_switch = SegmentedControl(["Pages", "Declarations"], compact=True)
242
+ card.setMinimumWidth(max(200, self.structure_switch.sizeHint().width() + 36))
243
+ card.add(hbox(self.structure_switch, None))
244
+ self.structure_stack = QStackedWidget()
245
+
246
+ # -- pages
247
+ pages = QWidget()
248
+ self.page_list = QListWidget()
249
+ self.page_list.setObjectName("sourceList")
250
+ self.page_list.setItemDelegate(_TwoLineDelegate(self.page_list))
251
+ self.page_list.setMouseTracking(True)
252
+ self.page_list.setFrameShape(QListWidget.NoFrame)
253
+ self.page_list.currentRowChanged.connect(self._page_selected)
254
+ self.page_list.itemDoubleClicked.connect(lambda _item: self._rename_page())
255
+ self.page_list.setToolTip("Double-click a page to rename it")
256
+ pages.setLayout(vbox(self.page_list,
257
+ hbox(button("+ Add page", self._add_page, kind="ghost"), None),
258
+ spacing=4))
259
+ self.structure_stack.addWidget(pages)
260
+
261
+ # -- declarations
262
+ declarations = QWidget()
263
+ self.declarations_editor = QPlainTextEdit()
264
+ self.declarations_editor.setFont(theme.mono_font(11))
265
+ self.declarations_editor.setLineWrapMode(QPlainTextEdit.NoWrap)
266
+ self.declarations_editor.setPlaceholderText(
267
+ "colset NO = int;\nvar n : NO;\nfun next n = n + 1;")
268
+ self.highlighter = MlHighlighter(self.declarations_editor.document())
269
+ self.declarations_status = label("", "muted", wrap=True)
270
+ self.apply_button = button("Apply", self._apply_declarations, kind="primary",
271
+ tooltip="Re-read the declarations and recompile the model")
272
+ self.revert_button = button("Revert", self._reload_declarations)
273
+ self.declarations_editor.textChanged.connect(self._declarations_edited)
274
+ declarations.setLayout(vbox(
275
+ label("CPN ML: colset, var, val, fun, globref — each ending in ;", "muted",
276
+ wrap=True),
277
+ self.declarations_editor, self.declarations_status,
278
+ hbox(None, self.revert_button, self.apply_button), spacing=6))
279
+ self.structure_stack.addWidget(declarations)
280
+ self.structure_switch.changed.connect(self._structure_tab)
281
+ card.add(self.structure_stack, 1)
282
+ self._reload_declarations()
283
+ return card
284
+
285
+ def _values_mode(self, index: int) -> None:
286
+ for i, action in enumerate(self.values_actions):
287
+ action.setChecked(i == index)
288
+ self.scene.show_token_values = (None, True, False)[index]
289
+ self.scene.update_markings(self._marking_for)
290
+
291
+ def _toggle_inspector(self) -> None:
292
+ show = not self.inspector_panel.isVisible()
293
+ self.inspector_panel.setVisible(show)
294
+ self.inspector_toggle.setText("Inspector ⇥" if show else "⇤ Inspector")
295
+ if self.view.auto_fit:
296
+ QTimer.singleShot(0, self.view.zoom_to_fit)
297
+
298
+ def _toggle_structure(self, show: bool) -> None:
299
+ splitter = getattr(self, "splitter", None)
300
+ inspector = splitter.sizes()[2] if splitter is not None else 0
301
+ self.structure_panel.setVisible(show)
302
+ if splitter is not None and self.isVisible():
303
+ self._canvas_takes_the_rest(inspector)
304
+
305
+ def _canvas_takes_the_rest(self, inspector: int | None = None) -> None:
306
+ """Side panels keep their width; the canvas gets whatever is left.
307
+
308
+ (QSplitter would share freed space out in proportion, widening the
309
+ inspector and squeezing the canvas's toolbar onto two lines.)
310
+ """
311
+ splitter = self.splitter
312
+ structure = 250 if self.structure_panel.isVisible() else 0
313
+ shown = 1 + self.structure_panel.isVisible() + self.inspector_panel.isVisible()
314
+ total = splitter.width() - splitter.handleWidth() * (shown - 1)
315
+ if inspector is None or inspector <= 0:
316
+ # A little more than the minimum, unless the canvas needs it.
317
+ minimum = self.inspector_panel.minimumWidth()
318
+ inspector = minimum + 40 if total - structure - minimum - 40 >= 800 else minimum
319
+ inspector = inspector if self.inspector_panel.isVisible() else 0
320
+ splitter.setSizes([structure, max(300, total - structure - inspector), inspector])
321
+
322
+ def showEvent(self, event) -> None: # noqa: N802
323
+ super().showEvent(event)
324
+ if not getattr(self, "_shown_once", False):
325
+ self._shown_once = True
326
+ # On a small screen start with the canvas as large as possible
327
+ # (single-page models rarely need the pages list). Measured
328
+ # against the space the window gives the page -- the page's own
329
+ # width already includes the panel.
330
+ from PySide6.QtWidgets import QScrollArea
331
+ available = self.window().width()
332
+ holder = self.parentWidget()
333
+ while holder is not None and not isinstance(holder, QScrollArea):
334
+ holder = holder.parentWidget()
335
+ if holder is not None:
336
+ available = holder.viewport().width()
337
+ if len(self.net.pages) == 1 and (available < 1500 or
338
+ self.minimumSizeHint().width() > available):
339
+ self.structure_panel.setVisible(False)
340
+ QTimer.singleShot(0, self._canvas_takes_the_rest)
341
+
342
+ def _structure_tab(self, index: int) -> None:
343
+ """Declarations are long lines of code: give them room while shown."""
344
+ self.structure_stack.setCurrentIndex(index)
345
+ splitter = getattr(self, "splitter", None)
346
+ if splitter is None:
347
+ return
348
+ sizes = splitter.sizes()
349
+ wanted = 440 if index == 1 else 250
350
+ if index == 1 and sizes[0] >= wanted:
351
+ return
352
+ spare = sizes[0] - wanted
353
+ splitter.setSizes([wanted, max(300, sizes[1] + spare), sizes[2]])
354
+
355
+ def _build_canvas(self) -> QWidget:
356
+ card = Card()
357
+ self.mode_switch = SegmentedControl(self.MODES)
358
+ self.mode_switch.setToolTip("Edit: draw and change the model\n"
359
+ "Step through: fire transitions one at a time\n"
360
+ "Simulate: let the model run by itself")
361
+ self.tool_switch = SegmentedControl([text for _, text, _ in self.TOOLS], compact=True)
362
+ from .tool_icons import tool_icon
363
+ for tool_button, (kind, _, tip) in zip(self.tool_switch.buttons, self.TOOLS):
364
+ tool_button.setToolTip(tip)
365
+ tool_button.setIcon(tool_icon(kind))
366
+ self.sim_status = ElidedLabel("", "muted")
367
+ self.structure_toggle = _tool("☰ Model", "Show or hide the pages and declarations "
368
+ "panel")
369
+ # What to write next to the green token counts (a compact menu button).
370
+ self.values_button = _tool("Tokens", "What to write next to the green token "
371
+ "counts. Hidden values still show on hover and in the "
372
+ "Marking table.")
373
+ self.values_button.setPopupMode(QToolButton.InstantPopup)
374
+ menu = QMenu(self.values_button)
375
+ self.values_actions = []
376
+ for index, text in enumerate(["Values as in the model (CPN Tools’ “hide marking”)",
377
+ "All values", "Counts only"]):
378
+ action = menu.addAction(text)
379
+ action.setCheckable(True)
380
+ action.setChecked(index == 0)
381
+ action.triggered.connect(lambda _=False, i=index: self._values_mode(i))
382
+ self.values_actions.append(action)
383
+ self.values_button.setMenu(menu)
384
+ self.inspector_toggle = _tool("Inspector ⇥", "Show or hide the inspector, to give "
385
+ "the canvas the full width")
386
+ self.undo_button = _tool("↶", f"Undo the last change to the model "
387
+ f"({shortcut_text('Ctrl+Z')})")
388
+ self.redo_button = _tool("↷", f"Redo ({shortcut_text('Ctrl+Shift+Z')})")
389
+ # A flow layout, like the simulation bar: when the model panel and the
390
+ # inspector leave little room, the row wraps instead of widening the page.
391
+ # The status goes last and takes its natural width (up to a limit), so
392
+ # a long one moves to a line of its own rather than pushing buttons.
393
+ self.sim_status.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
394
+ self.sim_status.setMaximumWidth(460)
395
+ card.add(flow(self.structure_toggle, self.undo_button, self.redo_button, 10,
396
+ self.mode_switch, 10, self.values_button, self.inspector_toggle, 10,
397
+ self.sim_status))
398
+ self.tool_hint = ElidedLabel("", "muted")
399
+ self.names_box = QCheckBox("Names outside")
400
+ self.names_box.setToolTip("Write the names of places and transitions next to them "
401
+ "instead of inside; you can then drag each name where you "
402
+ "want it")
403
+ self.names_box.setChecked(getattr(self.net, "names_outside", False))
404
+ self.names_box.toggled.connect(self._names_outside_toggled)
405
+ # Neat nets: new and moved things on the grid, and tidying in one go.
406
+ self.grid_box = QCheckBox("Snap to grid")
407
+ self.grid_box.setToolTip("Put new and moved places, transitions and arc bends on "
408
+ "the canvas's dots, so they line up neatly")
409
+ self.grid_box.setChecked(NetScene.snap_to_grid)
410
+ self.grid_box.toggled.connect(self._snap_toggled)
411
+ self.tidy_button = _tool("Snap All to Grid", "Keep the layout, but move every "
412
+ "place, transition and arc bend on this page to the "
413
+ "nearest dot (undo puts them back)")
414
+ self.tidy_button.clicked.connect(self.snap_everything)
415
+ # Wraps like the row above: a fixed row made this the widest thing on
416
+ # the page, so a narrow window pinned the splitter handles in place.
417
+ self.tool_hint.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
418
+ self.tool_hint.setMaximumWidth(460)
419
+ self.tool_row = flow(self.tool_switch, 12, self.names_box, 4, self.grid_box, 4,
420
+ self.tidy_button, 12, self.tool_hint)
421
+ card.add(self.tool_row)
422
+
423
+ # -- simulation bar ---------------------------------------------------
424
+ self.reset_button = _tool("⟲ Reset", "Back to the initial marking at time 0")
425
+ self.back_button = _tool("◀ Back", "Undo the last firing")
426
+ self.step_button = _tool("Step ▶", f"Fire one random enabled binding "
427
+ f"({shortcut_text('Ctrl+.')})")
428
+ self.play_button = _tool("▶ Play", "Fire random enabled bindings automatically",
429
+ checkable=True)
430
+ self.speed = QSpinBox()
431
+ self.speed.setRange(1, 60)
432
+ self.speed.setValue(5)
433
+ self.speed.setSuffix(" / s")
434
+ self.speed.setToolTip("Play speed: firings per second")
435
+ self.forward_steps = QSpinBox()
436
+ self.forward_steps.setRange(1, 1_000_000)
437
+ self.forward_steps.setValue(1000)
438
+ self.forward_steps.setGroupSeparatorShown(True)
439
+ self.forward_steps.setToolTip("How many steps Fast-forward fires (it stops earlier at "
440
+ "a dead marking)")
441
+ self.forward_button = _tool("⏭ Fast-forward", "Fire many steps as fast as possible "
442
+ "and only show the result", checkable=True)
443
+ self.repeat_box = QCheckBox("Repeat")
444
+ self.repeat_box.setToolTip("When a run reaches a dead marking, start again")
445
+ self.trace_box = QCheckBox("Trace")
446
+ self.trace_box.setToolTip("Highlight the path the tokens took: every fired "
447
+ "transition gets its step numbers and its arcs light up, "
448
+ "the latest step strongest")
449
+ self.trace_box.setChecked(CpnPage.show_trace)
450
+ self.trace_box.toggled.connect(self._trace_toggled)
451
+ for box in (self.repeat_box, self.trace_box):
452
+ # As tall as the buttons, so the flow layout lines them up.
453
+ box.setFixedHeight(self.reset_button.sizeHint().height())
454
+ self.speed.setFixedWidth(84)
455
+ self.forward_steps.setFixedWidth(104)
456
+ steps_label = label("steps", "muted")
457
+ # A flow layout: on a narrow window the bar wraps instead of widening the page.
458
+ self.sim_bar = flow(self.reset_button, self.back_button, self.step_button, 12,
459
+ self.play_button, self.speed, 12, self.forward_button,
460
+ self.forward_steps, steps_label, 12, self.repeat_box, 12,
461
+ self.trace_box)
462
+ card.add(self.sim_bar)
463
+ self.simulate_widgets = [self.play_button, self.speed, self.forward_button,
464
+ self.forward_steps, steps_label, self.repeat_box]
465
+ self.sim_hint = label("", "muted", wrap=True)
466
+ card.add(self.sim_hint)
467
+
468
+ # -- canvas -------------------------------------------------------------
469
+ self.scene = NetScene(self.net)
470
+ self.view = NetView(self.scene)
471
+ self.view.setMinimumSize(300, 240)
472
+ card.add(self.view, 1)
473
+
474
+ self.timer = QTimer(self)
475
+ self.timer.timeout.connect(self._tick)
476
+ self.scene.selection_changed.connect(self._selection_changed)
477
+ self.scene.model_changed.connect(self._model_changed)
478
+ # Undo: remember the model before every change the canvas makes.
479
+ self.undo_stack: list[dict] = []
480
+ self.redo_stack: list[dict] = []
481
+ self._pending_move: dict | None = None
482
+ self.scene.about_to_change.connect(self._checkpoint)
483
+ self.scene.drag_started.connect(self._drag_started)
484
+ self.scene.moved.connect(self._moved)
485
+ self.undo_button.clicked.connect(self.undo)
486
+ self.redo_button.clicked.connect(self.redo)
487
+ for keys, slot in ((QKeySequence.Undo, self.undo), (QKeySequence.Redo, self.redo),
488
+ (QKeySequence("Ctrl+Shift+Z"), self.redo)):
489
+ shortcut = QShortcut(keys, self)
490
+ shortcut.setContext(Qt.WidgetWithChildrenShortcut)
491
+ shortcut.activated.connect(slot)
492
+ self._update_undo_buttons()
493
+ self.scene.transition_activated.connect(self._fire_transition)
494
+ self.scene.created.connect(self._element_created)
495
+ self.scene.rename_requested.connect(lambda element: self.start_rename(element.id))
496
+ self.scene.message.connect(self.status.emit)
497
+ self.mode_switch.changed.connect(self._mode_changed)
498
+ self.tool_switch.changed.connect(self._tool_changed)
499
+ self.reset_button.clicked.connect(self._reset)
500
+ self.back_button.clicked.connect(self._back)
501
+ self.step_button.clicked.connect(lambda: self._step(None))
502
+ self.play_button.toggled.connect(self._play_toggled)
503
+ self.speed.valueChanged.connect(
504
+ lambda v: self.timer.setInterval(int(1000 / v)))
505
+ self.forward_button.toggled.connect(self._forward_toggled)
506
+ self.inspector_toggle.clicked.connect(self._toggle_inspector)
507
+ self.structure_toggle.clicked.connect(
508
+ lambda: self._toggle_structure(not self.structure_panel.isVisible()))
509
+ for key in (QKeySequence.Delete, QKeySequence(Qt.Key_Backspace)):
510
+ shortcut = QShortcut(key, self.view)
511
+ shortcut.setContext(Qt.WidgetShortcut)
512
+ shortcut.activated.connect(self._delete_selection)
513
+ QShortcut(QKeySequence("Ctrl+."), self, lambda: self._step(None))
514
+ return card
515
+
516
+ def _build_inspector(self) -> QWidget:
517
+ host = QWidget()
518
+ self.inspector_panel = host
519
+ layout = QVBoxLayout(host)
520
+ layout.setContentsMargins(0, 0, 0, 0)
521
+ layout.setSpacing(10)
522
+ self.inspector_tabs = SegmentedControl(self.INSPECTOR, compact=True)
523
+ # Room for the tabs (the segmented control grows for "Problems (12)"),
524
+ # but no more: the page must fit a 13" laptop's window.
525
+ host.setMinimumWidth(max(320, self.inspector_tabs.sizeHint().width() + 12))
526
+ layout.addLayout(hbox(self.inspector_tabs, None))
527
+ self.inspector_stack = QStackedWidget()
528
+ for build in (self._build_simulation_tab, self._build_element_tab,
529
+ self._build_problems_tab, self._build_state_space_tab):
530
+ self.inspector_stack.addWidget(scroll(build()))
531
+ self.inspector_tabs.changed.connect(self.inspector_stack.setCurrentIndex)
532
+ layout.addWidget(self.inspector_stack, 1)
533
+ return host
534
+
535
+ # -- inspector: simulation ------------------------------------------------------
536
+ def _build_simulation_tab(self) -> QWidget:
537
+ page = QWidget()
538
+ layout = QVBoxLayout(page)
539
+ layout.setContentsMargins(0, 0, 4, 0)
540
+ layout.setSpacing(10)
541
+ self.step_tile = StatTile("Step", "0")
542
+ self.time_tile = StatTile("Model time", "0")
543
+ self.enabled_tile = StatTile("Enabled", "–")
544
+ layout.addLayout(hbox(self.step_tile, self.time_tile, self.enabled_tile))
545
+
546
+ bindings = Card("Enabled bindings", "Each is a transition with values for its "
547
+ "variables. Double-click one to fire exactly that binding.")
548
+ self.bindings_card = bindings
549
+ self.binding_filter = QLineEdit()
550
+ self.binding_filter.setPlaceholderText("Filter…")
551
+ self.binding_filter.setClearButtonEnabled(True)
552
+ self.binding_filter.textChanged.connect(lambda _: self._fill_bindings())
553
+ self.binding_list = QListWidget()
554
+ self.binding_list.setFont(theme.ui_font(12))
555
+ self.binding_list.setItemDelegate(HtmlDelegate(self.binding_list))
556
+ self.binding_list.setResizeMode(QListWidget.Adjust) # re-wrap rows on resize
557
+ self.binding_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
558
+ self.binding_list.setMinimumHeight(150)
559
+ self.binding_list.setMaximumHeight(260)
560
+ self.binding_list.itemDoubleClicked.connect(
561
+ lambda item: self._step(item.data(Qt.UserRole)))
562
+ self.fire_selected = button("Fire selected", lambda: self._step(
563
+ self.binding_list.currentItem().data(Qt.UserRole)
564
+ if self.binding_list.currentItem() else None))
565
+ self.binding_note = label("", "muted", wrap=True)
566
+ bindings.add(self.binding_filter)
567
+ bindings.add(self.binding_list)
568
+ bindings.add(hbox(self.binding_note, None, self.fire_selected))
569
+ layout.addWidget(bindings)
570
+
571
+ marking = Card("Marking", "Tokens on each place. Click a row to find the place.")
572
+ self.marking_card = marking
573
+ self.only_marked = QCheckBox("Only places with tokens")
574
+ self.only_marked.setChecked(True)
575
+ self.only_marked.toggled.connect(lambda _: self._fill_marking())
576
+ marking.add(self.only_marked)
577
+ self.marking_table = QTableWidget(0, 3)
578
+ self.marking_table.setHorizontalHeaderLabels(["Place", "#", "Tokens"])
579
+ self.marking_table.verticalHeader().hide()
580
+ self.marking_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
581
+ self.marking_table.setSelectionBehavior(QAbstractItemView.SelectRows)
582
+ self.marking_table.setAlternatingRowColors(True)
583
+ self.marking_table.setShowGrid(False)
584
+ self.marking_table.setWordWrap(False)
585
+ header = self.marking_table.horizontalHeader()
586
+ header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
587
+ header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
588
+ header.setSectionResizeMode(2, QHeaderView.Stretch)
589
+ self.marking_table.setMinimumHeight(180)
590
+ self.marking_table.cellClicked.connect(self._marking_row_clicked)
591
+ marking.add(self.marking_table)
592
+ layout.addWidget(marking)
593
+
594
+ history = Card("History", "Firings so far, newest first. Double-click one to go "
595
+ "back to the state right after it.")
596
+ self.history_list = QListWidget()
597
+ self.history_list.setFont(theme.ui_font(12))
598
+ self.history_list.setItemDelegate(HtmlDelegate(self.history_list))
599
+ self.history_list.setResizeMode(QListWidget.Adjust) # re-wrap rows on resize
600
+ self.history_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
601
+ self.history_list.setMinimumHeight(140)
602
+ self.history_list.setMaximumHeight(240)
603
+ self.history_list.itemDoubleClicked.connect(self._rewind_to_item)
604
+ history.add(self.history_list)
605
+ self.export_log_button = button("Export as event log…", self._export_log,
606
+ tooltip="Turn the firings so far into an event log "
607
+ "and open it for mining (dotted chart, "
608
+ "discovery, …)")
609
+ history.add(hbox(None, self.export_log_button))
610
+ layout.addWidget(history)
611
+ layout.addStretch(1)
612
+ return page
613
+
614
+ # -- inspector: element -----------------------------------------------------------
615
+ def _build_element_tab(self) -> QWidget:
616
+ page = QWidget()
617
+ layout = QVBoxLayout(page)
618
+ layout.setContentsMargins(0, 0, 4, 0)
619
+ self.element_card = Card("Nothing selected")
620
+ self.element_hint = label("Click a place, transition or arc on the canvas to see and "
621
+ "edit its inscriptions. Changes apply when you press Return "
622
+ "or leave the field.", "muted", wrap=True)
623
+ self.element_card.add(self.element_hint)
624
+ self.form = QFormLayout()
625
+ self.form.setLabelAlignment(Qt.AlignLeft | Qt.AlignVCenter)
626
+ self.form.setHorizontalSpacing(12)
627
+ self.form.setVerticalSpacing(10)
628
+ self.form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
629
+ self.name_edit = self._line_edit(mono=False)
630
+ self.colour_set_box = QComboBox()
631
+ self.colour_set_box.setEditable(True)
632
+ self.colour_set_box.activated.connect(self._commit_element)
633
+ self.colour_set_box.lineEdit().editingFinished.connect(self._commit_element)
634
+ self.initial_edit = self._line_edit()
635
+ self.guard_edit = self._line_edit()
636
+ self.time_edit = self._line_edit()
637
+ self.expression_edit = self._line_edit()
638
+ self.orientation_box = QComboBox()
639
+ self.orientation_box.addItems(["Place → Transition (input)",
640
+ "Transition → Place (output)",
641
+ "Both directions"])
642
+ self.orientation_box.activated.connect(self._commit_element)
643
+ self.rows: dict[str, tuple[QWidget, QWidget]] = {}
644
+ for key, caption, widget in (
645
+ ("name", "Name", self.name_edit),
646
+ ("colour_set", "Colour set", self.colour_set_box),
647
+ ("initial", "Initial marking", self.initial_edit),
648
+ ("guard", "Guard", self.guard_edit),
649
+ ("time", "Time delay @+", self.time_edit),
650
+ ("expression", "Inscription", self.expression_edit),
651
+ ("orientation", "Direction", self.orientation_box)):
652
+ caption_label = label(caption, "muted")
653
+ self.form.addRow(caption_label, widget)
654
+ self.rows[key] = (caption_label, widget)
655
+ self.element_card.add(self.form)
656
+ self.element_info = label("", "muted", wrap=True, selectable=True)
657
+ self.element_card.add(self.element_info)
658
+ # A substitution transition: go to the page it stands for.
659
+ self.subpage_button = button("Open subpage", self._open_subpage, kind="ghost",
660
+ tooltip="Show the page this substitution transition "
661
+ "stands for")
662
+ self.element_card.add(hbox(self.subpage_button, None))
663
+ self.subpage_button.hide()
664
+ self.element_problems = QVBoxLayout()
665
+ self.element_card.add(self.element_problems)
666
+ layout.addWidget(self.element_card)
667
+ layout.addStretch(1)
668
+ self._show_element(None)
669
+ return page
670
+
671
+ def _line_edit(self, mono: bool = True) -> QLineEdit:
672
+ edit = QLineEdit()
673
+ if mono:
674
+ edit.setFont(theme.mono_font(12))
675
+ edit.editingFinished.connect(self._commit_element)
676
+ return edit
677
+
678
+ # -- inspector: problems ------------------------------------------------------------
679
+ def _build_problems_tab(self) -> QWidget:
680
+ page = QWidget()
681
+ layout = QVBoxLayout(page)
682
+ layout.setContentsMargins(0, 0, 4, 0)
683
+ self.problems_card = Card("Problems", "Inscriptions and declarations that did not "
684
+ "compile. Click Show to jump to the element.")
685
+ layout.addWidget(self.problems_card)
686
+ layout.addStretch(1)
687
+ return page
688
+
689
+ # -- inspector: state space ----------------------------------------------------------
690
+ def _build_state_space_tab(self) -> QWidget:
691
+ page = QWidget()
692
+ layout = QVBoxLayout(page)
693
+ layout.setContentsMargins(0, 0, 4, 0)
694
+ layout.setSpacing(10)
695
+ card = Card("State space", "Every reachable marking (with its model time) and the "
696
+ "binding elements between them, explored breadth-first from the initial "
697
+ "marking — the analysis CPN Tools calls the state space tool.")
698
+ self.max_nodes = QSpinBox()
699
+ self.max_nodes.setRange(10, 1_000_000)
700
+ self.max_nodes.setValue(20_000)
701
+ self.max_nodes.setGroupSeparatorShown(True)
702
+ self.max_nodes.setToolTip("Stop exploring after this many nodes (the report then "
703
+ "says PARTIAL)")
704
+ self.space_button = button("Calculate", self._calculate_state_space, kind="primary")
705
+ # A flow layout: in a narrow inspector the button wraps below instead
706
+ # of being cut off.
707
+ card.add(flow(label("Node limit", "muted"), self.max_nodes, 12, self.space_button))
708
+ self.space_status = label("", "muted", wrap=True)
709
+ card.add(self.space_status)
710
+ layout.addWidget(card)
711
+ self.space_results = Card()
712
+ self.space_results.setVisible(False)
713
+ layout.addWidget(self.space_results)
714
+ layout.addStretch(1)
715
+ return page
716
+
717
+ # =====================================================================
718
+ # Refreshing
719
+ # =====================================================================
720
+ def _subtitle(self) -> str:
721
+ places = sum(1 for _ in self.net.all_places())
722
+ transitions = sum(1 for _ in self.net.all_transitions())
723
+ pages = len(self.net.pages)
724
+ where = Path(self.document.path).name if self.document.path else "not saved yet"
725
+ state = " · edited" if self.document.dirty and not self.document.autosave else ""
726
+ return (f"Coloured Petri net · {pages} page{'s' * (pages != 1)} · {places} places · "
727
+ f"{transitions} transitions · {where}{state}")
728
+
729
+ def restyle(self) -> None:
730
+ """Colours changed (light/dark): redraw the canvas items."""
731
+ self.scene.rebuild()
732
+ self.refresh_all()
733
+ self.highlighter.rehighlight()
734
+
735
+ def refresh_title(self) -> None:
736
+ self.header.set_text(self.net.name, self._subtitle())
737
+
738
+ def refresh_all(self) -> None:
739
+ self.refresh_title()
740
+ self._fill_pages()
741
+ self._fill_problems()
742
+ self.scene.refresh_labels()
743
+ self.refresh_simulation()
744
+
745
+ def _fill_pages(self) -> None:
746
+ self.page_list.blockSignals(True)
747
+ self.page_list.clear()
748
+ parents = {t.substitution_subpage: t for t in self.net.all_transitions()
749
+ if t.is_substitution}
750
+ for page in self.net.pages:
751
+ item = QListWidgetItem(page.name)
752
+ owner = parents.get(page.id)
753
+ item.setData(Qt.UserRole + 1, f"{len(page.places)} places · "
754
+ f"{len(page.transitions)} transitions"
755
+ + (f" · subpage of “{owner.name}”" if owner else ""))
756
+ self.page_list.addItem(item)
757
+ if self.scene.page in self.net.pages:
758
+ self.page_list.setCurrentRow(self.net.pages.index(self.scene.page))
759
+ self.page_list.blockSignals(False)
760
+
761
+ def refresh_simulation(self, light: bool = False) -> None:
762
+ """Enabling, markings, tiles, bindings and history.
763
+
764
+ ``light`` skips the lists (used while playing fast): the canvas and
765
+ the tiles are what one watches then.
766
+ """
767
+ counts: dict[str, int] = {}
768
+ self.enabled_elements = []
769
+ try:
770
+ for transition in self.net.all_transitions():
771
+ elements = self.simulator.enabled_bindings(transition)
772
+ if elements:
773
+ counts[transition.id] = len(elements)
774
+ self.enabled_elements.extend(elements)
775
+ except Exception as error: # noqa: BLE001 - a model error, surfaced not swallowed
776
+ self.status.emit(f"Enabling could not be computed: {error}")
777
+ self.scene.update_enabling(counts)
778
+ self.scene.update_markings(self._marking_for)
779
+ last = self.simulator.log[-1].binding.transition_id if self.simulator.log else None
780
+ for transition_id, item in self.scene.transition_items.items():
781
+ item.set_halo(self.simulating and transition_id == last)
782
+ self._update_trace()
783
+
784
+ self.step_tile.set(f"{self.simulator.step_count:,}")
785
+ self.time_tile.set(format_time(self.simulator.clock))
786
+ transitions = len(counts)
787
+ self.enabled_tile.set(f"{len(self.enabled_elements):,}",
788
+ f"binding{'s' * (len(self.enabled_elements) != 1)} of "
789
+ f"{transitions} transition{'s' * (transitions != 1)}")
790
+ self._update_sim_status()
791
+ if not light:
792
+ self._fill_bindings()
793
+ self._fill_marking()
794
+ self._fill_history()
795
+
796
+ def _update_trace(self) -> None:
797
+ if self.simulating and self.trace_box.isChecked():
798
+ log = self.simulator.log[-200:]
799
+ self.scene.set_trace([(record.step, record.binding.transition_id)
800
+ for record in log])
801
+ else:
802
+ self.scene.set_trace(None)
803
+
804
+ def _trace_toggled(self, on: bool) -> None:
805
+ CpnPage.show_trace = on # the next net opens the same way
806
+ self._update_trace()
807
+
808
+ def _marking_for(self, place_id: str) -> tuple[int, str]:
809
+ tokens = self.simulator.marking.get(self.net.marking_key(place_id))
810
+ count = tokens.size()
811
+ return (0, "") if not count else (count, str(tokens))
812
+
813
+ def _dead(self) -> bool:
814
+ """Nothing enabled now, and waiting (advancing time) cannot help."""
815
+ if self.enabled_elements:
816
+ return False
817
+ from ...ml.multiset import TimedMultiset
818
+ for place in self.net.all_places():
819
+ tokens = self.simulator.marking.get(self.net.marking_key(place.id))
820
+ if isinstance(tokens, TimedMultiset) and \
821
+ tokens.next_time_after(self.simulator.clock) is not None:
822
+ return False
823
+ return True
824
+
825
+ def _update_sim_status(self) -> None:
826
+ if not self.simulating:
827
+ problems = len(self.net.errors)
828
+ self.sim_status.setText("✓ compiles" if not problems else
829
+ f"⚠ {problems} problem{'s' * (problems != 1)} — see "
830
+ "the Problems tab")
831
+ return
832
+ if self.enabled_elements:
833
+ state = f"{len(self.enabled_elements):,} enabled"
834
+ elif self._dead():
835
+ state = "✕ dead marking: nothing can happen any more"
836
+ else:
837
+ state = "waiting: tokens become available later (Step advances time)"
838
+ runs = f" · {self.runs_completed} run(s)" if self.mode_switch.index() == 2 and \
839
+ self.runs_completed else ""
840
+ self.sim_status.setText(f"Step {self.simulator.step_count:,} · time "
841
+ f"{format_time(self.simulator.clock)} · {state}{runs}")
842
+ self.back_button.setEnabled(bool(self.simulator.log))
843
+ self.step_button.setEnabled(not self._dead())
844
+
845
+ def _fill_bindings(self) -> None:
846
+ wanted = self.binding_filter.text().strip().lower()
847
+ self.binding_list.clear()
848
+ shown = 0
849
+ matching = 0
850
+ for element in getattr(self, "enabled_elements", []):
851
+ text = element.describe(self.net)
852
+ if wanted and wanted not in text.lower():
853
+ continue
854
+ matching += 1
855
+ if shown >= self.MAX_BINDINGS_SHOWN:
856
+ continue
857
+ item = QListWidgetItem(binding_html(self.net, element))
858
+ item.setData(Qt.UserRole, element)
859
+ item.setToolTip(text)
860
+ self.binding_list.addItem(item)
861
+ shown += 1
862
+ total = len(getattr(self, "enabled_elements", []))
863
+ if not total:
864
+ self.binding_note.setText("Nothing is enabled." + ("" if self._dead() else
865
+ " Step advances the clock to the next token."))
866
+ elif shown < matching:
867
+ self.binding_note.setText(f"Showing {shown:,} of {matching:,}; filter to narrow.")
868
+ elif wanted:
869
+ self.binding_note.setText(f"{matching:,} of {total:,} match")
870
+ else:
871
+ self.binding_note.setText(f"{total:,} enabled")
872
+ self.fire_selected.setEnabled(bool(shown))
873
+
874
+ def _fill_marking(self) -> None:
875
+ rows = []
876
+ for place in self.net.all_places():
877
+ tokens = self.simulator.marking.get(self.net.marking_key(place.id))
878
+ count = tokens.size()
879
+ if count or not self.only_marked.isChecked():
880
+ rows.append((place, count, str(tokens) if count else "empty"))
881
+ self.marking_table.setRowCount(len(rows))
882
+ mono = theme.mono_font(11)
883
+ for row, (place, count, text) in enumerate(rows):
884
+ name = QTableWidgetItem(place.name)
885
+ name.setData(Qt.UserRole, place.id)
886
+ number = QTableWidgetItem(f"{count:,}")
887
+ number.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
888
+ tokens = QTableWidgetItem(text)
889
+ tokens.setFont(mono)
890
+ tokens.setToolTip(text if len(text) < 2000 else text[:2000] + " …")
891
+ if not count:
892
+ tokens.setForeground(QColor(style.tokens().text_muted))
893
+ self.marking_table.setItem(row, 0, name)
894
+ self.marking_table.setItem(row, 1, number)
895
+ self.marking_table.setItem(row, 2, tokens)
896
+
897
+ def _fill_history(self) -> None:
898
+ self.history_list.clear()
899
+ muted = style.tokens().text_muted
900
+ for record in reversed(self.simulator.log[-300:]):
901
+ item = QListWidgetItem(
902
+ f"<span style='color:{muted}'>{record.step:,} · t = "
903
+ f"{escape(format_time(record.time))}</span>&nbsp;&nbsp;"
904
+ + binding_html(self.net, record.binding))
905
+ item.setData(Qt.UserRole, record.step)
906
+ self.history_list.addItem(item)
907
+ if not self.simulator.log:
908
+ item = QListWidgetItem(f"<span style='color:{muted}'>Nothing has fired yet.</span>")
909
+ item.setFlags(Qt.NoItemFlags)
910
+ self.history_list.addItem(item)
911
+
912
+ def _fill_problems(self) -> None:
913
+ errors = list(self.net.errors)
914
+ count = len(errors)
915
+ self.inspector_tabs.buttons[2].setText(f"Problems ({count})" if count else "Problems")
916
+ card = self.problems_card
917
+ card.clear()
918
+ if not errors:
919
+ card.add(Verdict("Model compiles", "good", "Every declaration and inscription "
920
+ "parsed. The model is ready to simulate."))
921
+ return
922
+ for issue in errors[:200]:
923
+ row = QWidget()
924
+ title = f"{issue.element_name or 'Declarations'} — {issue.field_name}"
925
+ layout = hbox(Verdict(title, "critical", issue.message), spacing=6)
926
+ if issue.element_id:
927
+ layout.addWidget(button("Show", lambda _=False, i=issue.element_id:
928
+ self.reveal_element(i), kind="ghost"), 0, Qt.AlignTop)
929
+ elif issue.field_name == "declarations":
930
+ layout.addWidget(button("Show", lambda: self.structure_switch.set_index(1),
931
+ kind="ghost"), 0, Qt.AlignTop)
932
+ row.setLayout(layout)
933
+ card.add(row)
934
+
935
+ # =====================================================================
936
+ # Modes and tools
937
+ # =====================================================================
938
+ @property
939
+ def simulating(self) -> bool:
940
+ return self.mode_switch.index() in (1, 2)
941
+
942
+ def _mode_changed(self, index: int) -> None:
943
+ self._stop_running()
944
+ editing = index == 0
945
+ self.tool_row.setVisible(editing)
946
+ self.sim_bar.setVisible(not editing)
947
+ for widget in self.simulate_widgets:
948
+ widget.setVisible(index == 2)
949
+ self.sim_hint.setText(
950
+ "" if editing else
951
+ "Green = enabled. Click a green transition to fire it, or double-click a binding "
952
+ "in the inspector to choose the values. Back undoes. Trace lights up the path "
953
+ "so far." if index == 1 else
954
+ "Play fires random bindings by itself (blue halo = fired last). Fast-forward runs "
955
+ "many steps at once. History ▸ Export as event log makes a log you can mine.")
956
+ self.sim_hint.setVisible(not editing)
957
+ self.scene.fire_on_click = not editing
958
+ self.scene.set_editable(editing)
959
+ if not editing:
960
+ self.tool_switch.set_index(0)
961
+ if self.net.errors:
962
+ self.status.emit("The model has problems; fix them (Problems tab) for "
963
+ "reliable simulation.")
964
+ self.refresh_simulation()
965
+
966
+ def _tool_changed(self, index: int) -> None:
967
+ from .tool_icons import tool_cursor
968
+ tool = self.TOOLS[index][0]
969
+ self.scene._cancel_connection()
970
+ self.scene.hide_connect_handle()
971
+ self.scene.tool = tool
972
+ delete = shortcut_text("Backspace")
973
+ self.tool_hint.setText({
974
+ "select": f"Drag to move · hover a node and drag its arrow to connect, or "
975
+ f"to empty space to add the next node · double-click to rename · "
976
+ f"{delete} deletes",
977
+ "place": "Click on the canvas to add a place",
978
+ "transition": "Click on the canvas to add a transition",
979
+ "arc": "Drag from a place to a transition (or back), or to empty space to add "
980
+ "one — or click one node, then the other",
981
+ }[tool])
982
+ self.scene.set_editable(self.scene.editable) # handles only with the select tool
983
+ self.view.setDragMode(NetView.RubberBandDrag if tool == "select" else NetView.NoDrag)
984
+ self.view.viewport().setCursor(tool_cursor(tool))
985
+
986
+ # -- naming things on the canvas ---------------------------------------------------
987
+ # -- neat nets ----------------------------------------------------------------
988
+ def _snap_toggled(self, on: bool) -> None:
989
+ NetScene.snap_to_grid = on
990
+ self.snap_changed.emit(on)
991
+
992
+ def snap_everything(self) -> None:
993
+ """Snap All to Grid: every node and arc bend of this page, layout kept."""
994
+ from ..canvas import GRID_STEP
995
+ from ..tidy import snap_page
996
+ if self.scene.page is None:
997
+ return
998
+ self._checkpoint()
999
+ moved = snap_page(self.scene.page, GRID_STEP)
1000
+ self._edited()
1001
+ self.status.emit(f"Snapped to the grid ({moved} node{'s' * (moved != 1)} moved) — "
1002
+ f"{shortcut_text('Ctrl+Z')} puts them back")
1003
+
1004
+ def _names_outside_toggled(self, outside: bool) -> None:
1005
+ if getattr(self.net, "names_outside", False) == outside:
1006
+ return
1007
+ self._push_undo(self._snapshot())
1008
+ self.net.names_outside = outside
1009
+ self.scene.rebuild()
1010
+ self.refresh_simulation()
1011
+ self.set_dirty(True)
1012
+
1013
+ def _element_created(self, element) -> None:
1014
+ """A new place or transition: back to Select (from the Place and
1015
+ Transition tools) and type its name."""
1016
+ if self.scene.tool in ("place", "transition"):
1017
+ self.tool_switch.set_index(0)
1018
+ QTimer.singleShot(0, lambda: self.start_rename(element.id))
1019
+
1020
+ def start_rename(self, element_id: str) -> None:
1021
+ """Edit a place's or transition's name right on the canvas."""
1022
+ item = self.scene.place_items.get(element_id) or \
1023
+ self.scene.transition_items.get(element_id)
1024
+ if item is None or self.mode_switch.index() != 0:
1025
+ return
1026
+ element = getattr(item, "place", None) or item.transition
1027
+ self.scene.clearSelection()
1028
+ item.setSelected(True)
1029
+
1030
+ def done(text: str) -> None:
1031
+ text = text.strip()
1032
+ current = self.net.find_place(element_id) or self.net.find_transition(element_id)
1033
+ if current is None or not text or text == current.name:
1034
+ return
1035
+ self._push_undo(self._snapshot())
1036
+ current.name = text
1037
+ self._edited()
1038
+ self.reveal_element(element_id)
1039
+
1040
+ def anchor():
1041
+ # Looked up afresh each time: the node may be redrawn (a new
1042
+ # item) or moved while the box is open, or deleted.
1043
+ current = self.scene.place_items.get(element_id) or \
1044
+ self.scene.transition_items.get(element_id)
1045
+ return current.name_anchor() if current is not None else None
1046
+
1047
+ self.view.edit_text(anchor, element.name, done)
1048
+
1049
+ # =====================================================================
1050
+ # Simulation
1051
+ # =====================================================================
1052
+ def _step(self, element=None) -> bool:
1053
+ if self.busy:
1054
+ return False
1055
+ if not self.simulating:
1056
+ self.mode_switch.set_index(1)
1057
+ try:
1058
+ fired = self.simulator.step(element)
1059
+ except DeadMarkingError:
1060
+ self.status.emit(f"Dead marking at time {format_time(self.simulator.clock)} "
1061
+ f"after {self.simulator.step_count:,} steps")
1062
+ self.refresh_simulation()
1063
+ return False
1064
+ except Exception as error: # noqa: BLE001
1065
+ self._stop_running()
1066
+ QMessageBox.warning(self, "Simulation error", str(error))
1067
+ return False
1068
+ text = fired.describe(self.net)
1069
+ self.status.emit(f"Fired {text if len(text) < 140 else text[:138] + ' …'}")
1070
+ self.refresh_simulation(light=self.timer.isActive() and self.speed.value() > 12)
1071
+ return True
1072
+
1073
+ def _fire_transition(self, transition: Transition) -> None:
1074
+ """Clicking an enabled transition fires one of its bindings."""
1075
+ elements = self.simulator.enabled_bindings(transition)
1076
+ if elements:
1077
+ self._step(self.simulator.choice_rng.choice(elements))
1078
+
1079
+ def _back(self) -> None:
1080
+ if self.simulator.log:
1081
+ self.simulator.rewind_to(self.simulator.step_count - 1)
1082
+ self.refresh_simulation()
1083
+
1084
+ def _reset(self) -> None:
1085
+ self._stop_running()
1086
+ self.simulator.reset()
1087
+ self.runs_completed = 0
1088
+ self.status.emit("Back at the initial marking")
1089
+ self.refresh_simulation()
1090
+
1091
+ def _rewind_to_item(self, item: QListWidgetItem) -> None:
1092
+ step = item.data(Qt.UserRole)
1093
+ if step is not None:
1094
+ self._stop_running()
1095
+ self.simulator.rewind_to(step)
1096
+ self.status.emit(f"Went back to the state after step {step:,}")
1097
+ self.refresh_simulation()
1098
+
1099
+ def _play_toggled(self, on: bool) -> None:
1100
+ self.play_button.setText("⏸ Pause" if on else "▶ Play")
1101
+ if on:
1102
+ self.forward_button.setChecked(False)
1103
+ self.timer.start(int(1000 / self.speed.value()))
1104
+ else:
1105
+ self.timer.stop()
1106
+ self.refresh_simulation()
1107
+
1108
+ def _tick(self) -> None:
1109
+ if self._step(None):
1110
+ return
1111
+ # A dead marking: the run is over.
1112
+ self.runs_completed += 1
1113
+ if self.repeat_box.isChecked():
1114
+ self.simulator.reset()
1115
+ self.refresh_simulation(light=True)
1116
+ else:
1117
+ self.play_button.setChecked(False)
1118
+
1119
+ def _forward_toggled(self, on: bool) -> None:
1120
+ self.forward_button.setText("■ Stop" if on else "⏭ Fast-forward")
1121
+ if on:
1122
+ self.play_button.setChecked(False)
1123
+ self.fast_forward_left = self.forward_steps.value()
1124
+ QTimer.singleShot(0, self._forward_chunk)
1125
+ else:
1126
+ self.fast_forward_left = 0
1127
+
1128
+ def _forward_chunk(self) -> None:
1129
+ """Fire a chunk of steps, then yield to the event loop (keeps the UI alive)."""
1130
+ if self.fast_forward_left <= 0:
1131
+ self.forward_button.setChecked(False)
1132
+ self.refresh_simulation()
1133
+ return
1134
+ chunk = min(200, self.fast_forward_left)
1135
+ taken = self.simulator.run(chunk)
1136
+ self.fast_forward_left -= chunk
1137
+ if taken < chunk: # dead marking
1138
+ self.runs_completed += 1
1139
+ if self.repeat_box.isChecked() and self.fast_forward_left > 0:
1140
+ self.simulator.reset()
1141
+ else:
1142
+ self.fast_forward_left = 0
1143
+ self.refresh_simulation(light=True)
1144
+ self.status.emit(f"Fast-forward: step {self.simulator.step_count:,}, time "
1145
+ f"{format_time(self.simulator.clock)}")
1146
+ QTimer.singleShot(0, self._forward_chunk)
1147
+
1148
+ def _stop_running(self) -> None:
1149
+ if hasattr(self, "play_button"):
1150
+ self.play_button.setChecked(False)
1151
+ self.forward_button.setChecked(False)
1152
+ if hasattr(self, "timer"):
1153
+ self.timer.stop()
1154
+
1155
+ def _export_log(self) -> None:
1156
+ records = list(self.simulator.log)
1157
+ if not records:
1158
+ self.status.emit("Nothing has fired yet: simulate first, then export.")
1159
+ return
1160
+ counts: dict[str, int] = {}
1161
+ for record in records:
1162
+ for name, _value in record.binding.assignments:
1163
+ counts[name] = counts.get(name, 0) + 1
1164
+ if not counts:
1165
+ QMessageBox.information(self, "Export as event log",
1166
+ "The transitions that fired have no variables, so there "
1167
+ "is nothing to identify cases by.")
1168
+ return
1169
+ variables = sorted(counts, key=lambda n: (-counts[n], n))
1170
+ preferred = next((v for v in variables if v.lower() in ("id", "case", "caseid", "cid",
1171
+ "pid", "passenger")), variables[0])
1172
+ dialog = CaseVariableDialog(variables, preferred, self)
1173
+ if dialog.exec() != QDialog.Accepted:
1174
+ return
1175
+ log = simulation_to_log(self.net, records, dialog.variable.currentText(),
1176
+ dialog.unit.currentText())
1177
+ self.status.emit(f"Exported {len(log):,} cases from {len(records):,} firings")
1178
+ self.log_generated.emit(log)
1179
+
1180
+ # =====================================================================
1181
+ # Selection and editing
1182
+ # =====================================================================
1183
+ def _selection_changed(self, element) -> None:
1184
+ self._show_element(element)
1185
+ # While editing, selecting something opens its properties. While
1186
+ # simulating, clicks fire transitions: stay on the Simulation tab.
1187
+ if element is not None and self.mode_switch.index() == 0:
1188
+ self.inspector_tabs.set_index(1)
1189
+ if not self.inspector_panel.isVisible():
1190
+ self._toggle_inspector()
1191
+
1192
+ def reveal_element(self, element_id: str) -> None:
1193
+ """Switch to the element's page, select it and centre on it."""
1194
+ element = self.net.find_place(element_id) or self.net.find_transition(element_id) or \
1195
+ next((a for a in self.net.all_arcs() if a.id == element_id), None)
1196
+ if element is None:
1197
+ return
1198
+ page = self.net.page_of(element)
1199
+ if page is not None and page is not self.scene.page:
1200
+ self.scene.show_page(page)
1201
+ self._fill_pages()
1202
+ self.refresh_simulation()
1203
+ item = (self.scene.place_items.get(element_id) or
1204
+ self.scene.transition_items.get(element_id) or
1205
+ self.scene.arc_items.get(element_id))
1206
+ if item is not None:
1207
+ self.scene.clearSelection()
1208
+ item.setSelected(True)
1209
+ # Scroll only if it is out of sight: a jump under the mouse while
1210
+ # drawing (every edit reveals the selection) loses your place.
1211
+ shown = self.view.mapToScene(self.view.viewport().rect()).boundingRect()
1212
+ if not shown.contains(item.sceneBoundingRect()):
1213
+ self.view.centerOn(item)
1214
+
1215
+ def _marking_row_clicked(self, row: int, _column: int) -> None:
1216
+ item = self.marking_table.item(row, 0)
1217
+ if item is not None:
1218
+ tab = self.inspector_tabs.index()
1219
+ self.reveal_element(item.data(Qt.UserRole))
1220
+ self.inspector_tabs.set_index(tab) # stay on the marking
1221
+
1222
+ def _set_rows(self, keys: set[str]) -> None:
1223
+ for key, (caption, widget) in self.rows.items():
1224
+ caption.setVisible(key in keys)
1225
+ widget.setVisible(key in keys)
1226
+
1227
+ def _show_element(self, element) -> None:
1228
+ self.element = element
1229
+ card = self.element_card
1230
+ while self.element_problems.count():
1231
+ item = self.element_problems.takeAt(0)
1232
+ if item.widget():
1233
+ item.widget().hide()
1234
+ item.widget().deleteLater()
1235
+ self.element_info.setText("")
1236
+ self.subpage_button.hide()
1237
+ self.element_hint.setVisible(element is None)
1238
+ if element is None:
1239
+ card.title_label.setText("Nothing selected")
1240
+ self._set_rows(set())
1241
+ return
1242
+ if isinstance(element, Place):
1243
+ card.title_label.setText(f"Place · {element.name}")
1244
+ self.colour_set_box.blockSignals(True)
1245
+ self.colour_set_box.clear()
1246
+ self.colour_set_box.addItems(sorted(self.net.declarations.colour_sets))
1247
+ self.colour_set_box.setCurrentText(element.colour_set_name)
1248
+ self.colour_set_box.blockSignals(False)
1249
+ self.name_edit.setText(element.name)
1250
+ self.initial_edit.setText(element.initial_marking_text)
1251
+ self._set_rows({"name", "colour_set", "initial"})
1252
+ tokens = self.simulator.marking.get(self.net.marking_key(element.id))
1253
+ self.element_info.setText(f"Current marking: {tokens.size():,} token(s)"
1254
+ + (f"\n{tokens}" if tokens.size() else ""))
1255
+ elif isinstance(element, Transition):
1256
+ kind = "Substitution transition" if element.is_substitution else "Transition"
1257
+ card.title_label.setText(f"{kind} · {element.name}")
1258
+ self.name_edit.setText(element.name)
1259
+ self.guard_edit.setText(element.guard_text)
1260
+ self.time_edit.setText(element.time_text)
1261
+ self._set_rows({"name", "guard", "time"})
1262
+ subpage = self._subpage_of(element)
1263
+ if element.is_substitution:
1264
+ self.element_info.setText(
1265
+ f"Stands for the page “{subpage.name if subpage else '?'}”: its port "
1266
+ "places are the socket places around this transition, and its own "
1267
+ "transitions do the work.")
1268
+ self.subpage_button.setVisible(subpage is not None)
1269
+ else:
1270
+ enabled = len(self.simulator.enabled_bindings(element))
1271
+ self.element_info.setText(
1272
+ f"{enabled:,} enabled binding(s) in the current marking.")
1273
+ elif isinstance(element, Arc):
1274
+ place = self.net.find_place(element.place_id)
1275
+ transition = self.net.find_transition(element.transition_id)
1276
+ card.title_label.setText("Arc")
1277
+ self.expression_edit.setText(element.expression_text)
1278
+ self.orientation_box.setCurrentIndex(
1279
+ {"PtoT": 0, "TtoP": 1, "BOTHDIR": 2}.get(element.orientation, 0))
1280
+ self._set_rows({"expression", "orientation"})
1281
+ self.element_info.setText(f"Between place “{place.name if place else '?'}” and "
1282
+ f"transition “{transition.name if transition else '?'}”.")
1283
+ for issue in self.net.errors:
1284
+ if issue.element_id == getattr(element, "id", None):
1285
+ self.element_problems.addWidget(Verdict(issue.field_name, "critical",
1286
+ issue.message))
1287
+
1288
+ def _commit_element(self) -> None:
1289
+ element = self.element
1290
+ if element is None:
1291
+ return
1292
+ before = self._element_state(element)
1293
+ snapshot = self._snapshot()
1294
+ if isinstance(element, Place):
1295
+ element.name = self.name_edit.text().strip() or element.name
1296
+ element.colour_set_name = self.colour_set_box.currentText().strip()
1297
+ element.initial_marking_text = self.initial_edit.text()
1298
+ elif isinstance(element, Transition):
1299
+ element.name = self.name_edit.text().strip() or element.name
1300
+ element.guard_text = self.guard_edit.text()
1301
+ element.time_text = self.time_edit.text()
1302
+ elif isinstance(element, Arc):
1303
+ element.expression_text = self.expression_edit.text()
1304
+ element.orientation = ["PtoT", "TtoP", "BOTHDIR"][self.orientation_box.currentIndex()]
1305
+ if self._element_state(element) != before:
1306
+ self._push_undo(snapshot)
1307
+ self._edited()
1308
+ self._show_element(element)
1309
+
1310
+ @staticmethod
1311
+ def _element_state(element) -> tuple:
1312
+ return tuple(getattr(element, name, None) for name in (
1313
+ "name", "colour_set_name", "initial_marking_text", "guard_text", "time_text",
1314
+ "expression_text", "orientation"))
1315
+
1316
+ def _model_changed(self) -> None:
1317
+ self._edited()
1318
+
1319
+ # -- undo / redo --------------------------------------------------------------------
1320
+ UNDO_LIMIT = 100
1321
+
1322
+ def _snapshot(self) -> dict:
1323
+ """A copy of everything an edit can change (not the simulation)."""
1324
+ block = self.net.declarations
1325
+ page = self.scene.page
1326
+ return {
1327
+ "pages": copy_pages(self.net.pages),
1328
+ "declarations": (list(block.colour_set_sources), list(block.variable_sources),
1329
+ list(block.ml_sources), list(block.globref_sources)),
1330
+ "name": self.net.name,
1331
+ "names_outside": getattr(self.net, "names_outside", False),
1332
+ "fusion": {k: list(v) for k, v in self.net.fusion_sets.items()},
1333
+ "page": self.net.pages.index(page) if page in self.net.pages else 0,
1334
+ }
1335
+
1336
+ def _push_undo(self, snapshot: dict) -> None:
1337
+ self.undo_stack.append(snapshot)
1338
+ del self.undo_stack[:-self.UNDO_LIMIT]
1339
+ self.redo_stack.clear()
1340
+ self._update_undo_buttons()
1341
+
1342
+ def _checkpoint(self) -> None:
1343
+ """Called just before a change: remember how things were."""
1344
+ self._pending_move = None
1345
+ self._push_undo(self._snapshot())
1346
+
1347
+ def _drag_started(self) -> None:
1348
+ self._pending_move = self._snapshot()
1349
+
1350
+ def _moved(self) -> None:
1351
+ if self._pending_move is not None:
1352
+ self._push_undo(self._pending_move)
1353
+ self._pending_move = None
1354
+ self.set_dirty(True)
1355
+
1356
+ def undo(self) -> None:
1357
+ if self.undo_stack:
1358
+ self.redo_stack.append(self._snapshot())
1359
+ self._restore(self.undo_stack.pop())
1360
+ self.status.emit("Undone")
1361
+
1362
+ def redo(self) -> None:
1363
+ if self.redo_stack:
1364
+ self.undo_stack.append(self._snapshot())
1365
+ self._restore(self.redo_stack.pop())
1366
+ self.status.emit("Redone")
1367
+
1368
+ def _restore(self, snapshot: dict) -> None:
1369
+ self.net.pages = copy_pages(snapshot["pages"]) # the stack keeps its own copy
1370
+ block = self.net.declarations
1371
+ (block.colour_set_sources, block.variable_sources, block.ml_sources,
1372
+ block.globref_sources) = (list(x) for x in snapshot["declarations"])
1373
+ self.net.name = snapshot["name"]
1374
+ self.net.names_outside = snapshot.get("names_outside", False)
1375
+ self.names_box.blockSignals(True)
1376
+ self.names_box.setChecked(self.net.names_outside)
1377
+ self.names_box.blockSignals(False)
1378
+ self.net.fusion_sets = {k: list(v) for k, v in snapshot["fusion"].items()}
1379
+ index = min(snapshot["page"], len(self.net.pages) - 1)
1380
+ self.scene.page = self.net.pages[index] if self.net.pages else None
1381
+ self.element = None
1382
+ self._show_element(None)
1383
+ self._edited()
1384
+ self._reload_declarations()
1385
+ self._update_undo_buttons()
1386
+ self.dirty_changed.emit(True) # the sidebar re-reads the (maybe restored) name
1387
+
1388
+ def _update_undo_buttons(self) -> None:
1389
+ self.undo_button.setEnabled(bool(self.undo_stack))
1390
+ self.redo_button.setEnabled(bool(self.redo_stack))
1391
+
1392
+ def _delete_selection(self) -> None:
1393
+ if self.mode_switch.index() == 0:
1394
+ self.scene.delete_selected()
1395
+
1396
+ def _edited(self) -> None:
1397
+ """Something that can change behaviour was edited: recompile and restart."""
1398
+ self._stop_running()
1399
+ self.net.compile()
1400
+ self.simulator = Simulator(self.net)
1401
+ self.scene.rebuild()
1402
+ self.set_dirty(True)
1403
+ self.refresh_all()
1404
+ if self.element is not None:
1405
+ self.reveal_element(self.element.id)
1406
+
1407
+ def set_dirty(self, dirty: bool) -> None:
1408
+ if self.document.dirty != dirty:
1409
+ self.document.dirty = dirty
1410
+ self.dirty_changed.emit(dirty)
1411
+ if dirty:
1412
+ self.edited.emit()
1413
+ self.refresh_title()
1414
+
1415
+ # -- pages --------------------------------------------------------------------------
1416
+ def _subpage_of(self, transition):
1417
+ return next((page for page in self.net.pages
1418
+ if page.id == transition.substitution_subpage), None)
1419
+
1420
+ def _open_subpage(self) -> None:
1421
+ """Show the subpage of the selected substitution transition."""
1422
+ element = getattr(self, "element", None)
1423
+ subpage = self._subpage_of(element) if isinstance(element, Transition) else None
1424
+ if subpage is None:
1425
+ return
1426
+ self._toggle_structure(True)
1427
+ self.page_list.setCurrentRow(self.net.pages.index(subpage))
1428
+
1429
+ def _page_selected(self, row: int) -> None:
1430
+ if 0 <= row < len(self.net.pages):
1431
+ self.scene.show_page(self.net.pages[row])
1432
+ self.refresh_simulation()
1433
+ self.view.zoom_to_fit()
1434
+
1435
+ def _add_page(self) -> None:
1436
+ name, ok = QInputDialog.getText(self, "Add page", "Page name:",
1437
+ text=f"Page {len(self.net.pages) + 1}")
1438
+ if not ok or not name.strip():
1439
+ return
1440
+ self._checkpoint()
1441
+ page = self.net.add_page(name.strip())
1442
+ self.scene.show_page(page)
1443
+ self._edited()
1444
+ self.mode_switch.set_index(0)
1445
+
1446
+ def _rename_page(self) -> None:
1447
+ row = self.page_list.currentRow()
1448
+ if not 0 <= row < len(self.net.pages):
1449
+ return
1450
+ page = self.net.pages[row]
1451
+ name, ok = QInputDialog.getText(self, "Rename page", "Page name:", text=page.name)
1452
+ if ok and name.strip() and name.strip() != page.name:
1453
+ self._checkpoint()
1454
+ page.name = name.strip()
1455
+ self.set_dirty(True)
1456
+ self._fill_pages()
1457
+
1458
+ # -- declarations -------------------------------------------------------------------
1459
+ def _declarations_text(self) -> str:
1460
+ block = self.net.declarations
1461
+ lines: list[str] = [source.strip() for _name, source in block.colour_set_sources]
1462
+ seen: set[str] = set()
1463
+ for _name, _colour_set, source in block.variable_sources:
1464
+ if source not in seen:
1465
+ seen.add(source)
1466
+ lines.append(source.strip())
1467
+ lines += [source.strip() for source in block.ml_sources]
1468
+ lines += [f"globref {name} = {initial};" for name, initial in block.globref_sources]
1469
+ return "\n".join(lines)
1470
+
1471
+ def _reload_declarations(self) -> None:
1472
+ self.declarations_editor.blockSignals(True)
1473
+ self.declarations_editor.setPlainText(self._declarations_text())
1474
+ self.declarations_editor.blockSignals(False)
1475
+ self._declarations_edited()
1476
+
1477
+ def _declarations_edited(self) -> None:
1478
+ changed = self.declarations_editor.toPlainText().strip() != \
1479
+ self._declarations_text().strip()
1480
+ self.apply_button.setEnabled(changed)
1481
+ self.revert_button.setEnabled(changed)
1482
+ errors = [e for e in self.net.errors if e.field_name == "declarations"]
1483
+ t = style.tokens()
1484
+ if changed:
1485
+ self.declarations_status.setText("Edited — press Apply to recompile.")
1486
+ self.declarations_status.setStyleSheet(f"color: {t.text_muted}")
1487
+ elif errors:
1488
+ self.declarations_status.setText("✕ " + errors[0].message)
1489
+ self.declarations_status.setStyleSheet(f"color: {style.STATUS['critical']}")
1490
+ else:
1491
+ count = len(self.net.declarations.colour_sets)
1492
+ self.declarations_status.setText(f"✓ compiled · {count} colour sets available")
1493
+ self.declarations_status.setStyleSheet(f"color: {t.text_muted}")
1494
+
1495
+ def _apply_declarations(self) -> None:
1496
+ """Re-read the text: declarations end with ``;`` at the end of a line."""
1497
+ self._checkpoint()
1498
+ block = self.net.declarations
1499
+ block.colour_set_sources.clear()
1500
+ block.variable_sources.clear()
1501
+ block.ml_sources.clear()
1502
+ block.globref_sources.clear()
1503
+ buffer: list[str] = []
1504
+ for line in self.declarations_editor.toPlainText().splitlines():
1505
+ stripped = line.rstrip()
1506
+ if not stripped.strip():
1507
+ continue
1508
+ buffer.append(stripped)
1509
+ if stripped.endswith(";"):
1510
+ self.net.add_declaration("\n".join(buffer))
1511
+ buffer = []
1512
+ if buffer:
1513
+ self.net.add_declaration("\n".join(buffer))
1514
+ self._edited()
1515
+ self._reload_declarations()
1516
+
1517
+ # =====================================================================
1518
+ # State space
1519
+ # =====================================================================
1520
+ def _calculate_state_space(self) -> None:
1521
+ """Explore in a separate process; the button turns into Cancel meanwhile.
1522
+
1523
+ A process, not a thread: exploration is pure Python, and in a thread
1524
+ it would hold the interpreter lock and make the whole window stutter.
1525
+ """
1526
+ if self.busy: # the button says "Stop"
1527
+ self._space_job.cancel()
1528
+ self._space_cancel_at = time.monotonic()
1529
+ self.space_button.setEnabled(False)
1530
+ self.space_status.setText("Stopping… (keeping what was found so far)")
1531
+ return
1532
+ if self.net.errors:
1533
+ self.space_status.setText("Fix the problems in the Problems tab first: the state "
1534
+ "space of a model that does not compile is meaningless.")
1535
+ return
1536
+ from ...analysis.state_space_process import StateSpaceJob
1537
+ try:
1538
+ job = StateSpaceJob(self.net, self.max_nodes.value())
1539
+ job.start()
1540
+ except Exception as error: # noqa: BLE001
1541
+ self.space_status.setText(f"Could not start the state space tool: {error}")
1542
+ return
1543
+ self._space_job = job
1544
+ self._space_cancel_at = None
1545
+ self.busy = True
1546
+ self._set_space_button(running=True)
1547
+ self.space_status.setText("Starting…")
1548
+ if not hasattr(self, "space_timer"):
1549
+ self.space_timer = QTimer(self)
1550
+ self.space_timer.timeout.connect(self._space_tick)
1551
+ self.space_timer.start(200)
1552
+
1553
+ def _set_space_button(self, running: bool) -> None:
1554
+ button_ = self.space_button
1555
+ button_.setText("■ Stop" if running else "Calculate")
1556
+ button_.setToolTip("Stop exploring and show what was found so far" if running else "")
1557
+ button_.setObjectName("" if running else "primary")
1558
+ button_.style().unpolish(button_)
1559
+ button_.style().polish(button_)
1560
+ button_.setEnabled(True)
1561
+
1562
+ def shutdown(self) -> None:
1563
+ """The page is being closed: stop background work and timers."""
1564
+ self._stop_running()
1565
+ if self.busy and getattr(self, "_space_job", None) is not None:
1566
+ self._space_job.kill()
1567
+ self.busy = False
1568
+ if hasattr(self, "space_timer"):
1569
+ self.space_timer.stop()
1570
+
1571
+ def _space_tick(self) -> None:
1572
+ job = self._space_job
1573
+ outcome = job.poll()
1574
+ if outcome is None:
1575
+ # A stop request normally takes effect within a second or two;
1576
+ # if the worker is stuck in one huge step, end it outright.
1577
+ if self._space_cancel_at is not None and \
1578
+ time.monotonic() - self._space_cancel_at > 8:
1579
+ job.kill()
1580
+ outcome = ("error", "stopped")
1581
+ else:
1582
+ nodes, arcs, phase = job.progress()
1583
+ if self._space_cancel_at is not None and phase == "Exploring":
1584
+ phase = "Stopping"
1585
+ self.space_status.setText(
1586
+ f"{phase}… {nodes:,} nodes, {arcs:,} arcs so far. It runs separately, "
1587
+ "so you can keep working; Stop keeps what was found.")
1588
+ return
1589
+ self.space_timer.stop()
1590
+ self.busy = False
1591
+ self._set_space_button(running=False)
1592
+ kind, payload = outcome
1593
+ if kind == "done":
1594
+ self._show_state_space(payload)
1595
+ elif payload == "stopped":
1596
+ self.space_status.setText("Stopped.")
1597
+ else:
1598
+ self.space_status.setText(f"State space failed: {payload}")
1599
+
1600
+ def _show_state_space(self, results: dict) -> None:
1601
+ nodes_found = results["nodes"]
1602
+ if results["cancelled"]:
1603
+ text = (f"Stopped by you after {nodes_found:,} nodes: the properties below "
1604
+ "describe only the explored part.")
1605
+ elif results["partial"]:
1606
+ text = (f"Partial: the node limit ({nodes_found:,}) was reached, so the "
1607
+ "properties below describe only the explored part. Raise the limit to "
1608
+ "explore further.")
1609
+ else:
1610
+ text = "Full state space: the properties below are proofs about every reachable " \
1611
+ "marking."
1612
+ self.space_status.setText(text)
1613
+ card = self.space_results
1614
+ card.clear()
1615
+ card.setVisible(True)
1616
+ dead = results["dead"]
1617
+ home = results["home"]
1618
+ partial = results["partial"]
1619
+ card.add(hbox(StatTile("Nodes", f"{nodes_found:,}",
1620
+ f"{results['unexplored']:,} not yet explored" if partial
1621
+ else "full"),
1622
+ StatTile("Arcs", f"{results['arcs']:,}"),
1623
+ StatTile("SCCs", f"{results['sccs']:,}")))
1624
+
1625
+ def listed(nodes: list[int]) -> str:
1626
+ shown = ", ".join(str(n) for n in nodes[:12])
1627
+ return shown + (f" … ({len(nodes):,} in total)" if len(nodes) > 12 else "")
1628
+
1629
+ # In a partial state space only some statements are meaningful: a
1630
+ # dead marking found is real, but "no home marking" or "transition
1631
+ # never occurs" may just mean exploration had not got there yet.
1632
+ where = " among the explored nodes" if partial else ""
1633
+ if dead:
1634
+ card.add(Verdict(f"{len(dead):,} dead marking(s){where}", "warning",
1635
+ f"Nodes {listed(dead)}. A dead marking has no enabled binding; "
1636
+ "for a model that should terminate these are its end states."))
1637
+ else:
1638
+ card.add(Verdict(f"No dead markings{where}", "good" if not partial else "unknown",
1639
+ "Something can always happen." if not partial else
1640
+ "Unexplored nodes are not counted: they may still hide one."))
1641
+ if home is None:
1642
+ card.add(Verdict("Home markings: not determined", "unknown",
1643
+ "Needs the full state space."))
1644
+ elif not home:
1645
+ card.add(Verdict("No home marking", "warning",
1646
+ "No marking can be reached from every other marking."))
1647
+ elif len(home) == nodes_found:
1648
+ card.add(Verdict("Every marking is a home marking", "good",
1649
+ "The initial marking can always be reached again."))
1650
+ else:
1651
+ card.add(Verdict(f"{len(home):,} home marking(s)", "good", f"Nodes {listed(home)}."))
1652
+ dead_transitions = results["dead_transitions"]
1653
+ names = ", ".join(dead_transitions)
1654
+ if partial:
1655
+ card.add(Verdict("Transitions not seen yet" if dead_transitions
1656
+ else "Every transition occurred", "unknown" if dead_transitions
1657
+ else "good",
1658
+ f"{names} did not occur in the explored part (not proof that "
1659
+ "they are dead)." if dead_transitions else
1660
+ "So none of them is dead."))
1661
+ else:
1662
+ card.add(Verdict("Dead transitions" if dead_transitions else "No dead transitions",
1663
+ "critical" if dead_transitions else "good",
1664
+ names if dead_transitions
1665
+ else "Every transition occurs somewhere in the state space."))
1666
+ live = results["live"]
1667
+ if live is None:
1668
+ card.add(Verdict("Live transitions: not determined", "unknown",
1669
+ "Needs the full state space."))
1670
+ else:
1671
+ card.add(Verdict(f"{len(live)} live transition(s)", "good" if live else "unknown",
1672
+ ", ".join(live) if live else
1673
+ "No transition can always fire again (usual for a model that "
1674
+ "ends)."))
1675
+
1676
+ card.add(label("BOUNDS", "sectionLabel"))
1677
+ bounds = results["bounds"]
1678
+ multiset = results["multiset"]
1679
+ table = QTableWidget(len(bounds), 3)
1680
+ table.setHorizontalHeaderLabels(["Place", "Lower", "Upper"])
1681
+ table.verticalHeader().hide()
1682
+ table.setEditTriggers(QAbstractItemView.NoEditTriggers)
1683
+ table.setShowGrid(False)
1684
+ table.setAlternatingRowColors(True)
1685
+ for row, (place_id, place_name, lower, upper) in enumerate(bounds):
1686
+ name = QTableWidgetItem(place_name)
1687
+ tip = multiset.get(place_id, "")
1688
+ name.setToolTip(f"Upper multiset bound:\n"
1689
+ f"{tip if len(tip) < 1500 else tip[:1500] + ' …'}")
1690
+ table.setItem(row, 0, name)
1691
+ for column, value in ((1, lower), (2, upper)):
1692
+ cell = QTableWidgetItem(f"{value:,}")
1693
+ cell.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
1694
+ table.setItem(row, column, cell)
1695
+ table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
1696
+ table.setMinimumHeight(min(360, 34 + 28 * len(bounds)))
1697
+ card.add(table)
1698
+
1699
+ if results["graph"] is not None:
1700
+ card.add(label("GRAPH", "sectionLabel"))
1701
+ view = GraphView()
1702
+ view.setMinimumHeight(320)
1703
+ nodes, edges = state_space_specs(*results["graph"])
1704
+ view.graph.populate(nodes, edges, layer_gap=70)
1705
+ view.fit()
1706
+ card.add(view)
1707
+
1708
+ card.add(label("REPORT", "sectionLabel"))
1709
+ report = QPlainTextEdit(results["report"])
1710
+ report.setReadOnly(True)
1711
+ report.setFont(theme.mono_font(11))
1712
+ report.setLineWrapMode(QPlainTextEdit.NoWrap)
1713
+ report.setMinimumHeight(260)
1714
+ card.add(report)
1715
+ self.status.emit(f"State space: {nodes_found:,} nodes, {results['arcs']:,} arcs")
1716
+
1717
+ # =====================================================================
1718
+ # Files
1719
+ # =====================================================================
1720
+ def save(self) -> bool:
1721
+ if not self.document.path:
1722
+ return self.export()
1723
+ return self._write(Path(self.document.path))
1724
+
1725
+ def export(self) -> bool:
1726
+ suggested = self.document.path or suggested_path(f"{self.net.name}.cpn")
1727
+ path, _ = QFileDialog.getSaveFileName(self, "Save CPN model", suggested,
1728
+ "CPN Tools model (*.cpn)")
1729
+ if not path:
1730
+ return False
1731
+ if not path.lower().endswith(".cpn"):
1732
+ path += ".cpn"
1733
+ self._take_file_name(Path(path))
1734
+ return self._write(Path(path))
1735
+
1736
+ def _take_file_name(self, path: Path) -> None:
1737
+ """Save As names the net after its new file ("Untitled 1" → "order").
1738
+
1739
+ Opening a .cpn or .pnml file names the net after the file too, so the
1740
+ name you see stays the same when the file is opened again later. The
1741
+ name is set before writing, so the file itself carries it as well.
1742
+ """
1743
+ if self.net.name != path.stem:
1744
+ self.net.name = path.stem
1745
+ self.refresh_title()
1746
+
1747
+ def _write(self, path: Path, quiet: bool = False) -> bool:
1748
+ """Save to ``path``. ``quiet``: an autosave (no status message)."""
1749
+ try:
1750
+ atomic_write(path, lambda temporary: write_cpn(self.net, temporary))
1751
+ except Exception as error: # noqa: BLE001
1752
+ return self._save_failed(error, quiet)
1753
+ self._written(path, quiet)
1754
+ return True
1755
+
1756
+ def _save_failed(self, error: Exception, quiet: bool) -> bool:
1757
+ """Report a failed save: a dialog, or (an autosave) just remember why --
1758
+ the window says so once, rather than a dialog after every edit."""
1759
+ self.save_error = str(error)
1760
+ if not quiet:
1761
+ QMessageBox.critical(self, "Could not save", str(error))
1762
+ return False
1763
+
1764
+ def _written(self, path: Path, quiet: bool) -> None:
1765
+ self.document.path = str(path)
1766
+ self.document.missing = False
1767
+ self.set_dirty(False)
1768
+ if not quiet:
1769
+ self.status.emit(f"Saved {path.name}")
1770
+ self.saved.emit()
1771
+
1772
+ def autosave(self) -> bool:
1773
+ """Save to the document's file without a word (the window decides when)."""
1774
+ if not self.document.path or not self.document.dirty:
1775
+ return True
1776
+ return self._write(Path(self.document.path), quiet=True)
1777
+
1778
+ def _export_image(self) -> None:
1779
+ path, _ = QFileDialog.getSaveFileName(self, "Export image",
1780
+ suggested_path(f"{self.net.name}.png"),
1781
+ "PNG (*.png);;SVG (*.svg)")
1782
+ if not path:
1783
+ return
1784
+ from PySide6.QtCore import QRectF
1785
+ from PySide6.QtGui import QImage
1786
+ rect = self.scene.itemsBoundingRect().adjusted(-20, -20, 20, 20)
1787
+ selected = self.scene.selectedItems()
1788
+ self.scene.clearSelection()
1789
+ if path.lower().endswith(".svg"):
1790
+ from PySide6.QtSvg import QSvgGenerator
1791
+ generator = QSvgGenerator()
1792
+ generator.setFileName(path)
1793
+ generator.setSize(rect.size().toSize())
1794
+ generator.setViewBox(QRectF(0, 0, rect.width(), rect.height()))
1795
+ painter = QPainter(generator)
1796
+ self.scene.render(painter, QRectF(0, 0, rect.width(), rect.height()), rect)
1797
+ painter.end()
1798
+ else:
1799
+ image = QImage(int(rect.width() * 2), int(rect.height() * 2), QImage.Format_ARGB32)
1800
+ image.fill(theme.palette().canvas)
1801
+ painter = QPainter(image)
1802
+ painter.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing)
1803
+ self.scene.render(painter, QRectF(image.rect()), rect)
1804
+ painter.end()
1805
+ image.save(path)
1806
+ for item in selected:
1807
+ item.setSelected(True)
1808
+ self.status.emit(f"Exported {Path(path).name}")
1809
+
1810
+
1811
+ # ---------------------------------------------------------------------------
1812
+ # Helpers that do not need the page
1813
+ # ---------------------------------------------------------------------------
1814
+ def copy_pages(pages: list) -> list:
1815
+ """Deep copy of a model's pages, for undo.
1816
+
1817
+ The parsed inscriptions (rebuilt by every compile) and the XML elements
1818
+ kept from the source file (never modified) are shared, not copied, which
1819
+ keeps a snapshot cheap.
1820
+ """
1821
+ import copy
1822
+ memo: dict[int, object] = {}
1823
+ for page in pages:
1824
+ if page.source_element is not None:
1825
+ memo[id(page.source_element)] = page.source_element
1826
+ for element in (*page.places, *page.transitions, *page.arcs):
1827
+ for name in ("source_element", "initial_marking_ast", "guard_ast", "time_ast",
1828
+ "expression_ast"):
1829
+ value = getattr(element, name, None)
1830
+ if value is not None:
1831
+ memo[id(value)] = value
1832
+ return copy.deepcopy(pages, memo)
1833
+
1834
+
1835
+ def state_space_specs(nodes: list, arcs: list) -> tuple[list[NodeSpec], list[EdgeSpec]]:
1836
+ """Graph drawing for a small state space.
1837
+
1838
+ ``nodes`` holds ``(index, description, is_dead)`` and ``arcs`` holds
1839
+ ``(source, target, binding description)``, as produced by
1840
+ :func:`openprocess.analysis.state_space_process.summarise`.
1841
+ """
1842
+ t = style.tokens()
1843
+ specs = [NodeSpec(str(index), "state", text=str(index), tooltip=description,
1844
+ emphasis=index == 0, fill=t.accent_soft if index == 0 else None,
1845
+ stroke=style.STATUS["critical"] if dead else None)
1846
+ for index, description, dead in nodes]
1847
+ labels: dict[tuple[int, int], list[str]] = {}
1848
+ for source, target, binding in arcs:
1849
+ labels.setdefault((source, target), []).append(binding)
1850
+ edges = [EdgeSpec(str(s), str(d), width=1.2,
1851
+ text=names[0].split(" <")[0] + (f" +{len(names) - 1}" if len(names) > 1
1852
+ else ""),
1853
+ tooltip="\n".join(names))
1854
+ for (s, d), names in labels.items()]
1855
+ return specs, edges
1856
+
1857
+
1858
+ from ...sim.export import simulation_to_log # noqa: E402 - the export lives with the engine now