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