openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,4031 @@
1
+ """OpenProcess Studio: the main window of the process-mining workspace.
2
+
3
+ Layout
4
+ ------
5
+ ::
6
+
7
+ +------------+-------------------------------------------------+
8
+ | OpenProcess | Page header (title, subtitle, actions) |
9
+ | | [Overview | Variants | Cases | ...] |
10
+ | EVENT LOGS | |
11
+ | ▤ log A | page content |
12
+ | MODELS | |
13
+ | ◇ α(L) | |
14
+ | COLOURED | |
15
+ | NETS | |
16
+ | ◈ plane | |
17
+ | Open… | |
18
+ +------------+-------------------------------------------------+
19
+
20
+ The sidebar lists every open *document*: an event log, a Petri net, or a
21
+ coloured Petri net (CPN Tools model). Selecting one shows its page.
22
+ Discovering a model from a log adds the model to the sidebar, and exporting
23
+ a CPN simulation adds its event log, so a whole analysis lives side by side
24
+ in one window.
25
+
26
+ With a folder open (File ▸ Open Folder…, e.g. "Week 2"; internally a
27
+ :class:`.workspace.Workspace`), the folder and the sidebar always match:
28
+
29
+ * the sidebar lists every log and net in the folder, open or not (lighter),
30
+ with its subfolders (the Folder view) or grouped by kind;
31
+ * folder → app: files added, renamed or deleted in Finder show up by
32
+ themselves, an open file changed on disk is reloaded, and one deleted on
33
+ disk is marked as missing;
34
+ * app → folder: new nets and logs are files in the folder from the start,
35
+ edits are saved as you go (autosave), and files can be moved between
36
+ subfolders, renamed or moved to the Bin from the sidebar;
37
+ * files opened from elsewhere can be copied or moved into the folder.
38
+
39
+ A subfolder with a ``question`` file is an *exercise*: clicking it swaps the
40
+ window for OpenProcess Learn (see :mod:`openprocess.gui.learn.mode`), a worksheet with answer
41
+ boxes beside the net, log or transition system the exercise gives. *Exit*
42
+ comes back to the folder exactly as it was.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import os
49
+ import shutil
50
+ import subprocess
51
+ import sys
52
+ import time
53
+ from dataclasses import asdict
54
+ from pathlib import Path
55
+
56
+ from PySide6.QtCore import (
57
+ QEvent, QFile, QFileSystemWatcher, QRect, QRectF, QSettings, QSize, Qt, QTimer, QUrl, Signal,
58
+ )
59
+ from PySide6.QtGui import (
60
+ QAction, QColor, QDesktopServices, QIcon, QKeySequence, QPainter, QPen, QPixmap, QShortcut,
61
+ )
62
+ from PySide6.QtWidgets import (
63
+ QAbstractItemView, QApplication, QComboBox, QDialog, QDialogButtonBox, QFileDialog,
64
+ QFormLayout, QFrame, QGridLayout, QHBoxLayout, QInputDialog, QLabel, QMainWindow, QMenu, QMessageBox,
65
+ QSizePolicy, QSplitter, QStackedWidget, QStyle, QStyledItemDelegate,
66
+ QToolButton, QTreeWidgetItem, QTreeWidgetItemIterator, QVBoxLayout, QWidget,
67
+ )
68
+
69
+ from ...mining.csv_import import ColumnMapping, guess_mapping, read_csv, sniff
70
+ from ...mining.log import EventLog, parse_simple_log
71
+ from ...mining.pnml import read_pnml, write_pnml
72
+ from ...mining.xes import read_xes, write_xes
73
+ from .. import theme
74
+ from . import style
75
+ from ..canvas import NetScene, NetView
76
+ from .compare_page import ComparePage
77
+ from .cpn_page import CpnPage
78
+ from .petri_page import PetriNetPage
79
+ from ..flow.page import WorkflowPage
80
+ from ..flow.templates import TEMPLATES, first_log
81
+ from .documents import (WorkflowDocument,
82
+ ComparisonDocument, CpnDocument, LogDocument, ModelDocument, TransitionSystemDocument,
83
+ )
84
+ from .file_dialogs import IMPORT_CHOICES, ImportDialog, SettingsDialog, ask_about_clash
85
+ from .graph_view import GraphView, ZoomControls
86
+ from .log_editor import EXAMPLES, NotationDialog # noqa: F401 - NotationDialog: older imports
87
+ from .log_page import LogPage
88
+ from .model_page import ModelPage
89
+ from .sidebar import FILE_ROLE, FOLDER_ROLE, SidebarTree
90
+ from .widgets import (
91
+ Card, ElidedLabel, NoticeBar, SegmentedControl, button, dialog_folder, hbox, label,
92
+ round_menus, scroll, set_dialog_folder, shortcut_text, vbox,
93
+ )
94
+ from .workspace import (
95
+ FORBIDDEN_CHARACTERS, Workspace, WorkspaceFolder, display_name,
96
+ exercise_files, file_kind, file_stem, file_suffix,
97
+ made_by_openprocess, safe_file_name, unique_path,
98
+ )
99
+ from .workers import run_in_background
100
+
101
+ APPLICATION_NAME = "OpenProcess Studio"
102
+
103
+ #: Sidebar item data: the iCloud placeholder of a file that is only in iCloud.
104
+ CLOUD_ROLE = Qt.UserRole + 3
105
+ #: Sidebar item data: the folder is an exercise (it has a question file).
106
+ EXERCISE_ROLE = Qt.UserRole + 4
107
+
108
+ #: How long after the last edit a net in the folder is saved (milliseconds).
109
+ AUTOSAVE_DELAY = 1000
110
+
111
+ #: Name of the Bin on this system (macOS says Bin in British English, as here).
112
+ BIN = "Recycle Bin" if sys.platform == "win32" else "Bin"
113
+
114
+
115
+ def _file_suffix(path: Path) -> str:
116
+ """The whole extension, so ``log.xes.gz`` gives ``.xes.gz`` (not just ``.gz``)."""
117
+ return file_suffix(path.name)
118
+
119
+
120
+ def _file_stem(path: Path) -> str:
121
+ """The file name without :func:`_file_suffix`: what a document opened from it is called."""
122
+ return file_stem(path.name)
123
+
124
+
125
+ def _same_file(a: Path, b: Path) -> bool:
126
+ """True when two paths name one file (e.g. a case-only change on macOS)."""
127
+ try:
128
+ return a.samefile(b)
129
+ except OSError:
130
+ return False
131
+
132
+
133
+ def _signature(path: str | Path) -> tuple[int, int] | None:
134
+ """(modification time, size) of a file, or None when it is not there."""
135
+ try:
136
+ status = os.stat(path)
137
+ except OSError:
138
+ return None
139
+ return status.st_mtime_ns, status.st_size
140
+
141
+
142
+ def move_to_trash(path: str | Path) -> bool:
143
+ """Move a file or folder to the Bin (recoverable). Tests replace this."""
144
+ return bool(QFile.moveToTrash(str(path)))
145
+
146
+
147
+ def reveal_label() -> str:
148
+ return {"darwin": "Show in Finder", "win32": "Show in Explorer"}.get(
149
+ sys.platform, "Open Containing Folder")
150
+
151
+
152
+
153
+ def app_icon() -> QIcon:
154
+ """The OpenProcess logo (Dock, window and About box)."""
155
+ return QIcon(str(Path(__file__).resolve().parents[1] / "resources" / "openprocess-icon.png"))
156
+
157
+
158
+ def _icon(kind: str) -> QIcon:
159
+ """Tiny painted icons, so the app ships no image files.
160
+
161
+ Drawn twice: in colour, and in the selected row's text colour, which Qt
162
+ uses for the selected row (a blue icon on the blue highlight vanished).
163
+ """
164
+ icon = QIcon(_icon_pixmap(kind, None))
165
+ icon.addPixmap(_icon_pixmap(kind, QColor(style.tokens().accent_text)), QIcon.Selected)
166
+ return icon
167
+
168
+
169
+ def _faded_icon(kind: str) -> QIcon:
170
+ """The kind's icon at reduced opacity: a workspace file that is not open yet."""
171
+ source = _icon_pixmap(kind, None)
172
+ faded = QPixmap(source.size())
173
+ faded.fill(Qt.transparent)
174
+ painter = QPainter(faded)
175
+ painter.setOpacity(0.45)
176
+ painter.drawPixmap(0, 0, source)
177
+ painter.end()
178
+ icon = QIcon(faded)
179
+ icon.addPixmap(_icon_pixmap(kind, QColor(style.tokens().accent_text)), QIcon.Selected)
180
+ return icon
181
+
182
+
183
+ def _icon_pixmap(kind: str, ink: QColor | None) -> QPixmap:
184
+ pixmap = QPixmap(32, 32)
185
+ pixmap.fill(Qt.transparent)
186
+ painter = QPainter(pixmap)
187
+ painter.setRenderHint(QPainter.Antialiasing)
188
+ colour = ink if ink is not None else QColor(
189
+ {"log": style.categorical(0), "cpn": style.categorical(2)}.get(kind, style.categorical(6)))
190
+ pen = QPen(colour, 3)
191
+ pen.setCapStyle(Qt.RoundCap)
192
+ painter.setPen(pen)
193
+ if kind == "folder":
194
+ painter.setPen(Qt.NoPen)
195
+ painter.setBrush(ink if ink is not None else QColor(style.tokens().accent))
196
+ tab = QRectF(3, 7, 11, 6)
197
+ painter.drawRoundedRect(tab, 2, 2)
198
+ painter.drawRoundedRect(QRectF(3, 10, 26, 16), 2.5, 2.5)
199
+ elif kind == "compare":
200
+ painter.setPen(Qt.NoPen)
201
+ for index, (x, height) in enumerate(((5, 12), (12, 20), (19, 8), (26, 16))):
202
+ painter.setBrush(ink if ink is not None else QColor(style.categorical(index % 2)))
203
+ painter.drawRoundedRect(QRectF(x - 2, 27 - height, 5, height), 1.5, 1.5)
204
+ elif kind == "cpn":
205
+ painter.drawEllipse(QRectF(3, 9, 13, 13))
206
+ painter.drawRoundedRect(QRectF(19, 8, 10, 15), 2, 2)
207
+ painter.setBrush(colour)
208
+ painter.drawEllipse(QRectF(8, 14, 3, 3))
209
+ elif kind == "log":
210
+ for y in (9, 16, 23):
211
+ painter.drawLine(7, y, 25, y)
212
+ painter.setBrush(colour)
213
+ painter.drawEllipse(4, 7, 1, 1)
214
+ elif kind == "ts":
215
+ # Two states and an arrow between them.
216
+ painter.drawEllipse(QRectF(3, 11, 9, 9))
217
+ painter.drawEllipse(QRectF(20, 11, 9, 9))
218
+ painter.drawLine(13, 15, 18, 15)
219
+ painter.drawLine(15, 12, 18, 15)
220
+ painter.drawLine(15, 18, 18, 15)
221
+ elif kind == "workflow":
222
+ # Two boxes joined by a wire.
223
+ painter.drawRoundedRect(QRectF(3, 6, 11, 9), 2, 2)
224
+ painter.drawRoundedRect(QRectF(18, 17, 11, 9), 2, 2)
225
+ painter.drawLine(14, 10, 18, 21)
226
+ elif kind == "exercise":
227
+ # A sheet with a question mark.
228
+ painter.drawRoundedRect(QRectF(6, 4, 20, 24), 3, 3)
229
+ font = painter.font()
230
+ font.setBold(True)
231
+ font.setPixelSize(17)
232
+ painter.setFont(font)
233
+ painter.drawText(QRectF(6, 4, 20, 24), Qt.AlignCenter, "?")
234
+ else:
235
+ painter.drawEllipse(QRectF(4, 10, 11, 11))
236
+ painter.drawRect(QRectF(19, 9, 9, 13))
237
+ painter.end()
238
+ return pixmap
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Sidebar: a remove button that appears on hover
243
+ # ---------------------------------------------------------------------------
244
+ class SidebarDelegate(QStyledItemDelegate):
245
+ """Draws a small ✕ on the hovered (or selected) document row.
246
+
247
+ Clicking it asks the window to remove that document from the workspace --
248
+ the same gesture as closing a tab in Safari or a document in Xcode's
249
+ sidebar. Section headers ("EVENT LOGS", "MODELS") never get one.
250
+ """
251
+
252
+ remove_clicked = Signal(int) # document id
253
+ BUTTON = 18
254
+
255
+ def _button_rect(self, row: QRect) -> QRect:
256
+ size = self.BUTTON
257
+ return QRect(row.right() - size - 8, row.center().y() - size // 2 + 1, size, size)
258
+
259
+ def paint(self, painter: QPainter, option, index) -> None:
260
+ super().paint(painter, option, index)
261
+ if index.data(Qt.UserRole) is None:
262
+ return
263
+ hovered = bool(option.state & QStyle.State_MouseOver)
264
+ selected = bool(option.state & QStyle.State_Selected)
265
+ if not (hovered or selected):
266
+ return
267
+ t = style.tokens()
268
+ rect = QRectF(self._button_rect(option.rect))
269
+ painter.save()
270
+ painter.setRenderHint(QPainter.Antialiasing)
271
+ ink = QColor(t.accent_text if selected else t.text_secondary)
272
+ backdrop = QColor(ink)
273
+ backdrop.setAlphaF(0.18)
274
+ painter.setPen(Qt.NoPen)
275
+ painter.setBrush(backdrop)
276
+ painter.drawEllipse(rect)
277
+ pen = QPen(ink, 1.6)
278
+ pen.setCapStyle(Qt.RoundCap)
279
+ painter.setPen(pen)
280
+ inset = rect.adjusted(5.5, 5.5, -5.5, -5.5)
281
+ painter.drawLine(inset.topLeft(), inset.bottomRight())
282
+ painter.drawLine(inset.topRight(), inset.bottomLeft())
283
+ painter.restore()
284
+
285
+ def editorEvent(self, event, model, option, index) -> bool: # noqa: N802
286
+ if index.data(Qt.UserRole) is not None and event.type() in (
287
+ QEvent.MouseButtonPress, QEvent.MouseButtonRelease):
288
+ if self._button_rect(option.rect).contains(event.position().toPoint()):
289
+ if event.type() == QEvent.MouseButtonRelease:
290
+ self.remove_clicked.emit(int(index.data(Qt.UserRole)))
291
+ return True # swallow the press too, so the row is not selected
292
+ return super().editorEvent(event, model, option, index)
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Dialogs
297
+ # ---------------------------------------------------------------------------
298
+ class CompareDialog(QDialog):
299
+ """Pick the logs to compare (at least two)."""
300
+
301
+ def __init__(self, logs: list, preselected: list, parent=None) -> None:
302
+ super().__init__(parent)
303
+ from PySide6.QtWidgets import QListWidget, QListWidgetItem
304
+ self.setWindowTitle("Compare logs")
305
+ self.logs = logs
306
+ layout = QVBoxLayout(self)
307
+ layout.setSpacing(10)
308
+ layout.addWidget(label("Choose two or more event logs to show side by side.",
309
+ "muted", wrap=True))
310
+ self.list = QListWidget()
311
+ chosen = {d.id for d in preselected}
312
+ for log in logs:
313
+ item = QListWidgetItem(log.name)
314
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
315
+ item.setCheckState(Qt.Checked if log.id in chosen or len(logs) == 2
316
+ else Qt.Unchecked)
317
+ self.list.addItem(item)
318
+ self.list.itemChanged.connect(lambda _item: self._validate())
319
+ layout.addWidget(self.list)
320
+ self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
321
+ self.buttons.button(QDialogButtonBox.Ok).setText("Compare")
322
+ self.buttons.accepted.connect(self.accept)
323
+ self.buttons.rejected.connect(self.reject)
324
+ layout.addWidget(self.buttons)
325
+ self._validate()
326
+
327
+ def chosen(self) -> list:
328
+ return [log for index, log in enumerate(self.logs)
329
+ if self.list.item(index).checkState() == Qt.Checked]
330
+
331
+ def _validate(self) -> None:
332
+ self.buttons.button(QDialogButtonBox.Ok).setEnabled(len(self.chosen()) >= 2)
333
+
334
+
335
+ class CsvDialog(QDialog):
336
+ """Confirm which CSV columns hold case id, activity, timestamp, ..."""
337
+
338
+ def __init__(self, path: str, parent=None) -> None:
339
+ super().__init__(parent)
340
+ self.setWindowTitle(f"Import {Path(path).name}")
341
+ _, header = sniff(path)
342
+ guess = guess_mapping(header) or ColumnMapping(header[0] if header else "",
343
+ header[1] if len(header) > 1 else "")
344
+ self.boxes: dict[str, QComboBox] = {}
345
+ form = QFormLayout()
346
+ for role, title, optional in (("case", "Case id", False), ("activity", "Activity", False),
347
+ ("timestamp", "Timestamp", True),
348
+ ("resource", "Resource", True),
349
+ ("lifecycle", "Lifecycle", True)):
350
+ box = QComboBox()
351
+ if optional:
352
+ box.addItem("— none —", None)
353
+ for column in header:
354
+ box.addItem(column, column)
355
+ current = getattr(guess, role)
356
+ if current is not None:
357
+ box.setCurrentIndex(box.findData(current))
358
+ self.boxes[role] = box
359
+ form.addRow(title, box)
360
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
361
+ buttons.accepted.connect(self.accept)
362
+ buttons.rejected.connect(self.reject)
363
+ layout = QVBoxLayout(self)
364
+ layout.addWidget(label("Choose the columns that identify cases and activities. "
365
+ "Other columns become event attributes.", "muted", wrap=True))
366
+ layout.addLayout(form)
367
+ layout.addWidget(buttons)
368
+
369
+ def mapping(self) -> ColumnMapping:
370
+ return ColumnMapping(**{role: box.currentData() for role, box in self.boxes.items()})
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # Main window
375
+ # ---------------------------------------------------------------------------
376
+ class StudioWindow(QMainWindow):
377
+ """The Studio window.
378
+
379
+ ``persist=True`` (what the real app uses) remembers the files that were
380
+ open and reopens them on the next launch, and keeps an *Open Recent*
381
+ list. Tests pass ``persist=False`` so they never touch your settings.
382
+ """
383
+
384
+ RECENT_LIMIT = 12
385
+
386
+ def __init__(self, persist: bool = False) -> None:
387
+ super().__init__()
388
+ round_menus(QApplication.instance())
389
+ self.setWindowTitle(APPLICATION_NAME)
390
+ self.setAcceptDrops(True)
391
+ self.documents: list[LogDocument | ModelDocument] = []
392
+ self.pages: dict[int, QWidget] = {}
393
+ self.items: dict[int, QTreeWidgetItem] = {}
394
+ self.holders: dict[int, QWidget] = {}
395
+ self.persist = persist
396
+ self.settings = QSettings("OpenProcess", "Studio")
397
+ if not self.settings.allKeys(): # first start since CPNpy: keep its settings
398
+ old = QSettings("CPNpy", "Studio")
399
+ for key in old.allKeys():
400
+ self.settings.setValue(key, old.value(key))
401
+ #: How each file-backed document was opened (CSV column mapping etc.),
402
+ #: keyed by document id, so the session can reopen it the same way.
403
+ self.open_options: dict[int, dict] = {}
404
+ self._restoring = False
405
+ #: The open folder (None: just loose files, as before).
406
+ self.workspace: Workspace | None = None
407
+ #: Its subfolders and files, as last listed (see _rescan_workspace).
408
+ self._tree: WorkspaceFolder | None = None
409
+ self._tree_signature: tuple = ()
410
+ #: In a folder: the sidebar shows its subfolders ("folder") or groups by kind ("kind").
411
+ self.view_mode = "folder"
412
+ #: Expanded subfolders (relative paths), remembered in the folder's .openprocess.
413
+ self._expanded: set[str] = set()
414
+ #: Sidebar rows by what they stand for (rebuilt by _rebuild_sidebar).
415
+ self.placeholders: dict[str, QTreeWidgetItem] = {} # files not open, by path key
416
+ self.folder_items: dict[str, QTreeWidgetItem] = {} # subfolders, by relative path
417
+ #: Reopening a folder: files still loading, and the one that was selected.
418
+ self._restore_paths: dict[str, dict] = {}
419
+ self._restore_selected: str | None = None
420
+ #: When a click last opened a file of the folder (see _on_double_click).
421
+ self._clicked_open_at = 0.0
422
+ #: Files being opened by a click (shown with "…"), and iCloud downloads to open.
423
+ self._opening: set[str] = set()
424
+ self._pending_downloads: set[str] = set()
425
+ # Files added, removed or renamed in the folder (in Finder, say) show
426
+ # up by themselves; the timer folds a burst of changes into one rescan.
427
+ self.watcher = QFileSystemWatcher(self)
428
+ self._rescan_timer = QTimer(self)
429
+ self._rescan_timer.setSingleShot(True)
430
+ # macOS reports a change about half a second after it happens; this
431
+ # only folds a burst of events (a whole folder copied in) into one.
432
+ self._rescan_timer.setInterval(100)
433
+ self._rescan_timer.timeout.connect(self._rescan_workspace)
434
+ self.watcher.directoryChanged.connect(lambda _path: self._rescan_timer.start())
435
+ # Open files are watched too: one changed on disk is reloaded.
436
+ self._changed_files: set[str] = set()
437
+ self._file_timer = QTimer(self)
438
+ self._file_timer.setSingleShot(True)
439
+ self._file_timer.setInterval(150) # also: how long a file must stay unchanged
440
+ self._file_timer.timeout.connect(self._check_changed_files)
441
+ self.watcher.fileChanged.connect(self._file_changed)
442
+ #: (mtime, size) of files the app wrote itself, so its own saves are not
443
+ #: mistaken for changes made elsewhere; and of changes not settled yet.
444
+ self._own_writes: dict[str, tuple] = {}
445
+ self._unsettled: dict[str, tuple] = {}
446
+ self._gone_checks: dict[str, int] = {}
447
+ self._reload_attempts: dict[str, int] = {}
448
+ #: Each open file's identity on disk, so a file moved or renamed in
449
+ #: Finder is recognised in its new place (see _relocate).
450
+ self._identities: dict[int, tuple] = {}
451
+ #: Documents with a "changed on disk" question open: not autosaved meanwhile.
452
+ self._conflicts: set[int] = set()
453
+ # One Snap to grid setting for every editor, kept between launches.
454
+ NetScene.snap_to_grid = self._setting("editor/snap_to_grid", False)
455
+ #: Autosave: one timer per document, restarted on each edit.
456
+ self.autosave_enabled = self._setting("files/autosave", True)
457
+ self._autosave_timers: dict[int, QTimer] = {}
458
+ #: Documents whose autosave failed (read-only folder, full disk): saved
459
+ #: with ⌘S again, with the "edited" dot, until a save works.
460
+ self._autosave_failed: set[int] = set()
461
+ #: .cpn documents whose file OpenProcess wrote (only those are autosaved).
462
+ self._openprocess_files: set[int] = set()
463
+ #: Files the app created by itself this session (a new net's "Untitled 1.pnml").
464
+ self._created: set[str] = set()
465
+ #: The bytes of each net's file as it was opened (File ▸ Revert to Saved).
466
+ self._originals: dict[int, bytes] = {}
467
+ #: Each page's notice bar ("changed on disk", "missing").
468
+ self.banners: dict[int, NoticeBar] = {}
469
+ #: Counts folder switches (opening or closing one), so a log still
470
+ #: loading from the folder that was left is not added to the new one.
471
+ self._folder_switches = 0
472
+ #: Software updates: a check is running; the installer to run on quitting.
473
+ self._checking_updates = False
474
+ self._installer: list[str] | None = None
475
+ #: The tab you were last on, per kind of page. Selecting another
476
+ #: document opens the same tab, so e.g. flicking between logs in the
477
+ #: dotted chart keeps showing dotted charts.
478
+ self.remembered = {"log_tab": 0, "model_inspector": 0, "model_view": 0}
479
+ saved = self._setting("workflows/layout", "")
480
+ if saved:
481
+ try:
482
+ WorkflowPage.LAYOUT = {**WorkflowPage.LAYOUT, **json.loads(saved)}
483
+ except (ValueError, TypeError):
484
+ pass
485
+ #: The boxes available to workflows: OpenProcess's, installed packages', and the folder's boxes/.
486
+ self._library = None
487
+ self._boxes_signature: tuple = ()
488
+ # Any size down to this works: pages scroll when they do not fit.
489
+ self.setMinimumSize(720, 480)
490
+ self._fit_to_screen()
491
+
492
+ root = QSplitter()
493
+ root.setObjectName("studioRoot")
494
+ root.setHandleWidth(1)
495
+ root.addWidget(self._build_sidebar())
496
+ # Hidden and shown only by toggle_sidebar, so its button and menu tick agree.
497
+ root.setCollapsible(0, False)
498
+ self.content = QStackedWidget()
499
+ self.content.addWidget(scroll(self._build_welcome(), horizontal=True))
500
+ # Above the pages: "a new version is out" (see check_automatically).
501
+ from .updates import UpdateBar
502
+ self.update_bar = UpdateBar()
503
+ self.update_bar.whats_new.connect(lambda: self._show_update_dialog(
504
+ self.update_bar.release))
505
+ self.update_bar.install.connect(lambda: self._update_action(
506
+ self.update_bar.release, "install"))
507
+ self.update_bar.skip.connect(lambda: self._update_action(
508
+ self.update_bar.release, "skip"))
509
+ right = QWidget()
510
+ middle = QHBoxLayout()
511
+ middle.setContentsMargins(0, 0, 0, 0)
512
+ middle.setSpacing(0)
513
+ middle.addWidget(self._build_rail())
514
+ middle.addWidget(self.content, 1)
515
+ right.setLayout(vbox(self.update_bar, middle, spacing=0))
516
+ right.layout().setStretch(1, 1)
517
+ # Scratch notes floating over the pages (Learn has its own).
518
+ from .notes_overlay import NotesOverlay
519
+ self.notes = NotesOverlay(self.content)
520
+ self.notes.edited.connect(self._save_notes)
521
+ self.notes.open_changed.connect(self._notes_opened)
522
+ #: The notes kept outside a folder (in the settings; only in memory in tests).
523
+ self._loose_notes = self._setting("notes/text", "")
524
+ self.notes.set_text(self._loose_notes)
525
+ root.addWidget(right)
526
+ root.setStretchFactor(1, 1)
527
+ root.setSizes([220, 1260])
528
+ # Learn takes the whole window (see open_exercise); the
529
+ # folder's view waits underneath, as it was.
530
+ from ..learn.mode import LearnMode
531
+ self.learn_mode = LearnMode()
532
+ self.learn_mode.exit_requested.connect(self.leave_learn)
533
+ self.learn_mode.status.connect(lambda m: self.statusBar().showMessage(m, 8000))
534
+ self.learn_mode.title_changed.connect(
535
+ lambda title: self.in_learn and self.setWindowTitle(title))
536
+ self.modes = QStackedWidget()
537
+ self.modes.addWidget(root)
538
+ self.modes.addWidget(self.learn_mode)
539
+ self.setCentralWidget(self.modes)
540
+ self.statusBar().showMessage("Ready")
541
+ # ✎ Notes in the status bar: it never covers the page (see toggle_notes).
542
+ self.notes_button = QToolButton()
543
+ self.notes_button.setObjectName("notesStatusButton")
544
+ self.notes_button.setText("✎ Notes")
545
+ self.notes_button.setToolTip(f"Scratch paper, kept with the folder "
546
+ f"({shortcut_text('Ctrl+Alt+N')})")
547
+ self.notes_button.setCheckable(True)
548
+ self.notes_button.setCursor(Qt.PointingHandCursor)
549
+ self.notes_button.setFocusPolicy(Qt.NoFocus)
550
+ self.notes_button.clicked.connect(lambda: self.toggle_notes())
551
+ self.statusBar().addPermanentWidget(self.notes_button)
552
+ self.modes.currentChanged.connect(lambda _index: self._update_notes_button())
553
+ self._build_menus()
554
+ if not self._setting("window/sidebar", True):
555
+ self.toggle_sidebar(False)
556
+ self.set_notes_button(self._setting("notes/button", True))
557
+ if self._setting("notes/open", False):
558
+ self.notes.set_open(True)
559
+
560
+ # -- the editor fits the space it gets -----------------------------------------
561
+ def resizeEvent(self, event) -> None: # noqa: N802
562
+ super().resizeEvent(event)
563
+ self._schedule_fit()
564
+
565
+ def _schedule_fit(self) -> None:
566
+ QTimer.singleShot(0, self._fit_page)
567
+
568
+ def _fit_page(self) -> None:
569
+ """Fold the editor's inspector away when the page would not fit beside the
570
+ sidebar (it would scroll sideways and pin the canvas divider), and bring
571
+ it back when there is room again."""
572
+ page = self.current_page()
573
+ panel = getattr(page, "inspector_panel", None)
574
+ if panel is None or not hasattr(page, "_toggle_inspector"):
575
+ return
576
+ from PySide6.QtWidgets import QScrollArea
577
+ area = page.parentWidget()
578
+ while area is not None and not isinstance(area, QScrollArea):
579
+ area = area.parentWidget()
580
+ if area is None:
581
+ return
582
+ available = area.viewport().width()
583
+ if panel.isVisible():
584
+ if page.minimumSizeHint().width() > available:
585
+ page._toggle_inspector()
586
+ page._inspector_auto_hidden = True
587
+ self.statusBar().showMessage("The inspector is folded away to fit the window: "
588
+ "“⇤ Inspector” brings it back", 6000)
589
+ elif getattr(page, "_inspector_auto_hidden", False):
590
+ needed = (page.minimumSizeHint().width() + panel.minimumWidth()
591
+ + page.splitter.handleWidth())
592
+ if needed <= available:
593
+ page._toggle_inspector()
594
+ page._inspector_auto_hidden = False
595
+
596
+ def _fit_to_screen(self) -> None:
597
+ """Start at a size that fits the screen we are on (at most 1440 × 900).
598
+
599
+ A fixed size larger than a laptop's screen is what made the window run
600
+ off the edge; macOS cannot shrink a window below its minimum size.
601
+ """
602
+ screen = self.screen() or QApplication.primaryScreen()
603
+ if screen is None:
604
+ self.resize(1280, 800)
605
+ return
606
+ area = screen.availableGeometry()
607
+ width = min(1440, int(area.width() * 0.94))
608
+ height = min(900, int(area.height() * 0.94))
609
+ self.resize(width, height)
610
+ self.move(area.x() + (area.width() - width) // 2, area.y() + (area.height() - height) // 2)
611
+
612
+ def _setting(self, key: str, default):
613
+ """A setting of the app (the default in tests, which must not see yours)."""
614
+ if not self.persist:
615
+ return default
616
+ return self.settings.value(key, default, type=type(default))
617
+
618
+ def _set_setting(self, key: str, value) -> None:
619
+ if self.persist:
620
+ self.settings.setValue(key, value)
621
+
622
+ # ---------------------------------------------------------------- sidebar
623
+ def _build_sidebar(self) -> QWidget:
624
+ sidebar = QFrame()
625
+ self.sidebar = sidebar
626
+ sidebar.setObjectName("sidebar")
627
+ sidebar.setMinimumWidth(180)
628
+ sidebar.setMaximumWidth(360)
629
+ layout = QVBoxLayout(sidebar)
630
+ layout.setContentsMargins(8, 16, 8, 10)
631
+ layout.setSpacing(8)
632
+ layout.addWidget(self._build_sidebar_header())
633
+
634
+ # In a folder: its subfolders, or grouped by kind as without one.
635
+ self.view_switch = SegmentedControl(["Folders", "By kind"], compact=True)
636
+ self.view_switch.setToolTip("Show the folder's subfolders, or group the files by kind")
637
+ self.view_switch.changed.connect(
638
+ lambda index: self.set_view_mode(("folder", "kind")[index]))
639
+ self.view_row = QWidget()
640
+ self.view_row.setLayout(hbox(self.view_switch, None, margins=(10, 0, 0, 2)))
641
+ self.view_row.setHidden(True)
642
+ layout.addWidget(self.view_row)
643
+
644
+ self.tree = SidebarTree()
645
+ self.tree.setHeaderHidden(True)
646
+ self.tree.setIndentation(14)
647
+ self.tree.setIconSize(QSize(16, 16))
648
+ self.tree.setRootIsDecorated(False)
649
+ self.tree.setMouseTracking(True) # hover state for the ✕ button
650
+ self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection) # ⌘/⇧-click
651
+ self.tree.setTextElideMode(Qt.ElideRight)
652
+ self.tree.path_for = self._row_path
653
+ self.tree.drop_folder = self._drop_folder
654
+ self.tree.paths_dropped.connect(self._sidebar_drop)
655
+ self.delegate = SidebarDelegate(self.tree)
656
+ self.delegate.remove_clicked.connect(lambda doc_id: self.remove_documents([doc_id]))
657
+ self.tree.setItemDelegate(self.delegate)
658
+ self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
659
+ self.tree.customContextMenuRequested.connect(self._sidebar_menu)
660
+ # ⌫ / Delete removes the selection while the sidebar has focus.
661
+ for key in (QKeySequence.Delete, QKeySequence(Qt.Key_Backspace)):
662
+ shortcut = QShortcut(key, self.tree)
663
+ shortcut.setContext(Qt.WidgetShortcut)
664
+ shortcut.activated.connect(self.remove_selected)
665
+ # The Folder view: documents with no file (a model just discovered)
666
+ # on top, then the folder's subfolders and files, then open files
667
+ # from elsewhere and comparisons.
668
+ self.unsaved_section = self._section("UNSAVED")
669
+ self.logs_section = self._section("EVENT LOGS")
670
+ self.petri_section = self._section("PETRI NETS")
671
+ self.models_section = self._section("MODELS")
672
+ self.cpn_section = self._section("COLOURED NETS")
673
+ self.ts_section = self._section("TRANSITION SYSTEMS")
674
+ self.workflows_section = self._section("WORKFLOWS")
675
+ self.exercises_section = self._section("EXERCISES")
676
+ self.elsewhere_section = self._section("OTHER FILES")
677
+ self.elsewhere_section.setToolTip(0, "Open files that are not in the folder")
678
+ self.compare_section = self._section("COMPARISONS")
679
+ # Shown in an empty folder, so an empty sidebar is not a mystery.
680
+ self.empty_hint = QTreeWidgetItem(self.tree, ["No logs or nets yet"])
681
+ self.empty_hint.setFlags(Qt.NoItemFlags)
682
+ self.empty_hint.setToolTip(0, "Save a net or export a log into the folder, or add "
683
+ "files to it in Finder: they appear here.")
684
+ self.empty_hint.setHidden(True)
685
+ # Shown when a huge folder was only partly listed.
686
+ self.more_row = QTreeWidgetItem(["… more files not shown"])
687
+ self.more_row.setFlags(Qt.NoItemFlags)
688
+ self.more_row.setToolTip(0, "The sidebar lists up to 500 files, three subfolders deep. "
689
+ "Open a smaller folder to see everything.")
690
+ self.tree.currentItemChanged.connect(self._on_select)
691
+ # A file that is not open yet opens with one click (or Return).
692
+ self.tree.itemClicked.connect(lambda item, _column: self._open_placeholder(item))
693
+ self.tree.itemActivated.connect(lambda item, _column: self._open_placeholder(item))
694
+ # Double-click an open document to rename it (section headings have no id).
695
+ self.tree.itemDoubleClicked.connect(self._on_double_click)
696
+ self.tree.itemExpanded.connect(lambda item: self._folder_toggled(item, True))
697
+ self.tree.itemCollapsed.connect(lambda item: self._folder_toggled(item, False))
698
+ layout.addWidget(self.tree, 1)
699
+
700
+ footer = QWidget()
701
+ footer.setObjectName("sidebarFooter")
702
+ footer.setLayout(vbox(button("+ Open file…", self.action_open),
703
+ button("✎ Log from notation…", self.action_notation),
704
+ button("⇄ Compare logs…", lambda: self.action_compare()),
705
+ button("⇄ Compare nets…", self.action_compare_nets),
706
+ spacing=0))
707
+ layout.addWidget(footer)
708
+ return sidebar
709
+
710
+ def _build_sidebar_header(self) -> QWidget:
711
+ """"OpenProcess Studio", or the workspace's name with a ⋯ menu."""
712
+ header = QWidget()
713
+ row = QHBoxLayout(header)
714
+ row.setContentsMargins(10, 0, 2, 6)
715
+ row.setSpacing(4)
716
+ text = QVBoxLayout()
717
+ text.setSpacing(1)
718
+ self.workspace_caption = label("FOLDER", "sidebarCaption")
719
+ self.workspace_caption.setHidden(True)
720
+ self.sidebar_title = ElidedLabel(APPLICATION_NAME, "sidebarTitle")
721
+ text.addWidget(self.workspace_caption)
722
+ text.addWidget(self.sidebar_title)
723
+ row.addLayout(text, 1)
724
+ self.workspace_button = QToolButton()
725
+ self.workspace_button.setObjectName("sidebarMenuButton")
726
+ self.workspace_button.setText("⋯")
727
+ self.workspace_button.setToolTip("Folder")
728
+ self.workspace_button.setPopupMode(QToolButton.InstantPopup)
729
+ menu = QMenu(self.workspace_button)
730
+ reveal = {"darwin": "Show in Finder", "win32": "Show in Explorer"}.get(
731
+ sys.platform, "Open Folder")
732
+ menu.addAction(reveal, lambda: self.workspace and self.reveal(str(self.workspace.folder)))
733
+ menu.addAction("New Folder…", lambda: self.new_folder())
734
+ menu.addSeparator()
735
+ menu.addAction("Open Another Folder…", self.action_open_workspace)
736
+ menu.addSeparator()
737
+ menu.addAction("Close Folder", self.close_workspace)
738
+ self.workspace_button.setMenu(menu)
739
+ self.workspace_button.setHidden(True)
740
+ row.addWidget(self.workspace_button, 0, Qt.AlignVCenter)
741
+ # On the sidebar it hides, so what it does is plain to see; when the
742
+ # sidebar is hidden, the rail at the left edge brings it back.
743
+ self.sidebar_hide_button = self._rail_button(
744
+ "sidebar", f"Hide the sidebar ({shortcut_text('Ctrl+Alt+S')})",
745
+ lambda: self.toggle_sidebar(False))
746
+ row.addWidget(self.sidebar_hide_button, 0, Qt.AlignVCenter)
747
+ return header
748
+
749
+ def toggle_sidebar(self, show: bool | None = None, remember: bool = True) -> None:
750
+ """Show or hide the sidebar (hidden, every page gets the full width).
751
+
752
+ ``remember=False``: a change the app made by itself, not kept for the
753
+ next launch."""
754
+ if show is None:
755
+ show = self.sidebar.isHidden()
756
+ self.sidebar.setHidden(not show)
757
+ action = getattr(self, "sidebar_action", None)
758
+ if action is not None and action.isChecked() != show:
759
+ action.blockSignals(True)
760
+ action.setChecked(show)
761
+ action.blockSignals(False)
762
+ if remember:
763
+ self._set_setting("window/sidebar", show)
764
+ self._update_rail()
765
+ self._schedule_fit()
766
+
767
+ # -- notes: scratch paper over every page (see notes_overlay) --------------------------
768
+ def toggle_notes(self, show: bool | None = None) -> None:
769
+ """Open or fold away the notes (in Learn: the exercise's own)."""
770
+ if self.in_learn:
771
+ if self.learn_mode.view is None:
772
+ self.notes_action.setChecked(False)
773
+ return
774
+ if show is None:
775
+ show = not self.learn_mode.view.notes_visible
776
+ self.learn_mode.set_notes(show)
777
+ return
778
+ self.notes.set_open(not self.notes.is_open if show is None else show)
779
+
780
+ def _sync_notes_action(self) -> None:
781
+ """Tick Show Notes (and ✎ Notes) for the notes in view: the exercise's, or
782
+ the window's."""
783
+ if self.in_learn:
784
+ view = self.learn_mode.view
785
+ self._notes_opened(view is not None and view.notes_visible, remember=False)
786
+ self.notes_action.setEnabled(view is not None)
787
+ self.notes_button.setEnabled(view is not None)
788
+ else:
789
+ self._notes_opened(self.notes.is_open, remember=False)
790
+ self.notes_action.setEnabled(True)
791
+ self.notes_button.setEnabled(True)
792
+
793
+ def _notes_opened(self, show: bool, remember: bool = True) -> None:
794
+ if self.notes_action.isChecked() != show:
795
+ self.notes_action.blockSignals(True)
796
+ self.notes_action.setChecked(show)
797
+ self.notes_action.blockSignals(False)
798
+ self.notes_button.setChecked(show)
799
+ if remember:
800
+ self._set_setting("notes/open", show)
801
+
802
+ def set_notes_button(self, show: bool) -> None:
803
+ """Show the ✎ Notes button in the status bar (or rely on ⌘⌥N alone)."""
804
+ self._notes_button_wanted = show
805
+ self._update_notes_button()
806
+ if self.notes_button_action.isChecked() != show:
807
+ self.notes_button_action.blockSignals(True)
808
+ self.notes_button_action.setChecked(show)
809
+ self.notes_button_action.blockSignals(False)
810
+ self._set_setting("notes/button", show)
811
+
812
+ def _update_notes_button(self) -> None:
813
+ """Learn has a ✎ Notes of its own, in its top bar."""
814
+ self.notes_button.setVisible(getattr(self, "_notes_button_wanted", True)
815
+ and not self.in_learn)
816
+
817
+ def _save_notes(self, text: str) -> None:
818
+ """Keep the notes: with the open folder, or in the app's settings."""
819
+ if self.workspace is not None:
820
+ try:
821
+ self.workspace.save_notes(text)
822
+ except OSError as error:
823
+ self.statusBar().showMessage(f"Could not save your notes: {error}", 8000)
824
+ else:
825
+ self._loose_notes = text
826
+ self._set_setting("notes/text", text)
827
+
828
+ def _show_notes_of_folder(self) -> None:
829
+ """The notes of the folder now open (or the loose ones, outside a folder)."""
830
+ self.notes.set_text(self.workspace.load_notes() if self.workspace is not None
831
+ else self._loose_notes)
832
+
833
+ # -- the rail: what brings hidden panels back --------------------------------------
834
+ def _rail_button(self, icon: str, tooltip: str, slot) -> QToolButton:
835
+ from .tool_icons import question_icon, sidebar_icon
836
+ toggle = QToolButton()
837
+ toggle.setObjectName("sidebarToggle")
838
+ toggle.setIcon(sidebar_icon() if icon == "sidebar" else question_icon())
839
+ toggle.setIconSize(QSize(20, 20))
840
+ toggle.setCursor(Qt.PointingHandCursor)
841
+ toggle.setToolTip(tooltip)
842
+ toggle.clicked.connect(slot)
843
+ return toggle
844
+
845
+ def _build_rail(self) -> QWidget:
846
+ """A slim strip at the left edge, shown while the sidebar is hidden, with
847
+ the button that brings it back."""
848
+ self.rail = QFrame()
849
+ self.rail.setObjectName("rail")
850
+ self.rail.setFixedWidth(44)
851
+ layout = QVBoxLayout(self.rail)
852
+ layout.setContentsMargins(6, 14, 6, 10)
853
+ layout.setSpacing(6)
854
+ self.rail_sidebar = self._rail_button(
855
+ "sidebar", f"Show the sidebar ({shortcut_text('Ctrl+Alt+S')})",
856
+ lambda: self.toggle_sidebar(True))
857
+ layout.addWidget(self.rail_sidebar, 0, Qt.AlignHCenter)
858
+ layout.addStretch(1)
859
+ self.rail.setHidden(True)
860
+ return self.rail
861
+
862
+ def _update_rail(self) -> None:
863
+ if not hasattr(self, "rail"):
864
+ return
865
+ sidebar_hidden = self.sidebar.isHidden()
866
+ self.rail_sidebar.setVisible(sidebar_hidden)
867
+ self.rail.setVisible(sidebar_hidden)
868
+
869
+ def _section(self, title: str) -> QTreeWidgetItem:
870
+ item = QTreeWidgetItem(self.tree, [title])
871
+ # No ▸ for a heading: the sidebar only draws one for subfolders (a
872
+ # DontShowIndicator policy would also make Qt hide the heading's rows).
873
+ item.setFlags(Qt.ItemIsEnabled)
874
+ font = theme.ui_font(10, theme.QFont.Bold)
875
+ item.setFont(0, font)
876
+ item.setForeground(0, QColor(style.tokens().text_muted))
877
+ item.setExpanded(True)
878
+ item.setHidden(True)
879
+ return item
880
+
881
+ # ---------------------------------------------------------------- welcome
882
+ def _build_welcome(self) -> QWidget:
883
+ page = QWidget()
884
+ outer = QVBoxLayout(page)
885
+ outer.setContentsMargins(20, 16, 32, 32)
886
+ outer.addSpacing(24)
887
+ outer.addStretch(1)
888
+ logo = QLabel()
889
+ logo.setPixmap(app_icon().pixmap(96, 96))
890
+ outer.addWidget(logo, 0, Qt.AlignHCenter)
891
+ title = label(APPLICATION_NAME)
892
+ title.setStyleSheet("font-size: 34px; font-weight: 700;")
893
+ outer.addWidget(title, 0, Qt.AlignHCenter)
894
+ subtitle = label("Process mining, Petri nets and workflows in one open app: "
895
+ "every algorithm there to read, every result reproducible.", "pageSubtitle")
896
+ subtitle.setStyleSheet("font-size: 15px;")
897
+ outer.addWidget(subtitle, 0, Qt.AlignHCenter)
898
+ outer.addSpacing(30)
899
+
900
+ grid = QGridLayout()
901
+ grid.setSpacing(14)
902
+ # A whole-width card on top: working in a folder is the main way in.
903
+ workspace_card = Card("Work in a folder")
904
+ workspace_card.add(label(
905
+ "Open a folder such as “Week 2”: its event logs and Petri nets are listed in "
906
+ "the sidebar, and the nets you draw are saved there. "
907
+ "Next time, everything you had open comes back.", "muted", wrap=True))
908
+ self.welcome_recent = QWidget()
909
+ self.welcome_recent.setObjectName("plain")
910
+ self.welcome_recent.setStyleSheet("#plain { background: transparent; border: none; }")
911
+ self.welcome_recent.setLayout(hbox(spacing=8))
912
+ workspace_card.add(hbox(button("Open folder…", self.action_open_workspace,
913
+ kind="primary"), self.welcome_recent, None))
914
+ self.welcome_folder_card = workspace_card
915
+ grid.addWidget(workspace_card, 0, 0, 1, 2)
916
+ # Inside a workspace the top card is about that folder instead.
917
+ inside = Card("Folder")
918
+ self.welcome_inside_title = inside.title_label
919
+ self.welcome_inside_text = label("", "muted", wrap=True)
920
+ inside.add(self.welcome_inside_text)
921
+ reveal = {"darwin": "Show in Finder", "win32": "Show in Explorer"}.get(
922
+ sys.platform, "Open Folder")
923
+ inside.add(hbox(button("New Petri net", self.action_new_petri, kind="primary"),
924
+ button("New coloured net", self.action_new_cpn),
925
+ button(reveal, lambda: self.workspace and
926
+ self.reveal(str(self.workspace.folder))),
927
+ None,
928
+ button("Close folder", self.close_workspace)))
929
+ inside.setHidden(True)
930
+ self.welcome_inside_card = inside
931
+ grid.addWidget(inside, 0, 0, 1, 2)
932
+ cards = [
933
+ ("Open an event log", "XES, XES.GZ or CSV. Explore variants, the dotted chart "
934
+ "and the process map, then discover a model.", "Open event log…", self.action_open),
935
+ ("Type a textbook log", "Write L = [<a,b,c>^3, <a,c>^2] exactly as in the course "
936
+ "and mine it immediately.", "Log from notation…", self.action_notation),
937
+ ("Draw a Petri net", "Places, transitions and arcs as in the lectures, or open a "
938
+ "PNML file. Soundness with counterexamples, footprint, reachability graph, "
939
+ "token game.", "New Petri net", self.action_new_petri),
940
+ ("Coloured Petri nets", "CPN Tools models (.cpn): edit, simulate step by step or "
941
+ "automatically, analyse the state space, and mine the simulated behaviour.",
942
+ "Open CPN model…", self.action_open_cpn),
943
+ ("Build a workflow", "Boxes on a canvas: a log, a miner, a check, a comparison. Click a "
944
+ "box to see its result and how it got there; change a setting and only what follows "
945
+ "runs again. Saved with everything needed to get the same numbers back.",
946
+ "New workflow", lambda: self.action_new_workflow(TEMPLATES[0][1])),
947
+ ]
948
+ for index, (heading, text, action, slot) in enumerate(cards):
949
+ card = Card(heading)
950
+ card.setMinimumWidth(240)
951
+ card.add(label(text, "muted", wrap=True))
952
+ card.add(hbox(button(action, slot), None))
953
+ grid.addWidget(card, 1 + index // 2, index % 2)
954
+ holder = QWidget()
955
+ holder.setLayout(grid)
956
+ holder.setMaximumWidth(760)
957
+ holder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
958
+ # Centre with stretches rather than an alignment flag: an aligned
959
+ # widget is sized by its sizeHint and its wrapped labels get clipped.
960
+ outer.addLayout(hbox(None, holder, None))
961
+ outer.addSpacing(20)
962
+ tip = label("Tip: drop files (or a folder, to work in it) anywhere on this "
963
+ "window. Hover a file in the sidebar and click ✕ (or press "
964
+ f"{shortcut_text('Backspace')}) to close "
965
+ "it; double-click a name to rename it.", "muted", wrap=True)
966
+ tip.setAlignment(Qt.AlignHCenter)
967
+ tip.setMaximumWidth(760)
968
+ tip.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
969
+ outer.addLayout(hbox(None, tip, None))
970
+ outer.addStretch(2)
971
+ self._refresh_welcome_recent()
972
+ return page
973
+
974
+ # ---------------------------------------------------------------- menus
975
+ def _build_menus(self) -> None:
976
+ file_menu = self.menuBar().addMenu("&File")
977
+ self._action(file_menu, "Open…", QKeySequence.Open, self.action_open)
978
+ self._action(file_menu, "Open Folder…", "Ctrl+Alt+O", self.action_open_workspace)
979
+ self.recent_workspaces_menu = file_menu.addMenu("Open Recent Folder")
980
+ self.recent_workspaces_menu.aboutToShow.connect(self._fill_recent_workspaces_menu)
981
+ self.close_workspace_action = self._action(file_menu, "Close Folder", None,
982
+ self.close_workspace)
983
+ self.close_workspace_action.setEnabled(False)
984
+ file_menu.addSeparator()
985
+ self._action(file_menu, "New Log from Notation…", "Ctrl+L", self.action_notation)
986
+ self._action(file_menu, "New Petri Net", QKeySequence.New, self.action_new_petri)
987
+ self._action(file_menu, "New Coloured Petri Net", "Ctrl+Shift+N", self.action_new_cpn)
988
+ self._action(file_menu, "New Transition System…", None, self.action_new_ts)
989
+ workflows = file_menu.addMenu("New Workflow")
990
+ for text, build, about in TEMPLATES:
991
+ action = workflows.addAction(text, lambda b=build: self.action_new_workflow(b))
992
+ action.setToolTip(about)
993
+ workflows.addSeparator()
994
+ workflows.addAction("Empty workflow", lambda: self.action_new_workflow(None))
995
+ examples = file_menu.addMenu("Open Example Log")
996
+ for name, text in EXAMPLES.items():
997
+ examples.addAction(name, lambda n=name, t=text: self.add_document(LogDocument(
998
+ EventLog.from_simple_log(parse_simple_log(t), n.split(" (")[0]), notation=t)))
999
+ from ...model.examples import EXAMPLES as PETRI_EXAMPLES
1000
+ petri_examples = file_menu.addMenu("Open Example Petri Net")
1001
+ for name, build in PETRI_EXAMPLES.items():
1002
+ petri_examples.addAction(name, lambda b=build: self.open_example_net(b()))
1003
+ self.recent_menu = file_menu.addMenu("Open Recent")
1004
+ self.recent_menu.aboutToShow.connect(self._fill_recent_menu)
1005
+ file_menu.addSeparator()
1006
+ self._action(file_menu, "Open Coloured Petri Net…", "Ctrl+Shift+O", self.action_open_cpn)
1007
+ self._action(file_menu, "Compare Logs…", "Ctrl+Shift+C", lambda: self.action_compare())
1008
+ self._action(file_menu, "Compare Nets…", None, self.action_compare_nets)
1009
+ file_menu.addSeparator()
1010
+ self._action(file_menu, "Save", QKeySequence.Save, self.action_save)
1011
+ self._action(file_menu, "Save As / Export…", "Ctrl+Shift+S", self.export_selected)
1012
+ self._action(file_menu, "Export Selected…", "Ctrl+E", self.export_selected)
1013
+ self._action(file_menu, "Rename…", None, self.rename_selected)
1014
+ self._action(file_menu, "Revert to Saved…", None, self.revert_selected)
1015
+ self.autosave_action = QAction("Autosave", self)
1016
+ self.autosave_action.setCheckable(True)
1017
+ self.autosave_action.setChecked(self.autosave_enabled)
1018
+ self.autosave_action.setToolTip("Save edits to nets in the open folder as you go")
1019
+ self.autosave_action.toggled.connect(self.set_autosave)
1020
+ file_menu.addAction(self.autosave_action)
1021
+ file_menu.addSeparator()
1022
+ self._action(file_menu, f"Move to {BIN}", None, self.trash_selected)
1023
+ self._action(file_menu, "Close", QKeySequence.Close, self.remove_selected)
1024
+ self._action(file_menu, "Close All", "Ctrl+Shift+W", self.remove_all)
1025
+ file_menu.addSeparator()
1026
+ self.restore_action = QAction("Reopen Files at Launch", self)
1027
+ self.restore_action.setCheckable(True)
1028
+ self.restore_action.setChecked(self._setting("session/restore", True))
1029
+ self.restore_action.toggled.connect(lambda on: self._set_setting("session/restore", on))
1030
+ file_menu.addAction(self.restore_action)
1031
+ settings = self._action(file_menu, "Settings…", QKeySequence.Preferences,
1032
+ self.show_settings)
1033
+ settings.setMenuRole(QAction.PreferencesRole) # the app menu, on macOS
1034
+
1035
+ view_menu = self.menuBar().addMenu("&View")
1036
+ view_menu.aboutToShow.connect(self._sync_notes_action)
1037
+ self._action(view_menu, "Show Welcome Page", "Ctrl+1",
1038
+ lambda: (self.in_learn and self.leave_learn(),
1039
+ self.content.setCurrentIndex(0)))
1040
+ self.sidebar_action = self._action(view_menu, "Show Sidebar", "Ctrl+Alt+S",
1041
+ lambda on: self.toggle_sidebar(on))
1042
+ self.sidebar_action.setCheckable(True)
1043
+ self.sidebar_action.setChecked(True)
1044
+ self.notes_action = self._action(view_menu, "Show Notes", "Ctrl+Alt+N",
1045
+ lambda on: self.toggle_notes(on))
1046
+ self.notes_action.setCheckable(True)
1047
+ self.notes_button_action = self._action(view_menu, "Show Notes in Status Bar", None,
1048
+ lambda on: self.set_notes_button(on))
1049
+ self.notes_button_action.setCheckable(True)
1050
+ self.notes_button_action.setChecked(True)
1051
+ view_menu.addSeparator()
1052
+ # The Workflows page's panels (the actions act on the current page).
1053
+ self.box_list_action = self._action(view_menu, "Show Box List", "Ctrl+Alt+B",
1054
+ lambda on: self._workflow_panel("box_list", on))
1055
+ self.box_list_action.setCheckable(True)
1056
+ self.panel_action = self._action(view_menu, "Show Box Panel", "Ctrl+Alt+P",
1057
+ lambda on: self._workflow_panel("panel", on))
1058
+ self.panel_action.setCheckable(True)
1059
+ self._action(view_menu, "Reset Workflow Layout", None, lambda: self._workflow_panel("reset", True))
1060
+ view_menu.aboutToShow.connect(self._sync_workflow_actions)
1061
+ view_menu.addSeparator()
1062
+ zoom_in = self._action(view_menu, "Zoom In", QKeySequence.ZoomIn,
1063
+ lambda: self._zoom("zoom_in"))
1064
+ zoom_in.setShortcuts([QKeySequence.ZoomIn, QKeySequence("Ctrl+=")])
1065
+ self._action(view_menu, "Zoom Out", QKeySequence.ZoomOut, lambda: self._zoom("zoom_out"))
1066
+ self._action(view_menu, "Zoom to Fit", "Ctrl+0", lambda: self._zoom("fit"))
1067
+ self._action(view_menu, "Actual Size", "Ctrl+Alt+0", lambda: self._zoom("actual_size"))
1068
+
1069
+ # Learn: exercise packs on top of the app (openprocess.learn, openprocess.gui.learn).
1070
+ learn_menu = self.menuBar().addMenu("&Learn")
1071
+ self._action(learn_menu, "Open Exercise Pack…", None, self.action_open_exercise)
1072
+ self._action(learn_menu, "Open Demo Exercises", None, self.open_demo_exercises)
1073
+ learn_menu.addSeparator()
1074
+ self._action(learn_menu, "Make a Pack from an Exam…", None, self.action_import_exam)
1075
+ self._action(learn_menu, "Writing Exercise Packs", None,
1076
+ lambda: self.show_guide("exercise-packs"))
1077
+
1078
+ help_menu = self.menuBar().addMenu("&Help")
1079
+ self._action(help_menu, "Definitions", None, self._definitions)
1080
+ self._action(help_menu, "References", None, lambda: self.show_guide("references"))
1081
+ self._action(help_menu, "Writing Exercise Packs", None,
1082
+ lambda: self.show_guide("exercise-packs"))
1083
+ self._action(help_menu, "Check for Updates…", None,
1084
+ lambda: self.check_for_updates(manual=True))
1085
+ self._action(help_menu, f"About {APPLICATION_NAME}", None, self._about)
1086
+
1087
+ def _workflow_panel(self, which: str, show: bool) -> None:
1088
+ page = self.current_page()
1089
+ if not isinstance(page, WorkflowPage):
1090
+ self.statusBar().showMessage("Open a workflow first: these panels belong to the Workflows page", 6000)
1091
+ return
1092
+ if which == "box_list":
1093
+ page.toggle_box_list(show)
1094
+ elif which == "panel":
1095
+ page.toggle_panel(show)
1096
+ else:
1097
+ page.reset_layout()
1098
+
1099
+ def _sync_workflow_actions(self) -> None:
1100
+ page = self.current_page()
1101
+ is_workflow = isinstance(page, WorkflowPage)
1102
+ for action, key in ((self.box_list_action, "box_list"), (self.panel_action, "panel")):
1103
+ action.setEnabled(is_workflow)
1104
+ action.blockSignals(True)
1105
+ action.setChecked(bool(page.layout_state()[key]) if is_workflow else False)
1106
+ action.blockSignals(False)
1107
+
1108
+ def _action(self, menu, text, shortcut, slot) -> QAction:
1109
+ action = QAction(text, self)
1110
+ if shortcut is not None:
1111
+ action.setShortcut(QKeySequence(shortcut))
1112
+ action.triggered.connect(slot)
1113
+ menu.addAction(action)
1114
+ return action
1115
+
1116
+ def _definitions(self) -> None:
1117
+ """Every analysis property, defined mathematically (docs/definitions.md)."""
1118
+ from .definition_view import show_reference
1119
+ show_reference(None, self)
1120
+
1121
+ def show_guide(self, name: str) -> None:
1122
+ """References, or the guide to writing exercise packs."""
1123
+ from .definition_view import show_guide
1124
+ show_guide(name, self)
1125
+
1126
+ def _about(self) -> None:
1127
+ from ... import __version__
1128
+ QMessageBox.about(self, APPLICATION_NAME,
1129
+ f"<b>{APPLICATION_NAME}</b> {__version__}"
1130
+ "<p>Process mining and Petri nets in pure "
1131
+ "Python. Algorithms follow the TU/e course readings; see the "
1132
+ "docstrings in <code>openprocess.mining</code>.</p>")
1133
+
1134
+ # ---------------------------------------------------------------- documents
1135
+ def logs(self) -> list[LogDocument]:
1136
+ return [d for d in self.documents if isinstance(d, LogDocument)]
1137
+
1138
+ def add_document(self, document, options: dict | None = None,
1139
+ near: str | None = None, keep_unsaved: bool = False) -> None:
1140
+ """Add a document to the window and show it.
1141
+
1142
+ Opening a file that is already open just selects it, so the sidebar
1143
+ never fills up with duplicates. With a folder open, a new log or net
1144
+ (one with no file yet) is saved into the folder straight away, next
1145
+ to ``near`` (the file it was made from) when that is in the folder.
1146
+ """
1147
+ if self.in_learn: # File ▸ New… or Open… from Learn
1148
+ self.leave_learn()
1149
+ if document.path:
1150
+ for existing in self.documents:
1151
+ if existing.path and self._key(existing.path) == self._key(document.path) \
1152
+ and type(existing) is type(document):
1153
+ self._select_document(existing)
1154
+ self.statusBar().showMessage(f"{Path(document.path).name} is already open", 5000)
1155
+ return
1156
+ self.documents.append(document)
1157
+ if options:
1158
+ self.open_options[document.id] = options
1159
+ self._make_page(document)
1160
+ if not keep_unsaved:
1161
+ self._materialise(document, near)
1162
+ if document.path:
1163
+ key = self._key(document.path)
1164
+ self._opening.discard(key)
1165
+ self._remember_identity(document)
1166
+ if isinstance(document, CpnDocument):
1167
+ if not document.path.lower().endswith(".cpn") or made_by_openprocess(document.path):
1168
+ self._openprocess_files.add(document.id)
1169
+ self._keep_original(document)
1170
+ self._update_autosave(document)
1171
+ # Reopening a folder loads logs in the background, in any order: of
1172
+ # those files, only the one that was selected last time takes the
1173
+ # selection. Anything you open yourself is always selected.
1174
+ key = self._key(document.path) if document.path else None
1175
+ restored = key is not None and key in self._restore_paths
1176
+ self._restore_paths.pop(key, None)
1177
+ self._rebuild_sidebar()
1178
+ if not restored or self._restore_selected in (None, key):
1179
+ self._select_document(document)
1180
+ self._refresh_log_choices()
1181
+ if document.path:
1182
+ self._remember_recent(document.path)
1183
+ self._save_session()
1184
+
1185
+ def _make_page(self, document) -> QWidget:
1186
+ """Build the page showing ``document`` (and its holder in the stack)."""
1187
+ if isinstance(document, LogDocument):
1188
+ page = LogPage(document)
1189
+ page.open_model.connect(self.add_document)
1190
+ page.open_log.connect(lambda log, source=document: self.add_document(
1191
+ log, near=source.path))
1192
+ page.edited.connect(lambda doc=document: self._log_edited(doc))
1193
+ page.tabs.changed.connect(lambda i: self.remembered.__setitem__("log_tab", i))
1194
+ page.workflow_requested.connect(lambda spec, doc=document: self.workflow_from_log(doc, spec))
1195
+ elif isinstance(document, TransitionSystemDocument):
1196
+ from .regions_view import TransitionSystemPage
1197
+ page = TransitionSystemPage(document)
1198
+ page.open_model.connect(self.add_document)
1199
+ elif isinstance(document, ComparisonDocument):
1200
+ page = ComparePage(document)
1201
+ page.tabs.changed.connect(lambda i: self.remembered.__setitem__("compare_tab", i))
1202
+ elif isinstance(document, WorkflowDocument):
1203
+ page = WorkflowPage(document, self.library(),
1204
+ self.workspace.folder if self.workspace is not None else None)
1205
+ page.open_document.connect(lambda doc, source=document: self.add_document(doc, near=source.path))
1206
+ page.edit_requested.connect(self.edit_petri_net)
1207
+ page.keep_requested.connect(lambda doc=document: self.keep_workflow(doc))
1208
+ page.edited.connect(lambda doc=document: (self._refresh_item(doc), self._schedule_autosave(doc)))
1209
+ if self.workspace is not None:
1210
+ page.custom_allowed = bool(self.workspace.settings().get("boxes_allowed"))
1211
+ page._refresh_custom_bar()
1212
+ page.custom_allowed_changed.connect(
1213
+ lambda: self.workspace and self.workspace.update_settings(boxes_allowed=True))
1214
+ page.layout_changed.connect(lambda p=page: self._set_setting("workflows/layout", json.dumps(p.layout_state())))
1215
+ QTimer.singleShot(0, lambda p=page: p.run_from(None))
1216
+ elif isinstance(document, CpnDocument):
1217
+ plain = getattr(document.net, "plain", False)
1218
+ page = PetriNetPage(document) if plain else CpnPage(document)
1219
+ page.log_generated.connect(lambda log, source=document: self.add_document(
1220
+ LogDocument(log), near=source.path))
1221
+ page.dirty_changed.connect(lambda _dirty, doc=document: self._refresh_item(doc))
1222
+ page.edited.connect(lambda doc=document: self._schedule_autosave(doc))
1223
+ page.snap_changed.connect(self._snap_changed)
1224
+ page.workflow_requested.connect(lambda doc=document: self.workflow_from_net(doc))
1225
+ page.inspector_tabs.changed.connect(
1226
+ lambda i, key="petri_inspector" if plain else "cpn_inspector":
1227
+ self.remembered.__setitem__(key, i))
1228
+ # Shown or hidden by hand: the window no longer folds it by itself.
1229
+ page.inspector_toggle.clicked.connect(
1230
+ lambda _=False, p=page: setattr(p, "_inspector_auto_hidden", False))
1231
+ if plain:
1232
+ page.open_model.connect(self.add_document)
1233
+ else:
1234
+ page = ModelPage(document, self.logs)
1235
+ page.inspector_tabs.changed.connect(
1236
+ lambda i: self.remembered.__setitem__("model_inspector", i))
1237
+ page.view_switch.changed.connect(
1238
+ lambda i: self.remembered.__setitem__("model_view", i))
1239
+ page.log_generated.connect(lambda log, source=document: self.add_document(
1240
+ LogDocument(log), near=source.path or (source.source_log.path
1241
+ if source.source_log else None)))
1242
+ page.edit_requested.connect(self.edit_petri_net)
1243
+ page.keep_requested.connect(lambda doc=document: self.keep_model(doc))
1244
+ page.workflow_requested.connect(lambda log, doc=document: self.workflow_from_model(doc, log))
1245
+ page.status.connect(lambda message: self.statusBar().showMessage(message, 8000))
1246
+ page.saved.connect(lambda doc=document: self._document_saved(doc))
1247
+ if not isinstance(document, ComparisonDocument):
1248
+ page.header.title_double_clicked.connect(
1249
+ lambda doc=document: self.rename_document(doc))
1250
+ page.header.title.set_hint("Double-click to rename")
1251
+ self.pages[document.id] = page
1252
+ # The page sits in a scroll area: if the window is made smaller than
1253
+ # the page's minimum size, scroll bars appear instead of the window
1254
+ # refusing to shrink (which is what pushed it off the screen).
1255
+ # Above it, a bar for news about its file ("changed on disk", …).
1256
+ banner = NoticeBar()
1257
+ holder = QWidget()
1258
+ holder.setLayout(vbox(banner, scroll(page, horizontal=True), spacing=0))
1259
+ holder.layout().setStretch(1, 1)
1260
+ self.banners[document.id] = banner
1261
+ self.holders[document.id] = holder
1262
+ self.content.addWidget(holder)
1263
+ return page
1264
+
1265
+ def _snap_changed(self, on: bool) -> None:
1266
+ """Snap to grid was ticked in one editor: the same everywhere, and next time."""
1267
+ self._set_setting("editor/snap_to_grid", on)
1268
+ for page in self.pages.values():
1269
+ box = getattr(page, "grid_box", None)
1270
+ if box is not None and box.isChecked() != on:
1271
+ box.blockSignals(True)
1272
+ box.setChecked(on)
1273
+ box.blockSignals(False)
1274
+
1275
+ def _select_document(self, document) -> None:
1276
+ item = self.items.get(document.id)
1277
+ if item is None:
1278
+ return
1279
+ parent = item.parent()
1280
+ while parent is not None: # inside a collapsed subfolder
1281
+ parent.setExpanded(True)
1282
+ parent = parent.parent()
1283
+ self.tree.clearSelection()
1284
+ self.tree.setCurrentItem(item)
1285
+
1286
+ def _refresh_item(self, document) -> None:
1287
+ """Sidebar text: the name, plus a dot while a model has unsaved edits."""
1288
+ item = self.items.get(document.id)
1289
+ if item is None:
1290
+ return
1291
+ if item.text(0) != self._row_text(document) and self.workspace is not None:
1292
+ self._rebuild_sidebar() # a new name: keep the rows in order
1293
+ else:
1294
+ self._style_document_row(item, document)
1295
+
1296
+ def _row_text(self, document) -> str:
1297
+ dirty = getattr(document, "dirty", False) and not getattr(document, "autosave", False)
1298
+ return document.name + (" •" if dirty else "")
1299
+
1300
+ def _style_document_row(self, item: QTreeWidgetItem, document) -> None:
1301
+ item.setText(0, self._row_text(document))
1302
+ font = item.font(0)
1303
+ font.setItalic(bool(getattr(document, "missing", False)))
1304
+ item.setFont(0, font)
1305
+ self._set_item_tooltip(item, document)
1306
+
1307
+ def _set_item_tooltip(self, item: QTreeWidgetItem, document) -> None:
1308
+ if getattr(document, "missing", False):
1309
+ where = f"{document.path}\nThe file is missing (moved or deleted) — Save As… to keep it"
1310
+ else:
1311
+ where = document.path or "Not saved to a file — export it to keep it"
1312
+ item.setToolTip(0, f"{document.name}\n{where}")
1313
+
1314
+ def _refresh_log_choices(self) -> None:
1315
+ for page in self.pages.values():
1316
+ if isinstance(page, ModelPage):
1317
+ page.refresh_logs()
1318
+
1319
+ def _on_select(self, current, previous=None) -> None:
1320
+ # Switching to another file saves the one you were editing.
1321
+ try:
1322
+ left = previous.data(0, Qt.UserRole) if previous is not None else None
1323
+ except RuntimeError: # a row the sidebar has just rebuilt
1324
+ left = None
1325
+ if left is not None:
1326
+ self._flush_autosaves([left])
1327
+ if current is None or current.data(0, Qt.UserRole) is None:
1328
+ return
1329
+ holder = self.holders.get(current.data(0, Qt.UserRole))
1330
+ if holder is not None:
1331
+ self._restore_tab(self.pages[current.data(0, Qt.UserRole)])
1332
+ self.content.setCurrentWidget(holder)
1333
+ self._schedule_fit()
1334
+ self._set_title(self._document(current.data(0, Qt.UserRole)))
1335
+
1336
+ def _restore_tab(self, page: QWidget) -> None:
1337
+ """Show the same tab on the newly selected page as on the last one."""
1338
+ if isinstance(page, LogPage):
1339
+ wanted = self.remembered["log_tab"]
1340
+ if page.tabs.index() != wanted:
1341
+ page.tabs.set_index(wanted)
1342
+ elif isinstance(page, ModelPage):
1343
+ if page.inspector_tabs.index() != self.remembered["model_inspector"]:
1344
+ page.inspector_tabs.set_index(self.remembered["model_inspector"])
1345
+ if page.view_switch.index() != self.remembered["model_view"]:
1346
+ page.view_switch.set_index(self.remembered["model_view"])
1347
+ elif isinstance(page, ComparePage):
1348
+ wanted = self.remembered.get("compare_tab", 0)
1349
+ if page.tabs.index() != wanted:
1350
+ page.tabs.set_index(wanted)
1351
+ elif isinstance(page, CpnPage):
1352
+ wanted = self.remembered.get("cpn_inspector", 0)
1353
+ if page.inspector_tabs.index() != wanted:
1354
+ page.inspector_tabs.set_index(wanted)
1355
+
1356
+ def active_graph_view(self) -> GraphView | None:
1357
+ """The canvas the zoom commands act on: the focused one, else the
1358
+ largest canvas visible on the current page."""
1359
+ widget = QApplication.focusWidget()
1360
+ while widget is not None:
1361
+ if isinstance(widget, (GraphView, NetView)) and widget.isVisible():
1362
+ return widget
1363
+ widget = widget.parentWidget()
1364
+ page = self.current_page()
1365
+ if page is None:
1366
+ return None
1367
+ views = [v for v in page.findChildren(GraphView) + page.findChildren(NetView)
1368
+ if v.isVisible()]
1369
+ return max(views, key=lambda v: v.width() * v.height(), default=None)
1370
+
1371
+ def _zoom(self, how: str) -> None:
1372
+ view = self.active_graph_view()
1373
+ if view is not None:
1374
+ getattr(view, how)()
1375
+
1376
+ def restyle(self) -> None:
1377
+ """Re-apply colours after a light/dark switch."""
1378
+ for controls in self.findChildren(ZoomControls):
1379
+ controls.restyle()
1380
+ for page in self.pages.values():
1381
+ if isinstance(page, CpnPage):
1382
+ page.restyle()
1383
+ self.update()
1384
+
1385
+ def current_page(self) -> QWidget | None:
1386
+ """The LogPage or ModelPage being shown (None on the welcome page)."""
1387
+ holder = self.content.currentWidget()
1388
+ for document_id, candidate in self.holders.items():
1389
+ if candidate is holder:
1390
+ return self.pages[document_id]
1391
+ return None
1392
+
1393
+ def _document(self, document_id: int):
1394
+ return next(d for d in self.documents if d.id == document_id)
1395
+
1396
+ def selected_ids(self) -> list[int]:
1397
+ """Selected documents in sidebar order; the current one if none is selected."""
1398
+ ids = [item.data(0, Qt.UserRole) for item in self.tree.selectedItems()
1399
+ if item.data(0, Qt.UserRole) is not None]
1400
+ if not ids and self.tree.currentItem() is not None:
1401
+ current = self.tree.currentItem().data(0, Qt.UserRole)
1402
+ if current is not None:
1403
+ ids = [current]
1404
+ return ids
1405
+
1406
+ # -- removing ----------------------------------------------------------------
1407
+ def remove_selected(self) -> None:
1408
+ self.remove_documents(self.selected_ids())
1409
+
1410
+ def remove_all(self) -> None:
1411
+ if self.documents:
1412
+ self.remove_documents([d.id for d in self.documents])
1413
+
1414
+ def remove_documents(self, ids: list[int], confirm: bool = True) -> bool:
1415
+ """Close documents. Files on disk are never touched (edits are saved
1416
+ first where autosave applies).
1417
+
1418
+ Returns False when the user cancelled. In a folder, a closed file
1419
+ stays listed (lighter) so it can be opened again with one click.
1420
+
1421
+ Only documents that exist nowhere else (a log typed in notation, a
1422
+ model just discovered) need a confirmation, because removing them
1423
+ loses them; anything opened from a file can simply be opened again.
1424
+ ``confirm=False`` skips that question (the caller asked already).
1425
+ """
1426
+ documents = [d for d in self.documents if d.id in set(ids)]
1427
+ if not documents:
1428
+ return True
1429
+ self._flush_autosaves([d.id for d in documents])
1430
+ # Comparisons of removed logs go too (they cannot outlive their logs).
1431
+ removed_logs = {d.id for d in documents if isinstance(d, LogDocument)}
1432
+ for other in self.documents:
1433
+ if isinstance(other, ComparisonDocument) and other not in documents and \
1434
+ any(log.id in removed_logs for log in other.logs):
1435
+ documents.append(other)
1436
+ # A comparison is recreated in a moment, so it needs no confirmation.
1437
+ unsaved = [d for d in documents if not isinstance(d, ComparisonDocument) and
1438
+ (not d.path or d.missing or getattr(d, "dirty", False))]
1439
+ if unsaved and confirm:
1440
+ names = "\n".join(f"• {d.name}" for d in unsaved[:8])
1441
+ more = f"\n… and {len(unsaved) - 8} more" if len(unsaved) > 8 else ""
1442
+ box = QMessageBox(self)
1443
+ box.setIcon(QMessageBox.Warning)
1444
+ box.setWindowTitle("Close")
1445
+ box.setText(f"Close {len(documents)} item(s)? These have changes that exist only "
1446
+ "in this window and will be lost:")
1447
+ box.setInformativeText(names + more + "\n\nSave or export them first to keep "
1448
+ "them.")
1449
+ remove = box.addButton("Close", QMessageBox.DestructiveRole)
1450
+ box.addButton(QMessageBox.Cancel)
1451
+ box.exec()
1452
+ if box.clickedButton() is not remove:
1453
+ return False
1454
+
1455
+ # Choose what to select afterwards: the row below the last removed one.
1456
+ order = [item.data(0, Qt.UserRole) for item in self._document_items()]
1457
+ removed = {d.id for d in documents}
1458
+ after = [i for i in order[order.index(max(removed, key=order.index)) + 1:]
1459
+ if i not in removed] if order else []
1460
+ before = [i for i in order if i not in removed]
1461
+ target = after[0] if after else (before[-1] if before else None)
1462
+
1463
+ for document in documents:
1464
+ self._drop_page(document)
1465
+ self.open_options.pop(document.id, None)
1466
+ for store in (self._originals, self._autosave_timers):
1467
+ store.pop(document.id, None)
1468
+ self._openprocess_files.discard(document.id)
1469
+ self._conflicts.discard(document.id)
1470
+ self.documents = [d for d in self.documents if d.id not in removed]
1471
+ self._rebuild_sidebar() # closed files of the folder are listed again
1472
+ self._refresh_log_choices()
1473
+ self._save_session()
1474
+
1475
+ if target is not None:
1476
+ self._select_document(self._document(target))
1477
+ else:
1478
+ self.tree.clearSelection()
1479
+ self.tree.setCurrentItem(None)
1480
+ self.content.setCurrentIndex(0)
1481
+ self._set_title(None)
1482
+ noun = "item" if len(documents) == 1 else "items"
1483
+ self.statusBar().showMessage(f"Closed {len(documents)} {noun}", 5000)
1484
+ return True
1485
+
1486
+ # Kept for the ⌘W binding in older code paths.
1487
+ close_current = remove_selected
1488
+
1489
+ def _drop_page(self, document) -> None:
1490
+ page = self.pages.pop(document.id)
1491
+ if hasattr(page, "shutdown"):
1492
+ page.shutdown()
1493
+ holder = self.holders.pop(document.id)
1494
+ self.content.removeWidget(holder)
1495
+ holder.deleteLater()
1496
+ self.banners.pop(document.id, None)
1497
+ timer = self._autosave_timers.get(document.id)
1498
+ if timer is not None:
1499
+ timer.stop()
1500
+
1501
+ def _document_items(self) -> list[QTreeWidgetItem]:
1502
+ """The open documents' rows, top to bottom (not the "not open" ones)."""
1503
+ items = []
1504
+ iterator = QTreeWidgetItemIterator(self.tree)
1505
+ while iterator.value() is not None:
1506
+ item = iterator.value()
1507
+ if item.data(0, Qt.UserRole) is not None:
1508
+ items.append(item)
1509
+ iterator += 1
1510
+ return items
1511
+
1512
+ # -- renaming, exporting, revealing ---------------------------------------------
1513
+ def rename_selected(self) -> None:
1514
+ ids = self.selected_ids()
1515
+ if len(ids) == 1:
1516
+ self.rename_document(self._document(ids[0]))
1517
+
1518
+ #: Characters macOS, Windows or Linux do not allow in a file name.
1519
+ FILE_NAME_FORBIDDEN = FORBIDDEN_CHARACTERS
1520
+
1521
+ def _rename_on_disk(self, source: Path, target: Path) -> bool:
1522
+ """Rename a file or folder, explaining why not if it cannot be done."""
1523
+ problem = None
1524
+ name = target.name if source.is_dir() else _file_stem(target)
1525
+ if self.FILE_NAME_FORBIDDEN & set(name) or name in (".", "..") \
1526
+ or name.startswith("."):
1527
+ problem = ("A name cannot start with a dot or contain any of "
1528
+ "/ \\ : * ? \" < > |")
1529
+ elif target.exists() and not _same_file(source, target):
1530
+ problem = f"There is already something called “{target.name}” in that folder."
1531
+ else:
1532
+ self._flush_autosaves()
1533
+ try:
1534
+ source.rename(target) # a case-only change works too (same file)
1535
+ except OSError as error:
1536
+ problem = f"It could not be renamed: {error.strerror or error}"
1537
+ if problem is not None:
1538
+ QMessageBox.warning(self, "Could not rename", problem)
1539
+ return False
1540
+ self._paths_moved(source, target)
1541
+ return True
1542
+
1543
+ def rename_document(self, document) -> None:
1544
+ """Ask for a new name; if the document is named after its file, rename the file too.
1545
+
1546
+ A net opened from ``order.pnml`` (or saved as it) is called "order", so
1547
+ renaming it to "order v2" also renames the file to ``order v2.pnml``
1548
+ in the same folder. A document whose name is not its file's name (an
1549
+ event log is named by its ``concept:name``, e.g. "Wilma 50 passengers"
1550
+ in ``PlaneBoarding_Wilma_50.xes``) only gets a new name in the app.
1551
+ """
1552
+ if isinstance(document, ComparisonDocument):
1553
+ return
1554
+ old_name = document.name
1555
+ name, ok = QInputDialog.getText(self, "Rename", "Name:", text=old_name)
1556
+ name = name.strip()
1557
+ if not ok or not name or name == old_name:
1558
+ return
1559
+
1560
+ source = Path(document.path) if document.path else None
1561
+ renames_file = (source is not None and source.exists()
1562
+ and _file_stem(source) == old_name)
1563
+ if renames_file:
1564
+ if self.FILE_NAME_FORBIDDEN & set(name) or name in (".", ".."):
1565
+ QMessageBox.warning(self, "Could not rename", "A file name cannot contain any "
1566
+ "of / \\ : * ? \" < > |")
1567
+ return
1568
+ target = source.with_name(name + _file_suffix(source))
1569
+ if not self._rename_on_disk(source, target):
1570
+ return
1571
+ self.statusBar().showMessage(f"Renamed the file to {target.name}", 8000)
1572
+ elif isinstance(document, CpnDocument):
1573
+ # Only the name in the model changes: it is an edit, so it can be undone.
1574
+ self.pages[document.id]._checkpoint()
1575
+
1576
+ if isinstance(document, LogDocument):
1577
+ document.log.attributes["concept:name"] = name
1578
+ elif isinstance(document, TransitionSystemDocument):
1579
+ document.ts.name = name
1580
+ elif isinstance(document, WorkflowDocument):
1581
+ document.workflow.name = name
1582
+ document.dirty = True
1583
+ self._schedule_autosave(document)
1584
+ else:
1585
+ document.net.name = name
1586
+ if isinstance(document, CpnDocument) and not renames_file:
1587
+ self.pages[document.id].set_dirty(True)
1588
+ self._rebuild_sidebar()
1589
+ self.pages[document.id].refresh_title()
1590
+ self._set_title(document)
1591
+ self._refresh_log_choices()
1592
+ self._save_session()
1593
+
1594
+ def rename_path(self, path: str) -> None:
1595
+ """Rename a file that is not open, or a subfolder (the sidebar's Rename…)."""
1596
+ source = Path(path)
1597
+ folder = source.is_dir()
1598
+ old_name = source.name if folder else _file_stem(source)
1599
+ name, ok = QInputDialog.getText(self, "Rename", "Name:", text=old_name)
1600
+ name = name.strip()
1601
+ if not ok or not name or name == old_name:
1602
+ return
1603
+ target = source.with_name(name if folder else name + _file_suffix(source))
1604
+ if self._rename_on_disk(source, target):
1605
+ self.statusBar().showMessage(f"Renamed to {target.name}", 6000)
1606
+ self._rescan_workspace()
1607
+
1608
+ def _paths_moved(self, old: Path, new: Path) -> None:
1609
+ """A file or folder was moved or renamed (by the app): follow it.
1610
+
1611
+ Open documents in it get their new path (unsaved edits stay, with
1612
+ their dot), and so do the remembered recent files and expanded folders.
1613
+ """
1614
+ old, new = Path(old), Path(new)
1615
+
1616
+ base = old.resolve()
1617
+
1618
+ def moved(path: str) -> Path | None:
1619
+ candidate = Path(path).resolve()
1620
+ if candidate == base:
1621
+ return new
1622
+ try:
1623
+ return new / candidate.relative_to(base)
1624
+ except ValueError:
1625
+ return None
1626
+
1627
+ for document in self.documents:
1628
+ if document.path and (target := moved(document.path)) is not None:
1629
+ self._forget_recent(document.path)
1630
+ previous_key = self._key(document.path)
1631
+ document.path = str(target)
1632
+ if previous_key in self._own_writes:
1633
+ self._own_writes[self._key(target)] = self._own_writes.pop(previous_key)
1634
+ if previous_key in self._created: # still the app's own "Untitled 1"
1635
+ self._created.discard(previous_key)
1636
+ self._created.add(self._key(target))
1637
+ if getattr(document, "_last_path", None):
1638
+ document._last_path = str(target)
1639
+ self._remember_recent(document.path)
1640
+ self.pages[document.id].refresh_title()
1641
+ self._update_autosave(document)
1642
+ if self.workspace is not None:
1643
+ old_relative, new_relative = self.workspace.relative(old), self.workspace.relative(new)
1644
+ renamed = set()
1645
+ for relative in self._expanded:
1646
+ if relative == old_relative or relative.startswith(old_relative + "/"):
1647
+ renamed.add(new_relative + relative[len(old_relative):])
1648
+ else:
1649
+ renamed.add(relative)
1650
+ if renamed != self._expanded:
1651
+ self._expanded = renamed
1652
+ self.workspace.update_settings(expanded=sorted(self._expanded))
1653
+ self._save_session()
1654
+
1655
+ def export_selected(self) -> None:
1656
+ ids = self.selected_ids()
1657
+ if len(ids) == 1:
1658
+ self.pages[ids[0]].export()
1659
+
1660
+ def _document_saved(self, document) -> None:
1661
+ """A page saved or exported its document: it is now backed by that file."""
1662
+ previous = getattr(document, "_last_path", None)
1663
+ document._last_path = document.path
1664
+ signature = _signature(document.path) if document.path else None
1665
+ if signature is not None:
1666
+ self._own_writes[self._key(document.path)] = signature
1667
+ self._remember_identity(document) # a safe save is a new file on disk
1668
+ if previous and self._key(previous) != self._key(document.path) \
1669
+ and self._key(previous) in self._created and Path(previous).exists():
1670
+ # Save As of a net the app created ("Untitled 1.pnml"): the old file
1671
+ # was only ever the app's, so it does not stay behind as a duplicate.
1672
+ self._created.discard(self._key(previous))
1673
+ move_to_trash(previous)
1674
+ if isinstance(document, CpnDocument):
1675
+ self._openprocess_files.add(document.id) # OpenProcess wrote it: autosaving is safe
1676
+ if document.id in self._autosave_failed: # saving works again
1677
+ self._autosave_failed.discard(document.id)
1678
+ self.banners[document.id].clear("unsaved")
1679
+ self._update_autosave(document)
1680
+ banner = self.banners.get(document.id)
1681
+ if banner is not None:
1682
+ banner.clear("missing")
1683
+ if document.id in self.items:
1684
+ self._refresh_item(document)
1685
+ if self.selected_ids() == [document.id]:
1686
+ # Save As may have renamed it after the file (see CpnPage._take_file_name).
1687
+ self._set_title(document)
1688
+ self.open_options.pop(document.id, None) # it is XES/PNML now, not CSV
1689
+ if document.path and document.path.lower().endswith(".csv"):
1690
+ # Reopen the exported CSV with its own (standard) columns, unasked.
1691
+ mapping = guess_mapping(sniff(document.path)[1])
1692
+ if mapping is not None:
1693
+ self.open_options[document.id] = {"csv_mapping": asdict(mapping)}
1694
+ self._remember_recent(document.path)
1695
+ self._save_session()
1696
+
1697
+ def reveal(self, path: str) -> None:
1698
+ """Show the file in Finder / Explorer (selected), or open its folder."""
1699
+ if sys.platform == "darwin":
1700
+ subprocess.run(["open", "-R", path], check=False)
1701
+ elif sys.platform == "win32":
1702
+ subprocess.run(["explorer", "/select,", str(Path(path))], check=False)
1703
+ else: # Linux: no standard "select this file", so open the folder
1704
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(Path(path).parent)))
1705
+
1706
+ def _sidebar_menu(self, position) -> None:
1707
+ item = self.tree.itemAt(position)
1708
+ menu = QMenu(self)
1709
+ in_folder = self.workspace is not None
1710
+ if item is not None and item.data(0, FOLDER_ROLE):
1711
+ path = item.data(0, FOLDER_ROLE)
1712
+ if item.data(0, EXERCISE_ROLE):
1713
+ menu.addAction("Open Exercise", lambda: self.open_exercise(path))
1714
+ menu.addSeparator()
1715
+ menu.addAction("New Folder…", lambda: self.new_folder(path))
1716
+ menu.addAction("Rename…", lambda: self.rename_path(path))
1717
+ menu.addAction(reveal_label(), lambda: self.reveal(path))
1718
+ menu.addSeparator()
1719
+ menu.addAction(f"Move to {BIN}", lambda: self.trash_paths([path]))
1720
+ menu.exec(self.tree.viewport().mapToGlobal(position))
1721
+ return
1722
+ if item is not None and item.data(0, FILE_ROLE):
1723
+ path = item.data(0, FILE_ROLE)
1724
+ if item.data(0, CLOUD_ROLE):
1725
+ menu.addAction("Download from iCloud", lambda: self._open_placeholder(item))
1726
+ else:
1727
+ menu.addAction("Open", lambda: self._open_placeholder(item))
1728
+ menu.addAction("Rename…", lambda: self.rename_path(path))
1729
+ menu.addAction(reveal_label(), lambda: self.reveal(path))
1730
+ menu.addSeparator()
1731
+ menu.addAction(f"Move to {BIN}", lambda: self.trash_paths([path]))
1732
+ menu.exec(self.tree.viewport().mapToGlobal(position))
1733
+ return
1734
+ if item is not None and item.data(0, Qt.UserRole) is None:
1735
+ # A section header: offer to clear that section.
1736
+ section = item
1737
+ ids = [section.child(i).data(0, Qt.UserRole) for i in range(section.childCount())]
1738
+ kind = {id(self.logs_section): "Logs", id(self.petri_section): "Petri Nets",
1739
+ id(self.models_section): "Models", id(self.unsaved_section): "Unsaved",
1740
+ id(self.elsewhere_section): "Other Files",
1741
+ id(self.compare_section): "Comparisons",
1742
+ id(self.ts_section): "Transition Systems"}.get(id(section), "Coloured Nets")
1743
+ ids = [i for i in ids if i is not None] # not the "not open" rows
1744
+ if ids:
1745
+ menu.addAction(f"Close All {kind}", lambda: self.remove_documents(ids))
1746
+ else:
1747
+ if item is not None and not item.isSelected():
1748
+ self.tree.clearSelection()
1749
+ self.tree.setCurrentItem(item)
1750
+ ids = self.selected_ids() if item is not None else []
1751
+ if not ids:
1752
+ if in_folder:
1753
+ menu.addAction("New Folder…", lambda: self.new_folder())
1754
+ menu.addSeparator()
1755
+ menu.addAction("Open File…", self.action_open)
1756
+ menu.addAction("New Log from Notation…", self.action_notation)
1757
+ elif len(ids) == 1 and isinstance(self._document(ids[0]), ComparisonDocument):
1758
+ menu.addAction("Export Figures as CSV…", self.export_selected)
1759
+ menu.addSeparator()
1760
+ menu.addAction("Close", self.remove_selected)
1761
+ elif len(ids) == 1:
1762
+ document = self._document(ids[0])
1763
+ menu.addAction("Rename…", self.rename_selected)
1764
+ if isinstance(document, LogDocument) and len(self.logs()) > 1:
1765
+ menu.addAction("Compare with…", lambda d=document: self.action_compare([d]))
1766
+ if isinstance(document, CpnDocument):
1767
+ menu.addAction("Save", self.pages[document.id].save)
1768
+ label_text = "Save As…"
1769
+ elif isinstance(document, LogDocument):
1770
+ menu.addAction("Filter…", self.pages[document.id].filter_log)
1771
+ label_text = "Export Log (XES or CSV)…"
1772
+ elif isinstance(document, TransitionSystemDocument):
1773
+ label_text = "Export as Text…"
1774
+ else:
1775
+ if in_folder and not document.path:
1776
+ menu.addAction("Keep in Folder", lambda d=document: self.keep_model(d))
1777
+ label_text = "Export Model as PNML…"
1778
+ menu.addAction(label_text, self.export_selected)
1779
+ if document.path and not document.missing:
1780
+ menu.addAction(reveal_label(), lambda p=document.path: self.reveal(p))
1781
+ menu.addSeparator()
1782
+ menu.addAction("Close", self.remove_selected)
1783
+ if document.path and not document.missing:
1784
+ menu.addAction(f"Move to {BIN}", self.trash_selected)
1785
+ else:
1786
+ chosen = [self._document(i) for i in ids]
1787
+ if all(isinstance(d, LogDocument) for d in chosen):
1788
+ menu.addAction(f"Compare {len(ids)} Logs", lambda: self.compare(chosen))
1789
+ menu.addSeparator()
1790
+ menu.addAction(f"Close {len(ids)} Items", self.remove_selected)
1791
+ if all(d.path and not d.missing for d in chosen):
1792
+ menu.addAction(f"Move {len(ids)} Items to {BIN}", self.trash_selected)
1793
+ menu.exec(self.tree.viewport().mapToGlobal(position))
1794
+
1795
+ # -- session and recent files -----------------------------------------------------
1796
+ def _session_entries(self) -> list[dict]:
1797
+ entries = []
1798
+ for document in self.documents:
1799
+ if document.path:
1800
+ entry = {"path": document.path}
1801
+ entry.update(self.open_options.get(document.id, {}))
1802
+ entries.append(entry)
1803
+ # Files of a reopened workspace still loading in the background count as open.
1804
+ entries += list(self._restore_paths.values())
1805
+ return entries
1806
+
1807
+ def _save_session(self) -> None:
1808
+ """Remember what is open: in the workspace folder, or in the app's settings."""
1809
+ if self._restoring:
1810
+ return
1811
+ if self.workspace is not None:
1812
+ # Written even in tests (persist=False): it lives in the folder, not in
1813
+ # your settings.
1814
+ self.workspace.save_state(self._session_entries(), self._selected_path())
1815
+ if self.persist:
1816
+ self.settings.setValue("workspace/current", str(self.workspace.folder))
1817
+ elif self.persist:
1818
+ self.settings.setValue("session/files", json.dumps(self._session_entries()))
1819
+
1820
+ def _selected_path(self) -> str | None:
1821
+ ids = self.selected_ids()
1822
+ if len(ids) == 1:
1823
+ return self._document(ids[0]).path
1824
+ return None
1825
+
1826
+ def restore_session(self) -> None:
1827
+ """Reopen the files that were open when the app was last closed."""
1828
+ if not self.settings.value("session/restore", True, type=bool):
1829
+ return
1830
+ folder = self.settings.value("workspace/current", "") or ""
1831
+ if folder and Path(folder).is_dir():
1832
+ self.open_workspace(folder)
1833
+ return
1834
+ try:
1835
+ entries = json.loads(self.settings.value("session/files", "[]"))
1836
+ except (TypeError, ValueError):
1837
+ entries = []
1838
+ missing = []
1839
+ self._restoring = True
1840
+ try:
1841
+ for entry in entries:
1842
+ path = entry.get("path", "")
1843
+ if not Path(path).exists():
1844
+ missing.append(Path(path).name)
1845
+ continue
1846
+ self.open_path(path, csv_mapping=entry.get("csv_mapping"))
1847
+ finally:
1848
+ self._restoring = False
1849
+ self._save_session()
1850
+ if missing:
1851
+ self.statusBar().showMessage("Could not find: " + ", ".join(missing), 10000)
1852
+
1853
+ def _remember_recent(self, path: str) -> None:
1854
+ if not self.persist or not path:
1855
+ return
1856
+ recent = [p for p in self._recent() if Path(p) != Path(path)]
1857
+ recent.insert(0, path)
1858
+ self.settings.setValue("recent", json.dumps(recent[:self.RECENT_LIMIT]))
1859
+
1860
+ def _forget_recent(self, path: str) -> None:
1861
+ if self.persist and path:
1862
+ recent = [p for p in self._recent() if Path(p) != Path(path)]
1863
+ self.settings.setValue("recent", json.dumps(recent))
1864
+
1865
+ def _recent(self) -> list[str]:
1866
+ if not self.persist: # tests: never your recent files
1867
+ return []
1868
+ try:
1869
+ return list(json.loads(self.settings.value("recent", "[]")))
1870
+ except (TypeError, ValueError):
1871
+ return []
1872
+
1873
+ def _fill_recent_menu(self) -> None:
1874
+ self.recent_menu.clear()
1875
+ recent = [p for p in self._recent() if Path(p).exists()]
1876
+ if not recent:
1877
+ action = self.recent_menu.addAction("No Recent Files")
1878
+ action.setEnabled(False)
1879
+ return
1880
+ for path in recent:
1881
+ self.recent_menu.addAction(Path(path).name, lambda p=path: self.open_files([p])) \
1882
+ .setToolTip(path)
1883
+ self.recent_menu.addSeparator()
1884
+ self.recent_menu.addAction("Clear Menu", lambda: self.settings.setValue("recent", "[]"))
1885
+
1886
+ # ---------------------------------------------------------------- opening
1887
+ def action_open(self) -> None:
1888
+ paths, _ = QFileDialog.getOpenFileNames(
1889
+ self, "Open", dialog_folder(),
1890
+ "Supported files (*.xes *.gz *.csv *.pnml *.cpn);;Event logs (*.xes *.gz *.csv);;"
1891
+ "Petri nets (*.pnml);;CPN Tools models (*.cpn);;All files (*)")
1892
+ self.open_files(paths)
1893
+
1894
+ def action_open_cpn(self) -> None:
1895
+ path, _ = QFileDialog.getOpenFileName(self, "Open coloured Petri net", dialog_folder(),
1896
+ "CPN Tools models (*.cpn)")
1897
+ if path:
1898
+ self.open_files([path])
1899
+
1900
+ def action_notation(self) -> None:
1901
+ dialog = NotationDialog(self)
1902
+ if dialog.exec() == QDialog.Accepted:
1903
+ self.add_document(LogDocument(dialog.log(), notation=dialog.text()))
1904
+
1905
+ def open_files(self, paths: list[str], folder: str | None = None) -> None:
1906
+ """Open files the user chose (Open…, Open Recent, a drop, the command line).
1907
+
1908
+ With a folder open, files from elsewhere are first copied or moved
1909
+ into it (or ``folder``, a subfolder), or opened where they are, as the
1910
+ user says (once, or always for this folder): see :meth:`_bring_in`.
1911
+ """
1912
+ outside = [p for p in paths if self.workspace is not None and Path(p).is_file()
1913
+ and not self.workspace.contains(p)]
1914
+ for path in paths:
1915
+ if path not in outside:
1916
+ self.open_path(path)
1917
+ if outside:
1918
+ for path in self._bring_in(outside, folder):
1919
+ self.open_path(path)
1920
+
1921
+ def _import_choice(self, names: list[str], allow_open: bool) -> str | None:
1922
+ """Copy, move or open in place? The folder's choice, the app's, or the user's."""
1923
+ remembered = self.workspace.settings().get("import")
1924
+ if remembered not in IMPORT_CHOICES:
1925
+ remembered = self._setting("files/import", "ask")
1926
+ if remembered in ("copy", "move"):
1927
+ return remembered
1928
+ if remembered == "open":
1929
+ return "open" if allow_open else "copy"
1930
+ dialog = ImportDialog(names, self.workspace.name, self, allow_open=allow_open)
1931
+ if dialog.exec() != QDialog.Accepted:
1932
+ return None
1933
+ choice = dialog.choice()
1934
+ if dialog.always.isChecked():
1935
+ self.workspace.update_settings(**{"import": choice})
1936
+ return choice
1937
+
1938
+ def _bring_in(self, paths: list[str], folder: str | None = None) -> list[str]:
1939
+ """Copy or move files from elsewhere into the open folder; the paths to open.
1940
+
1941
+ ``folder``: the subfolder they were dropped on (then "open where it
1942
+ is" is not on offer: the drop says where they go). A file with the
1943
+ same name already there is never overwritten silently: the user
1944
+ keeps both (``"name 2.xes"``) or replaces it (the old one goes to the Bin).
1945
+ """
1946
+ target_folder = Path(folder) if folder else self.workspace.folder
1947
+ choice = self._import_choice([Path(p).name for p in paths],
1948
+ allow_open=folder is None
1949
+ or Path(folder).resolve() == self.workspace.folder)
1950
+ if choice is None:
1951
+ return []
1952
+ if choice == "open":
1953
+ return list(paths)
1954
+ result = []
1955
+ for source in map(Path, paths):
1956
+ target = target_folder / source.name
1957
+ if target.exists():
1958
+ if _same_file(source, target):
1959
+ result.append(str(target))
1960
+ continue
1961
+ answer = ask_about_clash(self, target)
1962
+ if answer is None:
1963
+ continue
1964
+ if answer == "keep":
1965
+ target = unique_path(target_folder, source.name)
1966
+ elif not move_to_trash(target):
1967
+ QMessageBox.warning(self, "Could not replace", f"“{target.name}” could not "
1968
+ f"be moved to the {BIN}, so it was left as it is.")
1969
+ continue
1970
+ try:
1971
+ if choice == "move":
1972
+ shutil.move(str(source), str(target))
1973
+ self._forget_recent(str(source))
1974
+ else:
1975
+ shutil.copy2(source, target) # keeps the file's dates
1976
+ except OSError as error:
1977
+ QMessageBox.warning(self, "Could not add the file",
1978
+ f"{source.name}\n\n{error.strerror or error}")
1979
+ continue
1980
+ result.append(str(target))
1981
+ if result:
1982
+ verb = "Moved" if choice == "move" else "Copied"
1983
+ where = target_folder.name
1984
+ self.statusBar().showMessage(
1985
+ f"{verb} {Path(result[0]).name if len(result) == 1 else f'{len(result)} files'} "
1986
+ f"into {where}", 8000)
1987
+ self._rescan_workspace()
1988
+ return result
1989
+
1990
+ def open_path(self, path: str, csv_mapping: dict | None = None) -> None:
1991
+ lower = path.lower()
1992
+ name = Path(path).name
1993
+ if lower.endswith(".icloud") and Path(path).name.startswith("."):
1994
+ self._download_from_icloud(Path(path).with_name(name[1:-len(".icloud")]), Path(path))
1995
+ return
1996
+ size = _signature(path)
1997
+ try:
1998
+ if lower.endswith(".cpnflow"):
1999
+ from ...flow.record import load as load_workflow
2000
+ workflow, record = load_workflow(path, self.library())
2001
+ self.add_document(WorkflowDocument(workflow, path=path, record=record))
2002
+ self.statusBar().showMessage(f"Opened {name}: click a box to see its result and how it "
2003
+ "got there", 10000)
2004
+ elif lower.endswith(".pnml"):
2005
+ self.add_document(CpnDocument(self._read_net(path), path=path))
2006
+ self.statusBar().showMessage(f"Opened {name} — edit it, play the token game, "
2007
+ "or see the Analysis tab for soundness and more",
2008
+ 10000)
2009
+ elif lower.endswith(".cpn"):
2010
+ self.statusBar().showMessage(f"Reading {name}…")
2011
+ net = self._read_net(path)
2012
+ self.add_document(CpnDocument(net, path=path))
2013
+ problems = len(net.errors)
2014
+ self.statusBar().showMessage(
2015
+ f"Opened {name}" + (f" — {problems} problem(s), see the Problems tab"
2016
+ if problems else ""), 10000)
2017
+ elif lower.endswith(".txt") and file_kind(Path(path)) in ("log", "ts"):
2018
+ self.add_document(self._read_text_file(path))
2019
+ elif lower.endswith(".csv"):
2020
+ if csv_mapping is not None: # reopening: reuse the saved mapping
2021
+ mapping = ColumnMapping(**csv_mapping)
2022
+ else:
2023
+ dialog = CsvDialog(path, self)
2024
+ if dialog.exec() != QDialog.Accepted:
2025
+ self._opening.discard(self._key(path))
2026
+ self._rebuild_sidebar()
2027
+ return
2028
+ mapping = dialog.mapping()
2029
+ log = read_csv(path, mapping)
2030
+ self.add_document(LogDocument(log, path=path),
2031
+ options={"csv_mapping": asdict(mapping)})
2032
+ elif lower.endswith((".xes", ".xes.gz", ".gz")):
2033
+ self.statusBar().showMessage(f"Reading {name}…")
2034
+ folder = self._folder_switches
2035
+
2036
+ def loaded(log) -> None:
2037
+ if folder != self._folder_switches:
2038
+ # The folder was switched or closed while this was
2039
+ # loading: it belongs to the folder that was left.
2040
+ self._opening.discard(self._key(path))
2041
+ return
2042
+ self.add_document(LogDocument(log, path=path))
2043
+ self.statusBar().showMessage(f"Opened {name}: {len(log):,} cases", 6000)
2044
+
2045
+ run_in_background(lambda: read_xes(path), loaded,
2046
+ lambda message: self._could_not_open(path, message, size))
2047
+ else:
2048
+ QMessageBox.information(self, "Unsupported file",
2049
+ f"{name}: open .xes, .csv, .pnml or .cpn files (or a "
2050
+ "log in textbook notation named log.txt or "
2051
+ "….log.txt, a transition system named ts.txt or "
2052
+ "….ts.txt).")
2053
+ except Exception as error: # noqa: BLE001 - surface any import problem
2054
+ self._could_not_open(path, str(error), size)
2055
+
2056
+ def _could_not_open(self, path: str, message: str, before: tuple | None) -> None:
2057
+ self._opening.discard(self._key(path))
2058
+ self._rebuild_sidebar()
2059
+ name = Path(path).name
2060
+ if before is not None and _signature(path) != before:
2061
+ # A big file still being copied in (from Finder, a download, iCloud):
2062
+ # what was read was only part of it.
2063
+ self.statusBar().showMessage(f"{name} is still being copied — open it again when "
2064
+ "it has finished", 10000)
2065
+ return
2066
+ QMessageBox.warning(self, "Could not open file", f"{name}\n\n{message}")
2067
+
2068
+ @staticmethod
2069
+ def _read_text_file(path: str):
2070
+ """A log in textbook notation (``log.txt``) or a transition system (``ts.txt``)."""
2071
+ from ...mining.transition_system import parse_transition_system
2072
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
2073
+ stem = display_name(Path(path).name)
2074
+ if Path(path).name.lower() in ("log.txt", "ts.txt"):
2075
+ stem = Path(path).parent.name or stem # "log" says little; its folder more
2076
+ if file_kind(Path(path)) == "ts":
2077
+ return TransitionSystemDocument(parse_transition_system(text, stem), path=path)
2078
+ return LogDocument(EventLog.from_simple_log(parse_simple_log(text), stem), path=path,
2079
+ notation=text.strip())
2080
+
2081
+ @staticmethod
2082
+ def _read_net(path: str):
2083
+ """The net in a .pnml or .cpn file, as the editor edits it."""
2084
+ if path.lower().endswith(".pnml"):
2085
+ from ...model.plain import from_petri_net
2086
+ petri = read_pnml(path)
2087
+ petri.name = _file_stem(Path(path))
2088
+ return from_petri_net(petri)
2089
+ from ...io.cpn_reader import read_cpn
2090
+ return read_cpn(path)
2091
+
2092
+ # ---------------------------------------------------------------- comparing
2093
+ def action_compare(self, preselected: list | None = None) -> None:
2094
+ """Compare the selected logs, or ask which logs to compare."""
2095
+ logs = self.logs()
2096
+ if len(logs) < 2:
2097
+ QMessageBox.information(self, "Compare logs", "Open at least two event logs to "
2098
+ "compare them.")
2099
+ return
2100
+ if preselected is None:
2101
+ chosen = [self._document(i) for i in self.selected_ids()]
2102
+ chosen = [d for d in chosen if isinstance(d, LogDocument)]
2103
+ if len(chosen) >= 2:
2104
+ self.compare(chosen)
2105
+ return
2106
+ preselected = chosen
2107
+ dialog = CompareDialog(logs, preselected, self)
2108
+ if dialog.exec() == QDialog.Accepted:
2109
+ self.compare(dialog.chosen())
2110
+
2111
+ def compare(self, logs: list) -> None:
2112
+ for existing in self.documents:
2113
+ if isinstance(existing, ComparisonDocument) and \
2114
+ [d.id for d in existing.logs] == [d.id for d in logs]:
2115
+ self.tree.setCurrentItem(self.items[existing.id])
2116
+ return
2117
+ self.add_document(ComparisonDocument(list(logs)))
2118
+
2119
+ def action_new_petri(self) -> None:
2120
+ from ...model.plain import new_plain_net
2121
+ self.add_document(CpnDocument(new_plain_net(self._unique_cpn_name())))
2122
+ self.statusBar().showMessage("New Petri net: pick Place or Transition and click on the "
2123
+ "canvas; drag from one node to another to connect them",
2124
+ 10000)
2125
+
2126
+ def open_example_net(self, net) -> None:
2127
+ from ...model.plain import from_petri_net
2128
+ self.add_document(CpnDocument(from_petri_net(net)))
2129
+
2130
+ def edit_petri_net(self, net) -> None:
2131
+ """Open a (discovered or imported) Petri net in the editor, as a copy."""
2132
+ from ...model.plain import from_petri_net
2133
+ editable = from_petri_net(net)
2134
+ editable.name = f"{net.name} (edited)"
2135
+ self.add_document(CpnDocument(editable))
2136
+
2137
+ def library(self):
2138
+ """The boxes available in this window (the folder's boxes/ included)."""
2139
+ from ...flow.library import library_for
2140
+ folder = self.workspace.folder if self.workspace is not None else None
2141
+ signature = self._boxes_files(folder)
2142
+ if self._library is None or signature != self._boxes_signature:
2143
+ self._library = library_for(folder)
2144
+ self._boxes_signature = signature
2145
+ return self._library
2146
+
2147
+ @staticmethod
2148
+ def _boxes_files(folder) -> tuple:
2149
+ if folder is None:
2150
+ return ()
2151
+ boxes = Path(folder) / "boxes"
2152
+ if not boxes.is_dir():
2153
+ return ()
2154
+ try:
2155
+ return tuple(sorted((p.name, p.stat().st_mtime_ns) for p in boxes.glob("*.py")))
2156
+ except OSError:
2157
+ return ()
2158
+
2159
+ def _reload_boxes(self) -> None:
2160
+ """The folder's boxes/ changed: open workflow pages get the new boxes."""
2161
+ if self.workspace is None:
2162
+ return
2163
+ signature = self._boxes_files(self.workspace.folder)
2164
+ if signature == self._boxes_signature:
2165
+ return
2166
+ self._boxes_signature = signature
2167
+ if self._library is not None:
2168
+ self._library.load_folder(Path(self.workspace.folder) / "boxes")
2169
+ for document in self.documents:
2170
+ if isinstance(document, WorkflowDocument):
2171
+ page = self.pages.get(document.id)
2172
+ if page is not None:
2173
+ page.reload_library()
2174
+ self.statusBar().showMessage("Reloaded the folder's boxes", 6000)
2175
+
2176
+ def action_new_workflow(self, build=None) -> None:
2177
+ """A new workflow from a template (or empty), on the first log of the folder."""
2178
+ from ...flow.workflow import Workflow
2179
+ folder = self.workspace.folder if self.workspace is not None else None
2180
+ library = self.library()
2181
+ if build is None:
2182
+ workflow = Workflow(self._unique_cpn_name(), library)
2183
+ else:
2184
+ workflow = build(library, first_log(folder))
2185
+ workflow.name = self._unique_name(workflow.name)
2186
+ self.add_document(WorkflowDocument(workflow))
2187
+ self.statusBar().showMessage("New workflow: click a box to see what it gives, drag from a dot to "
2188
+ "connect boxes, double-click the canvas to add one", 10000)
2189
+
2190
+ # -- quick actions: the pages make workflows ----------------------------------
2191
+ def _log_box(self, workflow, document, position=(0.0, 150.0)):
2192
+ """An Open log box for a log of the folder, else a Typed log of its notation."""
2193
+ from ...mining.log import format_simple_log
2194
+ if document.path and self.workspace is not None and self.workspace.contains(document.path):
2195
+ relative = self.workspace.relative(document.path)
2196
+ return workflow.add("open_log", {"file": relative}, position)
2197
+ if document.path and Path(document.path).is_file():
2198
+ return workflow.add("open_log", {"file": document.path}, position)
2199
+ text = document.notation or format_simple_log(document.simple_log())
2200
+ self.statusBar().showMessage("The log has no file, so the workflow holds it as typed notation "
2201
+ "(activities only)", 8000)
2202
+ return workflow.add("typed_log", {"text": text, "name": document.name}, position)
2203
+
2204
+ def workflow_from_log(self, document, spec: dict) -> None:
2205
+ """The Discover tab's choice as a workflow: log → miner → Check fit."""
2206
+ from ...flow.workflow import Workflow
2207
+ workflow = Workflow(self._unique_name(f"{document.name} discovery"), self.library())
2208
+ log = self._log_box(workflow, document)
2209
+ algorithm = spec.get("algorithm", "im")
2210
+ if algorithm == "alpha":
2211
+ miner = workflow.add("alpha_miner", {}, (240.0, 150.0))
2212
+ elif algorithm == "imf":
2213
+ miner = workflow.add("inductive_miner", {"noise": spec.get("noise", 0.2)}, (240.0, 150.0))
2214
+ elif algorithm in ("heuristics", "heuristics_net"):
2215
+ miner = workflow.add("heuristics_miner", {"dependency": spec.get("dependency", 0.9)}, (240.0, 150.0))
2216
+ elif algorithm == "regions":
2217
+ states = workflow.add("classical_states", {
2218
+ "direction": spec.get("direction", "prefix"), "representation": spec.get("representation", "set"),
2219
+ "horizon": str(spec["horizon"]) if spec.get("horizon") in (1, 2, 3) else "all"}, (240.0, 150.0))
2220
+ workflow.connect(log, states)
2221
+ miner = workflow.add("regions_to_net", {}, (480.0, 150.0))
2222
+ workflow.connect(states, miner)
2223
+ else:
2224
+ miner = workflow.add("inductive_miner", {"noise": 0.0}, (240.0, 150.0))
2225
+ if algorithm != "regions":
2226
+ workflow.connect(log, miner)
2227
+ fit = workflow.add("check_fit", {}, (720.0 if algorithm == "regions" else 480.0, 130.0))
2228
+ workflow.connect(miner, fit, "model")
2229
+ workflow.connect(log, fit, "log")
2230
+ self.add_document(WorkflowDocument(workflow), near=document.path)
2231
+
2232
+ def workflow_from_model(self, document, log_document) -> None:
2233
+ """A model and a log as a workflow with a Check fit box."""
2234
+ from ...flow.workflow import Workflow
2235
+ from ...mining.discovery.alpha import AlphaResult
2236
+ from ...mining.discovery.heuristics import HeuristicsResult
2237
+ from ...mining.discovery.inductive import InductiveResult
2238
+ workflow = Workflow(self._unique_name(f"{document.name} fit"), self.library())
2239
+ if document.path and Path(document.path).is_file():
2240
+ file = self.workspace.relative(document.path) if self.workspace is not None and \
2241
+ self.workspace.contains(document.path) else document.path
2242
+ model = workflow.add("open_net", {"file": file}, (240.0, 60.0))
2243
+ elif isinstance(document.derivation, (AlphaResult, InductiveResult, HeuristicsResult)) \
2244
+ and document.source_log is not None:
2245
+ source = self._log_box(workflow, document.source_log, (0.0, 60.0))
2246
+ box, settings = {"AlphaResult": ("alpha_miner", {}),
2247
+ "InductiveResult": ("inductive_miner", {}),
2248
+ "HeuristicsResult": ("heuristics_miner", {})}[type(document.derivation).__name__]
2249
+ model = workflow.add(box, settings, (240.0, 60.0))
2250
+ workflow.connect(source, model)
2251
+ else:
2252
+ self.statusBar().showMessage("Keep the model first (so it has a file), then use it in a workflow",
2253
+ 8000)
2254
+ return
2255
+ fit = workflow.add("check_fit", {}, (480.0, 130.0))
2256
+ workflow.connect(model, fit, "model")
2257
+ if log_document is not None:
2258
+ log = self._log_box(workflow, log_document, (240.0, 220.0))
2259
+ workflow.connect(log, fit, "log")
2260
+ self.add_document(WorkflowDocument(workflow), near=document.path)
2261
+
2262
+ def workflow_from_net(self, document) -> None:
2263
+ """A drawn net on the workflow canvas: Open net, a log, Check fit (a coloured net: Simulate CPN)."""
2264
+ from ...flow.workflow import Workflow
2265
+ if not document.path or not Path(document.path).is_file():
2266
+ self.statusBar().showMessage("Save the net first (so it has a file), then use it in a workflow", 8000)
2267
+ return
2268
+ self._flush_autosaves([document.id])
2269
+ file = self.workspace.relative(document.path) if self.workspace is not None and \
2270
+ self.workspace.contains(document.path) else document.path
2271
+ workflow = Workflow(self._unique_name(f"{document.name} workflow"), self.library())
2272
+ if getattr(document.net, "plain", False):
2273
+ net = workflow.add("open_net", {"file": file}, (0.0, 60.0))
2274
+ fit = workflow.add("check_fit", {}, (480.0, 130.0))
2275
+ workflow.connect(net, fit, "model")
2276
+ logs = self.logs()
2277
+ if logs:
2278
+ log = self._log_box(workflow, logs[0], (0.0, 220.0))
2279
+ workflow.connect(log, fit, "log")
2280
+ else:
2281
+ log = workflow.add("simulate_log", {"cases": 100, "seed": 0}, (240.0, 220.0))
2282
+ workflow.connect(net, log)
2283
+ workflow.connect(log, fit, "log")
2284
+ else:
2285
+ net = workflow.add("open_cpn", {"file": file}, (0.0, 120.0))
2286
+ simulate = workflow.add("simulate_cpn", {"steps": 500, "seed": 0}, (240.0, 120.0))
2287
+ miner = workflow.add("inductive_miner", {}, (480.0, 120.0))
2288
+ workflow.connect(net, simulate)
2289
+ workflow.connect(simulate, miner)
2290
+ self.add_document(WorkflowDocument(workflow), near=document.path)
2291
+
2292
+ def _unique_name(self, base: str) -> str:
2293
+ names = {d.name for d in self.documents}
2294
+ if self.workspace is not None:
2295
+ names |= {f.path.stem for folder in (self._tree.walk() if self._tree else []) for f in folder.files}
2296
+ name, index = base, 2
2297
+ while name in names:
2298
+ name = f"{base} {index}"
2299
+ index += 1
2300
+ return name
2301
+
2302
+ def keep_workflow(self, document) -> None:
2303
+ """Save a workflow that is not in the folder yet (the Keep button)."""
2304
+ if self.workspace is None or document.path:
2305
+ self.pages[document.id].export()
2306
+ return
2307
+ target = unique_path(self.workspace.folder, safe_file_name(document.name) + ".cpnflow")
2308
+ self.pages[document.id].write_to(str(target), quiet=True)
2309
+ self.statusBar().showMessage(f"Kept as {target.name}", 8000)
2310
+ self._rebuild_sidebar()
2311
+
2312
+ def action_new_cpn(self) -> None:
2313
+ from ...model.net import CPNet
2314
+ net = CPNet(self._unique_cpn_name())
2315
+ net.add_declaration("colset UNIT = unit;")
2316
+ net.add_declaration("colset INT = int;")
2317
+ net.add_page("Top")
2318
+ document = CpnDocument(net)
2319
+ self.add_document(document)
2320
+ self.statusBar().showMessage("New coloured Petri net: pick Place or Transition and "
2321
+ "click on the canvas to draw", 8000)
2322
+
2323
+ def _unique_cpn_name(self) -> str:
2324
+ names = {d.name for d in self.documents}
2325
+ index = 1
2326
+ while f"Untitled {index}" in names:
2327
+ index += 1
2328
+ return f"Untitled {index}"
2329
+
2330
+ def action_save(self) -> None:
2331
+ page = self.current_page()
2332
+ if isinstance(page, (CpnPage, WorkflowPage)):
2333
+ page.save()
2334
+ elif page is not None:
2335
+ page.export()
2336
+
2337
+ def closeEvent(self, event) -> None: # noqa: N802
2338
+ self._flush_autosaves()
2339
+ self.notes.flush()
2340
+ if self.learn_mode.view is not None:
2341
+ self.learn_mode.view.flush()
2342
+ dirty = [d for d in self.documents if isinstance(d, (CpnDocument, WorkflowDocument)) and d.dirty]
2343
+ if dirty:
2344
+ box = QMessageBox(self)
2345
+ box.setIcon(QMessageBox.Warning)
2346
+ box.setWindowTitle("Unsaved changes")
2347
+ box.setText("Save changes to " + ", ".join(f"“{d.name}”" for d in dirty[:4])
2348
+ + (" and others" if len(dirty) > 4 else "") + " before quitting?")
2349
+ save = box.addButton("Save", QMessageBox.AcceptRole)
2350
+ discard = box.addButton("Don’t Save", QMessageBox.DestructiveRole)
2351
+ box.addButton(QMessageBox.Cancel)
2352
+ box.exec()
2353
+ clicked = box.clickedButton()
2354
+ if clicked is save:
2355
+ if not all(self.pages[d.id].save() for d in dirty):
2356
+ event.ignore()
2357
+ return
2358
+ elif clicked is not discard:
2359
+ event.ignore()
2360
+ return
2361
+ self._save_session()
2362
+ event.accept()
2363
+ if self._installer is not None: # an update is ready: swap the apps now
2364
+ from . import updates
2365
+ updates.run_detached(self._installer)
2366
+
2367
+ # ---------------------------------------------------------------- updates
2368
+ def check_automatically(self) -> None:
2369
+ """At launch: look for a newer version, quietly unless there is one.
2370
+
2371
+ Every time the app opens, so nobody misses a release; "Skip This
2372
+ Version" in the dialog keeps a version from being offered again.
2373
+ """
2374
+ if not self.persist or not self._setting("updates/automatic", True):
2375
+ return
2376
+ self.check_for_updates(manual=False)
2377
+
2378
+ def check_for_updates(self, manual: bool = True) -> None:
2379
+ """Help ▸ Check for Updates…: compare with the latest release on GitHub.
2380
+
2381
+ ``manual=False`` (the check at launch) says nothing unless there is a
2382
+ new version the user has not skipped, and nothing at all when offline.
2383
+ """
2384
+ from . import updates
2385
+ if self._checking_updates:
2386
+ return
2387
+ self._checking_updates = True
2388
+ if manual:
2389
+ self.statusBar().showMessage("Checking for updates…")
2390
+
2391
+ def failed(message: str) -> None:
2392
+ self._checking_updates = False
2393
+ if manual:
2394
+ self.statusBar().clearMessage()
2395
+ QMessageBox.warning(self, "Software Update", "Could not check for updates "
2396
+ f"(are you online?).\n\n{message}")
2397
+
2398
+ run_in_background(updates.fetch_latest,
2399
+ lambda release: self._update_found(release, manual), failed)
2400
+
2401
+ def _update_found(self, release, manual: bool) -> None:
2402
+ from ... import __version__
2403
+ from . import updates
2404
+ self._checking_updates = False
2405
+ if manual:
2406
+ self.statusBar().clearMessage()
2407
+ if release.prerelease or not updates.is_newer(release.version):
2408
+ if manual:
2409
+ QMessageBox.information(self, "Software Update", "You have the latest version "
2410
+ f"of OpenProcess ({__version__}).")
2411
+ return
2412
+ if not manual and self._setting("updates/skipped", "") == release.version:
2413
+ return
2414
+ if manual:
2415
+ self._show_update_dialog(release) # asked for: the dialog with the notes
2416
+ else:
2417
+ # At launch: a bar at the top that does not stop anyone working.
2418
+ self.update_bar.offer(release, self._can_install(release))
2419
+
2420
+ def _can_install(self, release) -> bool:
2421
+ from . import updates
2422
+ return updates.installed_app() is not None and release.download_for() is not None
2423
+
2424
+ def _show_update_dialog(self, release) -> None:
2425
+ """What's new in ``release`` (and the versions before it), and what to do."""
2426
+ from . import updates
2427
+ dialog = updates.UpdateDialog(release, self._can_install(release), self)
2428
+ dialog.exec()
2429
+ self._update_action(release, {dialog.SKIP: "skip", dialog.PAGE: "page",
2430
+ dialog.INSTALL: "install"}.get(dialog.outcome))
2431
+
2432
+ def _update_action(self, release, action: str | None) -> None:
2433
+ if action is None: # Later
2434
+ return
2435
+ self.update_bar.hide()
2436
+ if action == "skip":
2437
+ self._set_setting("updates/skipped", release.version)
2438
+ self.statusBar().showMessage(f"OpenProcess {release.version} skipped — you will be told "
2439
+ "about the next version", 6000)
2440
+ elif action == "page":
2441
+ QDesktopServices.openUrl(QUrl(release.page))
2442
+ elif not self._can_install(release): # "How to Update" (run from source)
2443
+ self._show_update_dialog(release)
2444
+ else:
2445
+ self._install_update(release)
2446
+
2447
+ def _install_update(self, release) -> None:
2448
+ """Download and unpack the update in the background, then restart into it."""
2449
+ from PySide6.QtWidgets import QProgressDialog
2450
+ from . import updates
2451
+ progress = QProgressDialog(f"Downloading OpenProcess {release.version}…", "Cancel", 0, 100,
2452
+ self)
2453
+ progress.setWindowTitle("Software Update")
2454
+ progress.setWindowModality(Qt.WindowModal)
2455
+ progress.setMinimumDuration(0)
2456
+ # By default the dialog resets and hides itself at 100 %; with the
2457
+ # value set to 100 again every 0.1 s while the download is checked and
2458
+ # unpacked, it kept popping up and vanishing. It stays up until done.
2459
+ progress.setAutoReset(False)
2460
+ progress.setAutoClose(False)
2461
+ self.update_progress = progress
2462
+ state = {"done": 0, "total": 0, "cancelled": False, "unpacking": False}
2463
+ progress.canceled.connect(lambda: state.__setitem__("cancelled", True))
2464
+
2465
+ def tick() -> None:
2466
+ if state["unpacking"]:
2467
+ return
2468
+ if state["total"] and state["done"] >= state["total"]:
2469
+ # Downloaded: now checked and unpacked, which cannot be
2470
+ # stopped half-way -- a busy bar, no Cancel.
2471
+ state["unpacking"] = True
2472
+ progress.setLabelText(f"Preparing OpenProcess {release.version}…")
2473
+ progress.setCancelButton(None)
2474
+ progress.setRange(0, 0)
2475
+ else:
2476
+ progress.setValue(int(100 * state["done"] / state["total"])
2477
+ if state["total"] else 0)
2478
+ poll = QTimer(self)
2479
+ poll.timeout.connect(tick)
2480
+ poll.start(100)
2481
+ progress.show()
2482
+
2483
+ def finish() -> None:
2484
+ poll.stop()
2485
+ progress.close()
2486
+
2487
+ def failed(message: str) -> None:
2488
+ finish()
2489
+ if not state["cancelled"]:
2490
+ QMessageBox.warning(self, "Software Update", "The update could not be "
2491
+ f"installed.\n\n{message}\n\nYou can download it from "
2492
+ f"{release.page}")
2493
+
2494
+ def ready(command: list[str]) -> None:
2495
+ finish()
2496
+ box = QMessageBox(self)
2497
+ box.setWindowTitle("Software Update")
2498
+ box.setText(f"OpenProcess {release.version} is ready.")
2499
+ box.setInformativeText("OpenProcess quits and starts again to finish updating. Your "
2500
+ "open files come back.")
2501
+ restart = box.addButton("Restart Now", QMessageBox.AcceptRole)
2502
+ box.addButton(QMessageBox.Cancel)
2503
+ box.exec()
2504
+ if box.clickedButton() is restart:
2505
+ self._installer = command
2506
+ if self.close():
2507
+ QApplication.quit()
2508
+ return
2509
+ self._installer = None # quitting was cancelled
2510
+ # Not installed: tidy up the unpacked copy.
2511
+ work = Path(command[-1]).parent
2512
+ if work.name.startswith(".openprocess-update-"):
2513
+ shutil.rmtree(work, ignore_errors=True)
2514
+
2515
+ run_in_background(lambda: updates.prepare(
2516
+ release, progress=lambda done, total: state.update(done=done, total=total),
2517
+ cancelled=lambda: state["cancelled"]), ready, failed)
2518
+
2519
+ # ---------------------------------------------------------------- folders
2520
+ def action_open_workspace(self) -> None:
2521
+ start = str(self.workspace.folder.parent) if self.workspace else dialog_folder()
2522
+ folder = QFileDialog.getExistingDirectory(self, "Open folder",
2523
+ start or str(Path.home()))
2524
+ if folder:
2525
+ self.open_workspace(folder)
2526
+
2527
+ def open_workspace(self, folder: str) -> bool:
2528
+ """Work in ``folder``: list its logs and nets, reopen what was open there.
2529
+
2530
+ Documents that are not in the folder are closed first (asking about
2531
+ unsaved changes), as when switching projects in other editors; files
2532
+ of the folder that are already open stay open.
2533
+ """
2534
+ workspace = Workspace(folder)
2535
+ if not workspace.exists():
2536
+ QMessageBox.warning(self, "Open folder", f"{folder}\n\nThis folder no longer "
2537
+ "exists.")
2538
+ self._forget_workspace(folder)
2539
+ return False
2540
+ if self.workspace is not None and self.workspace.folder == workspace.folder:
2541
+ return True
2542
+ self.notes.flush() # the old folder's notes, as they were
2543
+ self._save_session() # the old folder, as it was
2544
+ outside = [d.id for d in self.documents
2545
+ if not (d.path and workspace.contains(d.path))]
2546
+ self._restoring = True # closing is not a change to remember
2547
+ try:
2548
+ if outside and not self.remove_documents(outside):
2549
+ return False
2550
+ finally:
2551
+ self._restoring = False
2552
+ self._stop_watching()
2553
+ self._folder_switches += 1
2554
+
2555
+ self.workspace = workspace
2556
+ self._show_notes_of_folder()
2557
+ settings = workspace.settings()
2558
+ self.view_mode = "kind" if settings.get("view") == "kind" else "folder"
2559
+ self._expanded = {e for e in settings.get("expanded", []) if isinstance(e, str)}
2560
+ self.view_switch.blockSignals(True)
2561
+ self.view_switch.set_index(0 if self.view_mode == "folder" else 1)
2562
+ self.view_switch.blockSignals(False)
2563
+ set_dialog_folder(str(workspace.folder))
2564
+ self._remember_workspace(str(workspace.folder))
2565
+ self._show_workspace_header()
2566
+ for document in self.documents: # files of this folder that stayed open
2567
+ self._update_autosave(document)
2568
+ self._rescan_workspace(force=True)
2569
+
2570
+ state = workspace.load_state()
2571
+ entries = [e for e in state["open"] if Path(e["path"]).exists()]
2572
+ self._restore_paths = {self._key(e["path"]): e for e in entries}
2573
+ selected = state["selected"]
2574
+ self._restore_selected = (self._key(selected) if selected and
2575
+ self._key(selected) in self._restore_paths else None)
2576
+ self._restoring = True
2577
+ try:
2578
+ for entry in entries:
2579
+ self.open_path(entry["path"], csv_mapping=entry.get("csv_mapping"))
2580
+ finally:
2581
+ self._restoring = False
2582
+ self._save_session()
2583
+ if not self.documents:
2584
+ self.content.setCurrentIndex(0)
2585
+ self._set_title(self._current_document())
2586
+ count = len(self.placeholders) + sum(1 for d in self.documents if d.path)
2587
+ self.statusBar().showMessage(
2588
+ f"Opened {workspace.name}: "
2589
+ f"{count} file{'s' if count != 1 else ''} to work with", 6000)
2590
+ return True
2591
+
2592
+ def close_workspace(self) -> bool:
2593
+ """Close every document and go back to working with loose files."""
2594
+ if self.workspace is None:
2595
+ return True
2596
+ self.notes.flush()
2597
+ self._save_session()
2598
+ self._restoring = True
2599
+ try:
2600
+ if not self.remove_documents([d.id for d in self.documents]):
2601
+ return False
2602
+ finally:
2603
+ self._restoring = False
2604
+ self._stop_watching()
2605
+ self._folder_switches += 1
2606
+ name, self.workspace = self.workspace.name, None
2607
+ self._show_notes_of_folder()
2608
+ self._tree, self._tree_signature = None, ()
2609
+ set_dialog_folder(None)
2610
+ self.statusBar().showMessage(f"Closed {name}", 5000)
2611
+ if self.persist:
2612
+ self.settings.remove("workspace/current")
2613
+ self._show_workspace_header()
2614
+ self._rebuild_sidebar()
2615
+ self.content.setCurrentIndex(0)
2616
+ self._set_title(None)
2617
+ self._save_session()
2618
+ return True
2619
+
2620
+ def set_view_mode(self, mode: str) -> None:
2621
+ """In a folder: show its subfolders ("folder") or group files by kind ("kind")."""
2622
+ if mode == self.view_mode:
2623
+ return
2624
+ self.view_mode = mode
2625
+ self.view_switch.blockSignals(True)
2626
+ self.view_switch.set_index(0 if mode == "folder" else 1)
2627
+ self.view_switch.blockSignals(False)
2628
+ if self.workspace is not None:
2629
+ self.workspace.update_settings(view=mode)
2630
+ self._rebuild_sidebar()
2631
+
2632
+ def _rescan_workspace(self, force: bool = False) -> None:
2633
+ """Bring the sidebar in line with the folder's files (and watch its subfolders)."""
2634
+ if self.workspace is None:
2635
+ return
2636
+ if not self.workspace.exists():
2637
+ self.statusBar().showMessage(f"The folder {self.workspace.folder} is "
2638
+ "gone (moved or deleted?)", 10000)
2639
+ return
2640
+ self._tree = self.workspace.tree()
2641
+ self._reload_boxes()
2642
+ signature = tuple((f.relative, tuple((x.relative, x.in_cloud) for x in f.files))
2643
+ for f in self._tree.walk())
2644
+ missing_changed = self._check_missing()
2645
+ self._open_downloaded()
2646
+ if force or signature != self._tree_signature or missing_changed:
2647
+ self._tree_signature = signature
2648
+ self._rebuild_sidebar()
2649
+ # Watch the folder and its subfolders (new subfolders included).
2650
+ watched = set(self.watcher.directories())
2651
+ current = {str(d) for d in self.workspace.directories(self._tree)}
2652
+ if watched - current:
2653
+ self.watcher.removePaths(list(watched - current))
2654
+ if current - watched:
2655
+ self.watcher.addPaths(sorted(current - watched))
2656
+ if watched:
2657
+ # A file copied into a brand-new subfolder before it was watched
2658
+ # would be missed: look once more.
2659
+ self._rescan_timer.start()
2660
+
2661
+ def _stop_watching(self) -> None:
2662
+ if self.watcher.directories():
2663
+ self.watcher.removePaths(self.watcher.directories())
2664
+ self._tree, self._tree_signature = None, ()
2665
+
2666
+ # -- the sidebar's rows ---------------------------------------------------------
2667
+ def _sections(self) -> list[QTreeWidgetItem]:
2668
+ return [self.unsaved_section, self.exercises_section, self.logs_section,
2669
+ self.petri_section, self.models_section, self.cpn_section, self.ts_section,
2670
+ self.workflows_section, self.elsewhere_section, self.compare_section]
2671
+
2672
+ def _row_id(self, item: QTreeWidgetItem | None):
2673
+ """What a row stands for, so the same row can be found after a rebuild."""
2674
+ if item is None:
2675
+ return None
2676
+ try:
2677
+ if item.data(0, Qt.UserRole) is not None:
2678
+ return ("document", item.data(0, Qt.UserRole))
2679
+ if item.data(0, FILE_ROLE):
2680
+ return ("file", self._key(item.data(0, FILE_ROLE)))
2681
+ if item.data(0, FOLDER_ROLE):
2682
+ return ("folder", self._key(item.data(0, FOLDER_ROLE)))
2683
+ except RuntimeError:
2684
+ return None
2685
+ return None
2686
+
2687
+ def _row_path(self, item: QTreeWidgetItem) -> str | None:
2688
+ """The file or folder a row stands for (for dragging it, trashing it, …)."""
2689
+ if item.data(0, Qt.UserRole) is not None:
2690
+ document = self._document(item.data(0, Qt.UserRole))
2691
+ return document.path if document.path and not document.missing else None
2692
+ return item.data(0, FOLDER_ROLE) or (item.data(0, FILE_ROLE)
2693
+ if not item.data(0, CLOUD_ROLE) else None)
2694
+
2695
+ def _listed_files(self) -> list:
2696
+ """The folder's files that are not open (iCloud-only ones included)."""
2697
+ if self._tree is None:
2698
+ return []
2699
+ open_paths = {self._key(d.path) for d in self.documents if d.path}
2700
+ return [f for folder in self._tree.walk() for f in folder.files
2701
+ if self._key(f.path) not in open_paths]
2702
+
2703
+ def _rebuild_sidebar(self) -> None:
2704
+ """Lay out the sidebar from the open documents and the folder's files.
2705
+
2706
+ Called after anything that changes them. The current row, the
2707
+ selection, the expanded subfolders and the scroll position stay as
2708
+ they were, and the page shown does not change.
2709
+ """
2710
+ tree = self.tree
2711
+ current = self._row_id(tree.currentItem())
2712
+ selected = {self._row_id(item) for item in tree.selectedItems()}
2713
+ scroll_position = tree.verticalScrollBar().value()
2714
+ tree.blockSignals(True)
2715
+ tree._target = None
2716
+ try:
2717
+ while tree.topLevelItemCount():
2718
+ tree.takeTopLevelItem(0)
2719
+ for section in self._sections():
2720
+ section.takeChildren()
2721
+ self.items, self.placeholders, self.folder_items = {}, {}, {}
2722
+ folder_view = self.workspace is not None and self.view_mode == "folder"
2723
+ tree.setRootIsDecorated(folder_view)
2724
+ if folder_view:
2725
+ self._fill_folder_view()
2726
+ else:
2727
+ self._fill_kind_view()
2728
+ for section in self._sections():
2729
+ section.setExpanded(True)
2730
+ section.setHidden(section.childCount() == 0)
2731
+ self.more_row.setHidden(not (self._tree is not None and self._tree.truncated))
2732
+ empty = self.workspace is not None and not self.items and not self.placeholders \
2733
+ and not self.folder_items
2734
+ self.empty_hint.setHidden(not empty)
2735
+ for relative, item in self.folder_items.items():
2736
+ item.setExpanded(relative in self._expanded)
2737
+ # Put back the current row and the selection.
2738
+ rows = {self._row_id(item): item for item in self._all_rows()}
2739
+ if current in rows:
2740
+ item = rows[current]
2741
+ parent = item.parent()
2742
+ while parent is not None:
2743
+ parent.setExpanded(True)
2744
+ parent = parent.parent()
2745
+ tree.setCurrentItem(item)
2746
+ for key in selected:
2747
+ if key in rows:
2748
+ rows[key].setSelected(True)
2749
+ tree.verticalScrollBar().setValue(scroll_position)
2750
+ finally:
2751
+ tree.blockSignals(False)
2752
+ self._watch_open_files()
2753
+ self._update_welcome()
2754
+
2755
+ def _all_rows(self) -> list[QTreeWidgetItem]:
2756
+ rows = []
2757
+ iterator = QTreeWidgetItemIterator(self.tree)
2758
+ while iterator.value() is not None:
2759
+ rows.append(iterator.value())
2760
+ iterator += 1
2761
+ return rows
2762
+
2763
+ def _document_row(self, document) -> QTreeWidgetItem:
2764
+ kind = ("compare" if isinstance(document, ComparisonDocument) else
2765
+ "ts" if isinstance(document, TransitionSystemDocument) else
2766
+ "workflow" if isinstance(document, WorkflowDocument) else
2767
+ "log" if isinstance(document, LogDocument) else
2768
+ "cpn" if isinstance(document, CpnDocument)
2769
+ and not getattr(document.net, "plain", False) else "model")
2770
+ item = QTreeWidgetItem([""])
2771
+ item.setIcon(0, _icon(kind))
2772
+ item.setData(0, Qt.UserRole, document.id)
2773
+ self._style_document_row(item, document)
2774
+ self.items[document.id] = item
2775
+ return item
2776
+
2777
+ def _file_row(self, file, text: str) -> QTreeWidgetItem:
2778
+ """A file of the folder that is not open: lighter, opens with a click."""
2779
+ kind = {"log": "log", "cpn": "cpn", "ts": "ts", "workflow": "workflow"}.get(file.kind, "model")
2780
+ key = self._key(file.path)
2781
+ if file.in_cloud:
2782
+ text += " ☁"
2783
+ if key in self._opening or key in self._pending_downloads:
2784
+ text += " …"
2785
+ item = QTreeWidgetItem([text])
2786
+ item.setIcon(0, _faded_icon(kind))
2787
+ item.setData(0, FILE_ROLE, str(file.path))
2788
+ item.setForeground(0, QColor(style.tokens().text_muted))
2789
+ if file.in_cloud:
2790
+ item.setData(0, CLOUD_ROLE, str(file.cloud_placeholder))
2791
+ item.setToolTip(0, f"{file.relative}\nIn iCloud — click to download and open it")
2792
+ else:
2793
+ item.setToolTip(0, f"{file.relative}\nNot open — click to open")
2794
+ self.placeholders[key] = item
2795
+ return item
2796
+
2797
+ def _fill_kind_view(self) -> None:
2798
+ """Rows grouped by kind: logs, Petri nets, models, coloured nets, comparisons."""
2799
+ tree = self.tree
2800
+ for section in (self.exercises_section, self.logs_section, self.petri_section,
2801
+ self.models_section, self.cpn_section, self.ts_section,
2802
+ self.workflows_section, self.compare_section):
2803
+ tree.addTopLevelItem(section)
2804
+ if self._tree is not None:
2805
+ for folder in self._tree.walk():
2806
+ if folder.exercise:
2807
+ self.exercises_section.addChild(self._folder_row(folder))
2808
+ for document in self.documents:
2809
+ if isinstance(document, LogDocument):
2810
+ section = self.logs_section
2811
+ elif isinstance(document, TransitionSystemDocument):
2812
+ section = self.ts_section
2813
+ elif isinstance(document, WorkflowDocument):
2814
+ section = self.workflows_section
2815
+ elif isinstance(document, ComparisonDocument):
2816
+ section = self.compare_section
2817
+ elif isinstance(document, CpnDocument):
2818
+ section = self.petri_section if getattr(document.net, "plain", False) \
2819
+ else self.cpn_section
2820
+ else:
2821
+ section = self.models_section
2822
+ section.addChild(self._document_row(document))
2823
+ files = self._listed_files()
2824
+ # Rows show the file's name; the subfolder ("logs/…") only when two
2825
+ # files would otherwise look the same. The tooltip has the full path.
2826
+ short = [display_name(f.path.name) for f in files]
2827
+ clashes = {name for name in short if short.count(name) > 1}
2828
+ sections = {"log": self.logs_section, "petri": self.petri_section,
2829
+ "cpn": self.cpn_section, "ts": self.ts_section, "workflow": self.workflows_section}
2830
+ for file, name in zip(files, short):
2831
+ sections[file.kind].addChild(self._file_row(file, file.name if name in clashes
2832
+ else name))
2833
+ if self.workspace is not None: # rows in a folder are alphabetical
2834
+ for section in self._sections():
2835
+ self._sort_rows(section)
2836
+ tree.addTopLevelItem(self.more_row)
2837
+ tree.addTopLevelItem(self.empty_hint)
2838
+
2839
+ def _fill_folder_view(self) -> None:
2840
+ """Rows as in the folder: subfolders (expandable) and files, as in Finder."""
2841
+ tree = self.tree
2842
+ root = tree.invisibleRootItem()
2843
+ tree.addTopLevelItem(self.unsaved_section)
2844
+ tops: list[QTreeWidgetItem] = []
2845
+ if self._tree is not None:
2846
+ for folder in list(self._tree.walk())[1:]:
2847
+ item = self._folder_row(folder)
2848
+ parent = self.folder_items.get(folder.relative.rpartition("/")[0])
2849
+ (parent.addChild(item) if parent is not None else tops.append(item))
2850
+ self.folder_items[folder.relative] = item
2851
+
2852
+ def parent_row(relative: str):
2853
+ folder = relative.rpartition("/")[0]
2854
+ while folder and folder not in self.folder_items:
2855
+ folder = folder.rpartition("/")[0]
2856
+ return self.folder_items.get(folder)
2857
+
2858
+ for file in self._listed_files():
2859
+ item = self._file_row(file, display_name(file.path.name))
2860
+ parent = parent_row(file.relative)
2861
+ (parent.addChild(item) if parent is not None else tops.append(item))
2862
+ for document in self.documents:
2863
+ item = self._document_row(document)
2864
+ if isinstance(document, ComparisonDocument):
2865
+ self.compare_section.addChild(item)
2866
+ elif not document.path:
2867
+ self.unsaved_section.addChild(item)
2868
+ elif not self.workspace.contains(document.path):
2869
+ self.elsewhere_section.addChild(item)
2870
+ else:
2871
+ parent = parent_row(self.workspace.relative(document.path))
2872
+ (parent.addChild(item) if parent is not None else tops.append(item))
2873
+ for item in sorted(tops, key=self._sort_key):
2874
+ root.addChild(item)
2875
+ for item in self.folder_items.values():
2876
+ self._sort_rows(item)
2877
+ tree.addTopLevelItem(self.more_row)
2878
+ for section in (self.elsewhere_section, self.compare_section):
2879
+ self._sort_rows(section)
2880
+ tree.addTopLevelItem(section)
2881
+ tree.addTopLevelItem(self.empty_hint)
2882
+
2883
+ def _folder_row(self, folder) -> QTreeWidgetItem:
2884
+ """A subfolder's row; an exercise gets its own icon and opens with a click."""
2885
+ item = QTreeWidgetItem([folder.name])
2886
+ item.setData(0, FOLDER_ROLE, str(folder.path))
2887
+ if folder.exercise:
2888
+ item.setIcon(0, _icon("exercise"))
2889
+ item.setData(0, EXERCISE_ROLE, True)
2890
+ item.setToolTip(0, f"{folder.relative}\nExercise — click to do it")
2891
+ else:
2892
+ item.setIcon(0, _icon("folder"))
2893
+ item.setToolTip(0, folder.relative)
2894
+ return item
2895
+
2896
+ @staticmethod
2897
+ def _sort_key(item: QTreeWidgetItem):
2898
+ """Folders first, then by name, ignoring case (as Finder sorts)."""
2899
+ return (0 if item.data(0, FOLDER_ROLE) else 1, item.text(0).casefold())
2900
+
2901
+ def _sort_rows(self, parent: QTreeWidgetItem) -> None:
2902
+ children = parent.takeChildren()
2903
+ parent.addChildren(sorted(children, key=self._sort_key))
2904
+
2905
+ def _folder_toggled(self, item: QTreeWidgetItem, expanded: bool) -> None:
2906
+ """A subfolder was expanded or collapsed: remember it in the folder."""
2907
+ path = item.data(0, FOLDER_ROLE)
2908
+ if not path or self.workspace is None:
2909
+ return
2910
+ relative = self.workspace.relative(path)
2911
+ changed = (relative not in self._expanded) if expanded else (relative in self._expanded)
2912
+ if changed:
2913
+ (self._expanded.add if expanded else self._expanded.discard)(relative)
2914
+ self.workspace.update_settings(expanded=sorted(self._expanded))
2915
+
2916
+ def _open_placeholder(self, item) -> None:
2917
+ try:
2918
+ path = item.data(0, FILE_ROLE) if item is not None else None
2919
+ exercise = item.data(0, EXERCISE_ROLE) if item is not None else None
2920
+ except RuntimeError: # a row rebuilt meanwhile
2921
+ return
2922
+ if exercise and not path:
2923
+ self.open_exercise(item.data(0, FOLDER_ROLE))
2924
+ return
2925
+ if not path:
2926
+ return
2927
+ now = time.monotonic()
2928
+ # One click and a double-click (which also "activates") must not open
2929
+ # the file twice: a log loading in the background is not open yet.
2930
+ if self._key(path) == getattr(self, "_last_click_key", None) and \
2931
+ now - self._clicked_open_at < 3:
2932
+ return
2933
+ self._last_click_key = self._key(path)
2934
+ self._clicked_open_at = now
2935
+ self._restore_paths.pop(self._key(path), None) # the user chose: it gets selected
2936
+ if item.data(0, CLOUD_ROLE):
2937
+ self._download_from_icloud(Path(path), Path(item.data(0, CLOUD_ROLE)))
2938
+ return
2939
+ if path.lower().endswith((".xes", ".gz")):
2940
+ self._opening.add(self._key(path))
2941
+ item.setText(0, item.text(0) + " …")
2942
+ item.setToolTip(0, "Opening…")
2943
+ self.open_path(path)
2944
+
2945
+ def _on_double_click(self, item, _column) -> None:
2946
+ """Rename an open document, but not the one a click has just opened."""
2947
+ recently = time.monotonic() - self._clicked_open_at
2948
+ if recently < QApplication.doubleClickInterval() / 1000 * 2:
2949
+ return
2950
+ if item.data(0, Qt.UserRole) is not None:
2951
+ self.rename_document(self._document(item.data(0, Qt.UserRole)))
2952
+
2953
+ def _current_document(self):
2954
+ ids = self.selected_ids()
2955
+ return self._document(ids[0]) if len(ids) == 1 else None
2956
+
2957
+ @staticmethod
2958
+ def _key(path: str | Path) -> str:
2959
+ """One spelling per file, for comparing paths."""
2960
+ return os.path.normcase(str(Path(path).resolve()))
2961
+
2962
+ def _update_welcome(self) -> None:
2963
+ """The welcome page's top card: "Work in a folder", or about the open one."""
2964
+ if not hasattr(self, "welcome_inside_card"):
2965
+ return
2966
+ inside = self.workspace is not None
2967
+ self.welcome_folder_card.setHidden(inside)
2968
+ self.welcome_inside_card.setHidden(not inside)
2969
+ if inside:
2970
+ files = len(self.placeholders) + sum(
2971
+ 1 for d in self.documents if d.path and self.workspace.contains(d.path)
2972
+ and not d.missing)
2973
+ self.welcome_inside_title.setText(self.workspace.name)
2974
+ if files:
2975
+ what = f"{files} logs and nets" if files != 1 \
2976
+ else "1 file"
2977
+ self.welcome_inside_text.setText(
2978
+ f"{what} in this folder: click one in the sidebar to open it. "
2979
+ "New nets and logs are saved into this folder, edits are saved as you "
2980
+ "go, and what you have open is remembered for next time.")
2981
+ else:
2982
+ self.welcome_inside_text.setText(
2983
+ "This folder has no event logs or nets yet. Start a new net (it is saved "
2984
+ "here), or add .xes, .csv, .pnml or .cpn files to the folder: they appear "
2985
+ "in the sidebar by themselves.")
2986
+
2987
+ def _show_workspace_header(self) -> None:
2988
+ active = self.workspace is not None
2989
+ self.workspace_caption.setHidden(not active)
2990
+ self.workspace_button.setHidden(not active)
2991
+ self.view_row.setHidden(not active)
2992
+ self.sidebar_title.setText(self.workspace.name if active else APPLICATION_NAME)
2993
+ self.sidebar_title.setToolTip(str(self.workspace.folder) if active else "")
2994
+ self.close_workspace_action.setEnabled(active)
2995
+
2996
+ def _set_title(self, document) -> None:
2997
+ """"order — Week 2" in a workspace, "order — OpenProcess Studio" without one."""
2998
+ context = self.workspace.name if self.workspace else APPLICATION_NAME
2999
+ if document is None:
3000
+ self.setWindowTitle(context if self.workspace is None
3001
+ else f"{context} — {APPLICATION_NAME}")
3002
+ else:
3003
+ self.setWindowTitle(f"{document.name} — {context}")
3004
+
3005
+ # -- recent workspaces
3006
+ def _recent_workspaces(self) -> list[str]:
3007
+ if not self.persist: # tests: never your recent folders
3008
+ return []
3009
+ try:
3010
+ return [p for p in json.loads(self.settings.value("workspaces/recent", "[]"))
3011
+ if isinstance(p, str)]
3012
+ except (TypeError, ValueError):
3013
+ return []
3014
+
3015
+ def _remember_workspace(self, folder: str) -> None:
3016
+ if self.persist:
3017
+ recent = [p for p in self._recent_workspaces() if Path(p) != Path(folder)]
3018
+ self.settings.setValue("workspaces/recent", json.dumps([folder] + recent[:7]))
3019
+ self._refresh_welcome_recent()
3020
+
3021
+ def _forget_workspace(self, folder: str) -> None:
3022
+ if self.persist:
3023
+ recent = [p for p in self._recent_workspaces() if Path(p) != Path(folder)]
3024
+ self.settings.setValue("workspaces/recent", json.dumps(recent))
3025
+ self._refresh_welcome_recent()
3026
+
3027
+ def _fill_recent_workspaces_menu(self) -> None:
3028
+ menu = self.recent_workspaces_menu
3029
+ menu.clear()
3030
+ recent = [p for p in self._recent_workspaces() if Path(p).is_dir()]
3031
+ if not recent:
3032
+ menu.addAction("No Recent Folders").setEnabled(False)
3033
+ return
3034
+ for folder in recent:
3035
+ menu.addAction(Path(folder).name, lambda f=folder: self.open_workspace(f)) \
3036
+ .setToolTip(folder)
3037
+ menu.addSeparator()
3038
+ menu.addAction("Clear Menu", lambda: (self.settings.setValue("workspaces/recent", "[]"),
3039
+ self._refresh_welcome_recent()))
3040
+
3041
+ def _refresh_welcome_recent(self) -> None:
3042
+ """Up to three recent workspaces as quick buttons on the welcome page."""
3043
+ holder = getattr(self, "welcome_recent", None)
3044
+ if holder is None:
3045
+ return
3046
+ layout = holder.layout()
3047
+ while layout.count():
3048
+ widget = layout.takeAt(0).widget()
3049
+ if widget is not None:
3050
+ widget.deleteLater()
3051
+ recent = [p for p in self._recent_workspaces() if Path(p).is_dir()][:3]
3052
+ if recent:
3053
+ layout.addWidget(label("Recent:", "muted"))
3054
+ for folder in recent:
3055
+ # clicked sends "checked" (False) first: it must not land in f.
3056
+ shortcut = button(Path(folder).name,
3057
+ lambda _checked=False, f=folder: self.open_workspace(f))
3058
+ shortcut.setToolTip(f"Open the folder {folder}")
3059
+ layout.addWidget(shortcut)
3060
+
3061
+ # ---------------------------------------------------------------- app → folder
3062
+ def _materialise(self, document, near: str | None = None) -> None:
3063
+ """In a folder, a new log or net becomes a file in it straight away.
3064
+
3065
+ A net is named after its file, so "Untitled 1" is ``Untitled 1.pnml``
3066
+ (or ``Untitled 2`` when that name is taken). It goes next to ``near``
3067
+ (the log a filtered log came from, the net a simulation ran on) when
3068
+ that is in the folder, else at the top of the folder. A discovered
3069
+ model waits for Keep, so trying algorithms does not fill the folder.
3070
+ """
3071
+ if self.workspace is None or document.path or self._restoring:
3072
+ return
3073
+ page = self.pages[document.id]
3074
+ if isinstance(document, ModelDocument):
3075
+ page.keep_button.setVisible(True)
3076
+ return
3077
+ if not isinstance(document, (LogDocument, CpnDocument, TransitionSystemDocument,
3078
+ WorkflowDocument)):
3079
+ return
3080
+ folder = self.workspace.folder
3081
+ if near and self.workspace.contains(near) and Path(near).parent.is_dir():
3082
+ folder = Path(near).resolve().parent
3083
+ try:
3084
+ if isinstance(document, LogDocument):
3085
+ # A log written in notation stays in notation (Edit… goes back to it).
3086
+ target = unique_path(folder, safe_file_name(document.name)
3087
+ + (".log.txt" if document.notation else ".xes"))
3088
+ page.write_to(str(target), quiet=True)
3089
+ elif isinstance(document, TransitionSystemDocument):
3090
+ target = unique_path(folder, safe_file_name(document.name) + ".ts.txt")
3091
+ page.write_to(str(target), quiet=True)
3092
+ elif isinstance(document, WorkflowDocument):
3093
+ target = unique_path(folder, safe_file_name(document.name) + ".cpnflow")
3094
+ page.write_to(str(target), quiet=True)
3095
+ else:
3096
+ plain = getattr(document.net, "plain", False)
3097
+ target = unique_path(folder, safe_file_name(document.name)
3098
+ + (".pnml" if plain else ".cpn"))
3099
+ if document.net.name != _file_stem(target):
3100
+ document.net.name = _file_stem(target) # named after its file
3101
+ page.refresh_title()
3102
+ if not page._write(target, quiet=True):
3103
+ self.statusBar().showMessage(
3104
+ f"Could not save {document.name} into the folder "
3105
+ f"({getattr(page, 'save_error', 'unknown error')}) — it stays "
3106
+ "unsaved here", 10000)
3107
+ return
3108
+ except Exception as error: # noqa: BLE001 - the document stays, unsaved
3109
+ self.statusBar().showMessage(f"Could not save {document.name} into the folder: "
3110
+ f"{error}", 10000)
3111
+ return
3112
+ self._created.add(self._key(target))
3113
+
3114
+ def keep_model(self, document) -> None:
3115
+ """Save a discovered model into the folder (the Keep button)."""
3116
+ if self.workspace is None or document.path:
3117
+ self.pages[document.id].export()
3118
+ return
3119
+ source = document.source_log.path if document.source_log else None
3120
+ folder = Path(source).resolve().parent if source and self.workspace.contains(source) \
3121
+ else self.workspace.folder
3122
+ target = unique_path(folder, safe_file_name(document.name) + ".pnml")
3123
+ try:
3124
+ self.pages[document.id].write_to(str(target), quiet=True)
3125
+ except Exception as error: # noqa: BLE001
3126
+ QMessageBox.warning(self, "Could not keep the model", str(error))
3127
+ return
3128
+ self.statusBar().showMessage(f"Kept as {target.name}", 8000)
3129
+ self._rebuild_sidebar()
3130
+
3131
+ # -- autosave
3132
+ def _autosaves(self, document) -> bool:
3133
+ """Edits to this document are saved by themselves.
3134
+
3135
+ Only nets, only in the open folder, and a ``.cpn`` only if OpenProcess wrote
3136
+ it: a model made in CPN Tools is saved (in OpenProcess's writer) only when you
3137
+ press ⌘S, after which it is OpenProcess's too.
3138
+ """
3139
+ return (self.autosave_enabled and self.workspace is not None
3140
+ and document.id not in self._autosave_failed
3141
+ and isinstance(document, (CpnDocument, WorkflowDocument)) and bool(document.path)
3142
+ and self.workspace.contains(document.path) and not document.missing
3143
+ and (not document.path.lower().endswith(".cpn")
3144
+ or document.id in self._openprocess_files))
3145
+
3146
+ def _update_autosave(self, document) -> None:
3147
+ if not isinstance(document, (CpnDocument, WorkflowDocument)):
3148
+ return
3149
+ autosave = self._autosaves(document)
3150
+ if autosave != document.autosave:
3151
+ document.autosave = autosave
3152
+ page = self.pages.get(document.id)
3153
+ if page is not None:
3154
+ page.refresh_title()
3155
+ if document.id in self.items:
3156
+ self._refresh_item(document)
3157
+ if autosave and document.dirty:
3158
+ self._schedule_autosave(document)
3159
+
3160
+ def _schedule_autosave(self, document) -> None:
3161
+ """An edit: save a moment after the last one."""
3162
+ if not document.autosave or document.id in self._conflicts:
3163
+ return
3164
+ timer = self._autosave_timers.get(document.id)
3165
+ if timer is None:
3166
+ timer = QTimer(self)
3167
+ timer.setSingleShot(True)
3168
+ timer.setInterval(AUTOSAVE_DELAY)
3169
+ timer.timeout.connect(lambda doc=document: self._autosave(doc))
3170
+ self._autosave_timers[document.id] = timer
3171
+ timer.start()
3172
+
3173
+ def _autosave(self, document) -> None:
3174
+ page = self.pages.get(document.id)
3175
+ if page is None or not document.autosave or document.id in self._conflicts:
3176
+ return
3177
+ if not Path(document.path).exists():
3178
+ self._set_missing(document, True) # do not quietly bring a deleted file back
3179
+ return
3180
+ if not page.autosave():
3181
+ # Say so once, and fall back to saving by hand (the dot comes back).
3182
+ self._autosave_failed.add(document.id)
3183
+ self._update_autosave(document)
3184
+ name = Path(document.path).name
3185
+ self.banners[document.id].show_notice(
3186
+ "unsaved", f"<b>{name} could not be saved</b> automatically: "
3187
+ f"{getattr(page, 'save_error', 'unknown error')}. Your edits are still "
3188
+ "here; save it somewhere else to keep them.",
3189
+ [("Save As…", page.export),
3190
+ ("Try Again", lambda: page.save())])
3191
+
3192
+ def _flush_autosaves(self, ids: list[int] | None = None) -> None:
3193
+ """Save now what autosave would save in a moment (switching, closing, quitting)."""
3194
+ for document in list(self.documents):
3195
+ if (ids is None or document.id in ids) and getattr(document, "autosave", False) \
3196
+ and document.dirty:
3197
+ timer = self._autosave_timers.get(document.id)
3198
+ if timer is not None:
3199
+ timer.stop()
3200
+ self._autosave(document)
3201
+
3202
+ def set_autosave(self, on: bool) -> None:
3203
+ """File ▸ Autosave."""
3204
+ if on == self.autosave_enabled:
3205
+ return
3206
+ self.autosave_enabled = on
3207
+ self._set_setting("files/autosave", on)
3208
+ if self.autosave_action.isChecked() != on:
3209
+ self.autosave_action.setChecked(on)
3210
+ for document in self.documents:
3211
+ self._update_autosave(document)
3212
+ self.statusBar().showMessage("Edits to nets in the folder are saved as you go" if on
3213
+ else f"Autosave is off: save with {shortcut_text('Ctrl+S')}", 6000)
3214
+
3215
+ # -- revert
3216
+ def _keep_original(self, document) -> None:
3217
+ """Remember a net's file as it was opened, for Revert to Saved."""
3218
+ try:
3219
+ if os.path.getsize(document.path) <= 50_000_000:
3220
+ self._originals[document.id] = Path(document.path).read_bytes()
3221
+ except OSError:
3222
+ pass
3223
+
3224
+ def revert_selected(self) -> None:
3225
+ """File ▸ Revert to Saved: back to the file as it was when it was opened."""
3226
+ document = self._current_document()
3227
+ original = self._originals.get(document.id) if document is not None else None
3228
+ if document is None or original is None or not document.path:
3229
+ QMessageBox.information(self, "Revert to Saved", "Select a net opened from a file "
3230
+ "to go back to how it was when you opened it.")
3231
+ return
3232
+ box = QMessageBox(self)
3233
+ box.setIcon(QMessageBox.Warning)
3234
+ box.setWindowTitle("Revert to Saved")
3235
+ box.setText(f"Revert “{document.name}” to how it was when you opened it?")
3236
+ box.setInformativeText("The changes made since then are lost, and undo cannot bring "
3237
+ "them back.")
3238
+ revert = box.addButton("Revert", QMessageBox.DestructiveRole)
3239
+ box.addButton(QMessageBox.Cancel)
3240
+ box.exec()
3241
+ if box.clickedButton() is not revert:
3242
+ return
3243
+ from .workspace import atomic_write
3244
+ timer = self._autosave_timers.get(document.id)
3245
+ if timer is not None:
3246
+ timer.stop()
3247
+ try:
3248
+ atomic_write(document.path, lambda temporary: temporary.write_bytes(original))
3249
+ except OSError as error:
3250
+ QMessageBox.warning(self, "Revert to Saved", str(error))
3251
+ return
3252
+ self._own_writes[self._key(document.path)] = _signature(document.path)
3253
+ self._reload_document(document, f"Reverted {Path(document.path).name}")
3254
+
3255
+ # ---------------------------------------------------------------- folder → app
3256
+ def _watch_open_files(self) -> None:
3257
+ """Watch the open documents' files, so a change made elsewhere is noticed."""
3258
+ wanted = {str(Path(d.path)) for d in self.documents
3259
+ if d.path and not d.missing and os.path.exists(d.path)}
3260
+ current = set(self.watcher.files())
3261
+ if current - wanted:
3262
+ self.watcher.removePaths(sorted(current - wanted))
3263
+ if wanted - current:
3264
+ self.watcher.addPaths(sorted(wanted - current))
3265
+
3266
+ def _file_changed(self, path: str) -> None:
3267
+ self._changed_files.add(path)
3268
+ self._file_timer.start()
3269
+
3270
+ def _check_changed_files(self) -> None:
3271
+ """Open files changed (or vanished) on disk: reload, ask, or mark them missing.
3272
+
3273
+ A change is acted on once the file has stopped changing (a big file
3274
+ being copied in is not read half-way), and the app's own saves are
3275
+ recognised and ignored.
3276
+ """
3277
+ pending, self._changed_files = self._changed_files, set()
3278
+ again: set[str] = set()
3279
+ for path in pending:
3280
+ key = self._key(path)
3281
+ documents = [d for d in self.documents if d.path and self._key(d.path) == key]
3282
+ if not documents:
3283
+ continue
3284
+ signature = _signature(path)
3285
+ if signature is None:
3286
+ # Gone, or being replaced by a safe save (write elsewhere, then
3287
+ # rename): look again a few times before calling it missing.
3288
+ tries = self._gone_checks.get(key, 0)
3289
+ if tries < 3:
3290
+ self._gone_checks[key] = tries + 1
3291
+ again.add(path)
3292
+ else:
3293
+ self._gone_checks.pop(key, None)
3294
+ if self.workspace is not None:
3295
+ # Moved within the folder, perhaps: the rescan looks
3296
+ # for it (_relocate) before calling it missing.
3297
+ self._rescan_workspace(force=True)
3298
+ else:
3299
+ for document in documents:
3300
+ self._set_missing(document, True)
3301
+ continue
3302
+ self._gone_checks.pop(key, None)
3303
+ if path not in self.watcher.files():
3304
+ self.watcher.addPath(path) # a safe save replaced the watched file
3305
+ if self._own_writes.get(key) == signature:
3306
+ continue
3307
+ if self._unsettled.get(key) != signature:
3308
+ self._unsettled[key] = signature # still being written? look again
3309
+ again.add(path)
3310
+ continue
3311
+ self._unsettled.pop(key, None)
3312
+ self._own_writes[key] = signature # handled: not again for the same version
3313
+ for document in documents:
3314
+ if document.missing:
3315
+ self._set_missing(document, False)
3316
+ self._changed_on_disk(document)
3317
+ if again:
3318
+ self._changed_files |= again
3319
+ self._file_timer.start()
3320
+
3321
+ def _changed_on_disk(self, document) -> None:
3322
+ name = Path(document.path).name
3323
+ if not getattr(document, "dirty", False):
3324
+ self._reload_document(document, f"{name} changed on disk — reloaded")
3325
+ return
3326
+ # Edits here too: the user decides whose version wins.
3327
+ self._conflicts.add(document.id)
3328
+ timer = self._autosave_timers.get(document.id)
3329
+ if timer is not None:
3330
+ timer.stop()
3331
+
3332
+ def reload() -> None:
3333
+ self._conflicts.discard(document.id)
3334
+ self._reload_document(document, f"Reloaded {name}")
3335
+
3336
+ def keep_mine() -> None:
3337
+ self._conflicts.discard(document.id)
3338
+ self.banners[document.id].clear("changed")
3339
+ if document.autosave:
3340
+ self._autosave(document) # ours replaces theirs now
3341
+ self.statusBar().showMessage(f"Kept your version of {name}", 6000)
3342
+
3343
+ self.banners[document.id].show_notice(
3344
+ "changed", f"<b>{name} changed on disk</b> (in another app?), and it has edits "
3345
+ "here that are not saved.",
3346
+ [("Reload (lose my edits)", reload), ("Keep Mine", keep_mine)])
3347
+
3348
+ def _reload_document(self, document, message: str) -> None:
3349
+ """Read the document's file again and show it afresh."""
3350
+ path = document.path
3351
+ key = self._key(path)
3352
+
3353
+ def failed(error) -> None:
3354
+ tries = self._reload_attempts.get(key, 0)
3355
+ if tries < 5: # half-written still? try again soon
3356
+ self._reload_attempts[key] = tries + 1
3357
+ self._unsettled.pop(key, None)
3358
+ self._own_writes.pop(key, None)
3359
+ QTimer.singleShot(1000, lambda: self._file_changed(path))
3360
+ return
3361
+ self._reload_attempts.pop(key, None)
3362
+ banner = self.banners.get(document.id)
3363
+ if banner is not None:
3364
+ banner.show_notice("changed", f"<b>{Path(path).name} changed on disk</b> but "
3365
+ f"could not be read: {error}", [])
3366
+
3367
+ def done(content) -> None:
3368
+ if document not in self.documents:
3369
+ return
3370
+ self._reload_attempts.pop(key, None)
3371
+ if isinstance(document, LogDocument):
3372
+ document.replace_log(content)
3373
+ else:
3374
+ document.net = content
3375
+ if isinstance(document, CpnDocument):
3376
+ document.dirty = False
3377
+ self._remember_identity(document) # another app's safe save: a new file
3378
+ self._replace_page(document)
3379
+ for other in self.documents: # comparisons show the new log too
3380
+ if isinstance(other, ComparisonDocument) and document in other.logs:
3381
+ self._replace_page(other)
3382
+ self.statusBar().showMessage(message, 8000)
3383
+
3384
+ try:
3385
+ if isinstance(document, TransitionSystemDocument):
3386
+ content = self._read_text_file(path)
3387
+ document.ts = content.ts
3388
+ self._replace_page(document)
3389
+ self.statusBar().showMessage(message, 8000)
3390
+ return
3391
+ if isinstance(document, LogDocument):
3392
+ lower = path.lower()
3393
+ if lower.endswith(".txt"):
3394
+ content = self._read_text_file(path)
3395
+ document.notation = content.notation
3396
+ done(content.log)
3397
+ elif lower.endswith(".csv"):
3398
+ mapping = self.open_options.get(document.id, {}).get("csv_mapping")
3399
+ done(read_csv(path, ColumnMapping(**mapping) if mapping
3400
+ else guess_mapping(sniff(path)[1])))
3401
+ else:
3402
+ run_in_background(lambda: read_xes(path), done, failed)
3403
+ elif isinstance(document, CpnDocument):
3404
+ done(self._read_net(path))
3405
+ elif isinstance(document, ModelDocument):
3406
+ net = read_pnml(path)
3407
+ net.name = document.net.name
3408
+ done(net)
3409
+ except Exception as error: # noqa: BLE001
3410
+ failed(error)
3411
+
3412
+ def _log_edited(self, document) -> None:
3413
+ """A log was edited on its page: write it back to its file and show it afresh."""
3414
+ page = self.pages[document.id]
3415
+ if document.path and not document.missing:
3416
+ try:
3417
+ page.write_to(document.path, quiet=True)
3418
+ except Exception as error: # noqa: BLE001 - the edit stays, unsaved
3419
+ QMessageBox.warning(self, "Could not save the log",
3420
+ f"{Path(document.path).name}\n\n{error}\n\nYour changes "
3421
+ "are still open here: export the log to keep them.")
3422
+ else:
3423
+ self.statusBar().showMessage(f"Saved your changes to "
3424
+ f"{Path(document.path).name}", 6000)
3425
+ else:
3426
+ self._materialise(document)
3427
+ self._replace_page(document)
3428
+ for other in self.documents: # comparisons show the new log too
3429
+ if isinstance(other, ComparisonDocument) and document in other.logs:
3430
+ self._replace_page(other)
3431
+ self._rebuild_sidebar()
3432
+
3433
+ def _replace_page(self, document) -> None:
3434
+ """Rebuild a document's page (after reloading it), keeping it in view."""
3435
+ shown = self.content.currentWidget() is self.holders.get(document.id)
3436
+ self._drop_page(document)
3437
+ self._make_page(document)
3438
+ self._update_autosave(document)
3439
+ if document.missing:
3440
+ self._show_missing(document)
3441
+ if shown:
3442
+ self._restore_tab(self.pages[document.id])
3443
+ self.content.setCurrentWidget(self.holders[document.id])
3444
+ self._refresh_item(document)
3445
+ self._refresh_log_choices()
3446
+
3447
+ def _set_missing(self, document, missing: bool) -> None:
3448
+ """An open document's file was deleted or moved away (or came back)."""
3449
+ if document.missing == missing:
3450
+ return
3451
+ document.missing = missing
3452
+ self._update_autosave(document)
3453
+ if missing:
3454
+ self._show_missing(document)
3455
+ self.statusBar().showMessage(f"{Path(document.path).name} is no longer on disk — "
3456
+ "it is still open here", 10000)
3457
+ else:
3458
+ self.banners[document.id].clear("missing")
3459
+ if document.id in self.items:
3460
+ self._style_document_row(self.items[document.id], document)
3461
+
3462
+ def _show_missing(self, document) -> None:
3463
+ name = Path(document.path).name
3464
+ self.banners[document.id].show_notice(
3465
+ "missing", f"<b>{name} is missing</b>: it was moved or deleted outside the app. "
3466
+ "It is still open here — save it to keep it.",
3467
+ [("Save As…", self.pages[document.id].export),
3468
+ ("Close", lambda: self.remove_documents([document.id]))])
3469
+
3470
+ def _check_missing(self) -> bool:
3471
+ """Follow open files moved within the folder; mark the ones that are gone
3472
+ (or back) as missing. True if anything changed."""
3473
+ changed = False
3474
+ for document in self.documents:
3475
+ if not document.path or isinstance(document, ComparisonDocument):
3476
+ continue
3477
+ there = os.path.exists(document.path)
3478
+ if there:
3479
+ self._remember_identity(document)
3480
+ elif self._relocate(document):
3481
+ changed = True
3482
+ continue
3483
+ if there == document.missing:
3484
+ self._set_missing(document, not there)
3485
+ if there:
3486
+ self._file_changed(document.path) # back, perhaps changed
3487
+ changed = True
3488
+ return changed
3489
+
3490
+ @staticmethod
3491
+ def _identity(path) -> tuple | None:
3492
+ """What a move or rename keeps: the file's number on disk (device,
3493
+ inode), and its size and modification time. The number alone is not
3494
+ enough: Linux hands a deleted file's number to the next new file, and
3495
+ that file must not be taken for the one that was open."""
3496
+ try:
3497
+ status = os.stat(path)
3498
+ except (OSError, TypeError):
3499
+ return None
3500
+ return status.st_dev, status.st_ino, status.st_size, status.st_mtime_ns
3501
+
3502
+ def _remember_identity(self, document) -> None:
3503
+ identity = self._identity(document.path)
3504
+ if identity is not None:
3505
+ self._identities[document.id] = identity
3506
+
3507
+ def _relocate(self, document) -> bool:
3508
+ """An open file vanished from its place: was it moved within the folder?
3509
+
3510
+ A file moved or renamed in Finder (or with ``mv``) keeps its identity
3511
+ on disk, so it is looked for among the folder's files; if found, the
3512
+ document follows it -- open, unsaved edits and all -- instead of being
3513
+ marked missing while the file also shows up, unopened, in its new place.
3514
+ """
3515
+ identity = self._identities.get(document.id)
3516
+ if identity is None or self.workspace is None or self._tree is None:
3517
+ return False
3518
+ taken = {self._key(d.path) for d in self.documents if d.path}
3519
+ kind = file_kind(Path(document.path))
3520
+ for file in self.workspace.files(self._tree):
3521
+ if self._key(file.path) in taken or file.kind != kind:
3522
+ continue
3523
+ if self._identity(file.path) != identity:
3524
+ continue
3525
+ old = document.path
3526
+ self._paths_moved(Path(old), file.path)
3527
+ if document.missing:
3528
+ self._set_missing(document, False)
3529
+ self.statusBar().showMessage(
3530
+ f"{Path(old).name} was moved to {self.workspace.relative(file.path)} — it "
3531
+ "is still open", 8000)
3532
+ return True
3533
+ return False
3534
+
3535
+ # -- iCloud
3536
+ def _download_from_icloud(self, path: Path, placeholder: Path) -> None:
3537
+ """Ask iCloud Drive for a file that is only in the cloud; open it when it is here."""
3538
+ key = self._key(path)
3539
+ try:
3540
+ subprocess.run(["brctl", "download", str(placeholder)], check=True,
3541
+ capture_output=True, timeout=15)
3542
+ except (OSError, subprocess.SubprocessError):
3543
+ self.statusBar().showMessage(f"{path.name} is in iCloud: open it in Finder to "
3544
+ "download it", 10000)
3545
+ self.reveal(str(placeholder.parent))
3546
+ return
3547
+ self._pending_downloads.add(key)
3548
+ self.statusBar().showMessage(f"Downloading {path.name} from iCloud…", 10000)
3549
+ self._rebuild_sidebar()
3550
+
3551
+ def _open_downloaded(self) -> None:
3552
+ for key in list(self._pending_downloads):
3553
+ path = next((f.path for f in self.workspace.files(self._tree)
3554
+ if self._key(f.path) == key), None) if self._tree else None
3555
+ if path is not None:
3556
+ self._pending_downloads.discard(key)
3557
+ QTimer.singleShot(0, lambda p=str(path): self.open_path(p))
3558
+
3559
+ # ---------------------------------------------------------------- organising
3560
+ def _selected_paths(self) -> list[str]:
3561
+ paths = [self._row_path(item) for item in self.tree.selectedItems()]
3562
+ if not paths and self.tree.currentItem() is not None:
3563
+ paths = [self._row_path(self.tree.currentItem())]
3564
+ return [p for p in paths if p]
3565
+
3566
+ def trash_selected(self) -> None:
3567
+ self.trash_paths(self._selected_paths())
3568
+
3569
+ def trash_paths(self, paths: list[str]) -> None:
3570
+ """Move files or folders to the Bin (after asking); open documents in them close."""
3571
+ paths = [p for p in paths if os.path.exists(p)]
3572
+ if not paths:
3573
+ return
3574
+ what = f"“{Path(paths[0]).name}”" if len(paths) == 1 else f"{len(paths)} items"
3575
+ box = QMessageBox(self)
3576
+ box.setIcon(QMessageBox.Warning)
3577
+ box.setWindowTitle(f"Move to {BIN}")
3578
+ box.setText(f"Move {what} to the {BIN}?")
3579
+ inside = [d for d in self.documents if d.path and any(
3580
+ self._key(d.path) == self._key(p) or self._key(d.path).startswith(
3581
+ self._key(p) + os.sep) for p in paths)]
3582
+ unsaved = [d for d in inside if getattr(d, "dirty", False)]
3583
+ box.setInformativeText(
3584
+ f"You can put it back from the {BIN}." if not unsaved else
3585
+ f"You can put it back from the {BIN}, but the edits to "
3586
+ + ", ".join(f"“{d.name}”" for d in unsaved) + " that are not saved are lost.")
3587
+ trash = box.addButton(f"Move to {BIN}", QMessageBox.DestructiveRole)
3588
+ box.addButton(QMessageBox.Cancel)
3589
+ box.setDefaultButton(QMessageBox.Cancel)
3590
+ box.exec()
3591
+ if box.clickedButton() is not trash:
3592
+ return
3593
+ for document in inside:
3594
+ timer = self._autosave_timers.get(document.id)
3595
+ if timer is not None:
3596
+ timer.stop()
3597
+ if isinstance(document, CpnDocument):
3598
+ document.dirty = False # gone to the Bin: nothing to save
3599
+ self.remove_documents([d.id for d in inside], confirm=False)
3600
+ failed = []
3601
+ for path in paths:
3602
+ if move_to_trash(path):
3603
+ self._forget_recent(path)
3604
+ self._created.discard(self._key(path))
3605
+ else:
3606
+ failed.append(Path(path).name)
3607
+ if failed:
3608
+ QMessageBox.warning(self, f"Move to {BIN}", "Could not move to the "
3609
+ f"{BIN}: " + ", ".join(failed))
3610
+ else:
3611
+ self.statusBar().showMessage(f"Moved {what} to the {BIN}", 6000)
3612
+ self._rescan_workspace()
3613
+
3614
+ def new_folder(self, inside: str | None = None) -> None:
3615
+ """Make a subfolder (in ``inside``, else at the top of the open folder)."""
3616
+ if self.workspace is None:
3617
+ return
3618
+ parent = Path(inside) if inside else self.workspace.folder
3619
+ name, ok = QInputDialog.getText(self, "New Folder", "Name:", text="untitled folder")
3620
+ name = name.strip()
3621
+ if not ok or not name:
3622
+ return
3623
+ if FORBIDDEN_CHARACTERS & set(name) or name.startswith("."):
3624
+ QMessageBox.warning(self, "New Folder", "A folder name cannot start with a dot or "
3625
+ "contain any of / \\ : * ? \" < > |")
3626
+ return
3627
+ target = parent / name
3628
+ try:
3629
+ target.mkdir()
3630
+ except FileExistsError:
3631
+ QMessageBox.warning(self, "New Folder", f"There is already something called "
3632
+ f"“{name}” in {parent.name}.")
3633
+ return
3634
+ except OSError as error:
3635
+ QMessageBox.warning(self, "New Folder", str(error.strerror or error))
3636
+ return
3637
+ if parent.resolve() != self.workspace.folder:
3638
+ self._expanded.add(self.workspace.relative(parent))
3639
+ self.workspace.update_settings(expanded=sorted(self._expanded))
3640
+ if self.view_mode != "folder":
3641
+ self.set_view_mode("folder")
3642
+ self._rescan_workspace()
3643
+ item = self.folder_items.get(self.workspace.relative(target))
3644
+ if item is not None:
3645
+ self.tree.blockSignals(True)
3646
+ self.tree.setCurrentItem(item)
3647
+ self.tree.blockSignals(False)
3648
+ self.statusBar().showMessage(f"Made the folder {name}", 5000)
3649
+
3650
+ def move_paths(self, paths: list[str], folder: str | Path) -> None:
3651
+ """Move files or folders of the open folder into ``folder`` (dragging in the sidebar)."""
3652
+ target_folder = Path(folder)
3653
+ moved = []
3654
+ for source in map(Path, paths):
3655
+ if not source.exists() or source.resolve().parent == target_folder.resolve():
3656
+ continue
3657
+ if source.is_dir() and (source.resolve() == target_folder.resolve()
3658
+ or source.resolve() in target_folder.resolve().parents):
3659
+ QMessageBox.warning(self, "Move", f"“{source.name}” cannot go inside itself.")
3660
+ continue
3661
+ target = target_folder / source.name
3662
+ if target.exists():
3663
+ answer = ask_about_clash(self, target)
3664
+ if answer is None:
3665
+ continue
3666
+ if answer == "keep":
3667
+ target = unique_path(target_folder, source.name)
3668
+ else:
3669
+ self.trash_paths_quietly([str(target)])
3670
+ self._flush_autosaves()
3671
+ try:
3672
+ shutil.move(str(source), str(target))
3673
+ except OSError as error:
3674
+ QMessageBox.warning(self, "Could not move",
3675
+ f"{source.name}\n\n{error.strerror or error}")
3676
+ continue
3677
+ self._paths_moved(source, target)
3678
+ moved.append(target)
3679
+ if moved:
3680
+ what = moved[0].name if len(moved) == 1 else f"{len(moved)} items"
3681
+ where = target_folder.name if target_folder.resolve() != self.workspace.folder \
3682
+ else self.workspace.name
3683
+ self.statusBar().showMessage(f"Moved {what} to {where}", 6000)
3684
+ self._rescan_workspace(force=True)
3685
+
3686
+ def trash_paths_quietly(self, paths: list[str]) -> None:
3687
+ """Replace: the file being replaced goes to the Bin (its document closes)."""
3688
+ inside = [d.id for d in self.documents
3689
+ if d.path and any(self._key(d.path) == self._key(p) for p in paths)]
3690
+ self.remove_documents(inside, confirm=False)
3691
+ for path in paths:
3692
+ move_to_trash(path)
3693
+
3694
+ def _drop_folder(self, item) -> str | None:
3695
+ """Where a drop on this sidebar row goes, in the Folder view."""
3696
+ if self.workspace is None or self.view_mode != "folder":
3697
+ return None
3698
+ root = str(self.workspace.folder)
3699
+ if item is None:
3700
+ return root
3701
+ if item.data(0, FOLDER_ROLE):
3702
+ return item.data(0, FOLDER_ROLE)
3703
+ path = self._row_path(item)
3704
+ if path and self.workspace.contains(path):
3705
+ return str(Path(path).resolve().parent)
3706
+ return root
3707
+
3708
+ def _sidebar_drop(self, paths: list[str], folder: str | None, internal: bool) -> None:
3709
+ """Files dropped on the sidebar: moved within the folder, or brought in."""
3710
+ if folder is None: # not the Folder view
3711
+ if not internal:
3712
+ self._open_dropped(paths)
3713
+ return
3714
+ inside = [p for p in paths if self.workspace.contains(p)]
3715
+ outside = [p for p in paths if p not in inside]
3716
+ if inside:
3717
+ self.move_paths(inside, folder)
3718
+ folders = [p for p in outside if Path(p).is_dir()]
3719
+ if folders:
3720
+ self.open_workspace(folders[0]) # a folder from elsewhere: work in it
3721
+ return
3722
+ for path in self._bring_in(outside, folder) if outside else []:
3723
+ self.open_path(path)
3724
+
3725
+ # ---------------------------------------------------------------- settings
3726
+ def show_settings(self) -> None:
3727
+ dialog = SettingsDialog({
3728
+ "autosave": self.autosave_enabled,
3729
+ "import": self._setting("files/import", "ask"),
3730
+ "restore": self.restore_action.isChecked(),
3731
+ "check_updates": self._setting("updates/automatic", True),
3732
+ }, self)
3733
+ if dialog.exec() != QDialog.Accepted:
3734
+ return
3735
+ values = dialog.values()
3736
+ self.set_autosave(values["autosave"])
3737
+ self._set_setting("files/import", values["import"])
3738
+ self.restore_action.setChecked(values["restore"])
3739
+ self._set_setting("updates/automatic", values["check_updates"])
3740
+
3741
+ # ---------------------------------------------------------------- exercises
3742
+ def action_open_exercise(self) -> None:
3743
+ """File ▸ Open Exercise Pack…: a pack, a chapter of one, or one exercise."""
3744
+ start = str(self.workspace.folder) if self.workspace else dialog_folder()
3745
+ folder = QFileDialog.getExistingDirectory(self, "Open exercise pack",
3746
+ start or str(Path.home()))
3747
+ if folder:
3748
+ self.open_exercise(folder)
3749
+
3750
+ def action_import_exam(self) -> None:
3751
+ """Learn ▸ Make a Pack from an Exam…: the exam's text (numbered questions
3752
+ with lettered parts) becomes a skeleton pack for the author to finish."""
3753
+ from ...learn.importer import todo_list, write_pack
3754
+ start = str(self.workspace.folder) if self.workspace else dialog_folder()
3755
+ source, _ = QFileDialog.getOpenFileName(self, "The exam's text", start or str(Path.home()),
3756
+ "Text or Markdown (*.txt *.md);;All files (*)")
3757
+ if not source:
3758
+ return
3759
+ parent = QFileDialog.getExistingDirectory(self, "Where should the pack go?",
3760
+ str(Path(source).parent))
3761
+ if not parent:
3762
+ return
3763
+ title = Path(source).stem
3764
+ target = unique_path(parent, title)
3765
+ try:
3766
+ text = Path(source).read_text(encoding="utf-8", errors="replace")
3767
+ write_pack(text, target, title)
3768
+ except (OSError, ValueError) as error:
3769
+ QMessageBox.warning(self, "Make a pack", f"Could not make a pack from "
3770
+ f"{Path(source).name}:\n\n{error}")
3771
+ return
3772
+ todos = todo_list(target)
3773
+ QMessageBox.information(
3774
+ self, "Make a pack", f"Made “{target.name}” with one exercise per question.\n\n"
3775
+ f"{len(todos)} TODO{'s' * (len(todos) != 1)} left for you: the nets and logs the exam "
3776
+ "showed as pictures, the computes to pick, the model answers. Open the question.md "
3777
+ "files, then run openprocess exercises check on the folder.")
3778
+ self.open_exercise(target)
3779
+
3780
+ @staticmethod
3781
+ def demo_exercises_source() -> Path:
3782
+ """The demo exercises that ship with OpenProcess (read-only in an installed app)."""
3783
+ return Path(__file__).resolve().parents[2] / "exercises"
3784
+
3785
+ def demo_exercises_target(self) -> Path | None:
3786
+ """Where the demo exercises are copied (so your answers can be saved): the
3787
+ copy made before, else a folder you choose now (None: you cancelled)."""
3788
+ remembered = self._setting("exercises/demo_copy", "")
3789
+ if remembered and Path(remembered).is_dir():
3790
+ return Path(remembered)
3791
+ start = str(self.workspace.folder) if self.workspace else dialog_folder()
3792
+ parent = QFileDialog.getExistingDirectory(
3793
+ self, "Where should the demo exercises go? (Your answers are saved there.)",
3794
+ start or str(Path.home()))
3795
+ if not parent:
3796
+ return None
3797
+ return unique_path(parent, "OpenProcess Demo Exercises")
3798
+
3799
+ def open_demo_exercises(self) -> None:
3800
+ """Copy the demo exercises (once, where you say) and do them."""
3801
+ target = self.demo_exercises_target()
3802
+ if target is None:
3803
+ return
3804
+ source = self.demo_exercises_source()
3805
+ try:
3806
+ if not target.exists():
3807
+ shutil.copytree(source, target)
3808
+ self.statusBar().showMessage(f"Copied the demo exercises to {target}", 8000)
3809
+ elif not (target / "pack.md").exists():
3810
+ # A copy made by an older version: bring its worksheets up to
3811
+ # date; your answers (my answer…) are left alone.
3812
+ shutil.copytree(source, target, dirs_exist_ok=True)
3813
+ except OSError as error:
3814
+ QMessageBox.warning(self, "Demo exercises", f"Could not copy the demo "
3815
+ f"exercises to {target}:\n\n{error}")
3816
+ return
3817
+ self._set_setting("exercises/demo_copy", str(target))
3818
+ self.open_exercise(target)
3819
+
3820
+ def open_exercise(self, folder: str | Path) -> bool:
3821
+ """Do the exercises in ``folder`` (a pack, or one exercise of one) in
3822
+ Learn. An exercise belongs to the nearest folder above it with
3823
+ a ``pack.md``, else to the folder open in the sidebar."""
3824
+ from ...learn.pack import find_exercises, pack_root
3825
+ folder = Path(folder).resolve()
3826
+ if exercise_files(folder) is not None:
3827
+ boundary = self.workspace.folder if self.workspace is not None and \
3828
+ self.workspace.contains(folder) else None
3829
+ root, start = pack_root(folder, boundary), folder
3830
+ elif find_exercises(folder):
3831
+ root, start = folder, None
3832
+ else:
3833
+ QMessageBox.information(self, "Open exercises", f"{folder}\n\nThere are no "
3834
+ "exercises here: an exercise is a folder with a "
3835
+ "question.md (or question.pdf or question.png).")
3836
+ return False
3837
+ self._flush_autosaves()
3838
+ if not self.learn_mode.open_pack(root, start):
3839
+ return False
3840
+ self.modes.setCurrentWidget(self.learn_mode)
3841
+ self.learn_mode._update_bar() # the window's title names the exercise
3842
+ self.statusBar().showMessage("Answers are saved in each exercise's folder as you go",
3843
+ 6000)
3844
+ return True
3845
+
3846
+ @property
3847
+ def in_learn(self) -> bool:
3848
+ return self.modes.currentWidget() is self.learn_mode
3849
+
3850
+ def leave_learn(self) -> None:
3851
+ """Exit: back to the folder, as it was (with your answers' files now in it)."""
3852
+ self.learn_mode.close_view()
3853
+ self.modes.setCurrentIndex(0)
3854
+ if self.workspace is not None:
3855
+ self._rescan_workspace(force=True)
3856
+ self._set_title(self._current_document()) # back to the file you had open
3857
+
3858
+ def _petri_of(self, document):
3859
+ """The :class:`PetriNet` a document stands for (a drawn net as it is now)."""
3860
+ if isinstance(document, ModelDocument):
3861
+ return document.net
3862
+ from ...model.plain import to_petri_net
3863
+ return to_petri_net(document.net)
3864
+
3865
+ def replay_trace(self, document, trace, mapping: dict | None = None) -> None:
3866
+ """Fire as much of ``trace`` (labels) as the net allows, in its token game."""
3867
+ page = self.pages.get(document.id)
3868
+ if not isinstance(page, PetriNetPage):
3869
+ return
3870
+ from ...mining.analysis import check_workflow_net
3871
+ from ...mining.compare_nets import replayable_prefix
3872
+ petri = page.petri_net()
3873
+ source = None
3874
+ if not petri.initial_marking:
3875
+ workflow = check_workflow_net(petri)
3876
+ source = workflow.source if workflow.is_workflow_net else None
3877
+ path, done = replayable_prefix(petri, trace, mapping)
3878
+ self._select_document(document)
3879
+ page.replay(path, source)
3880
+ shown = "⟨" + ", ".join(trace) + "⟩"
3881
+ if done < len(trace):
3882
+ self.statusBar().showMessage(
3883
+ f"{shown}: your net can do the first {done} step(s), then not "
3884
+ f"“{trace[done]}” — this is where it differs", 12000)
3885
+ else:
3886
+ self.statusBar().showMessage(f"{shown}: replayed on your net", 8000)
3887
+
3888
+ # ---------------------------------------------------------------- comparing nets
3889
+ def _nets(self) -> list:
3890
+ return [d for d in self.documents if isinstance(d, ModelDocument) or
3891
+ (isinstance(d, CpnDocument) and getattr(d.net, "plain", False))]
3892
+
3893
+ def action_compare_nets(self) -> None:
3894
+ """Compare two open Petri nets on behaviour (complete traces)."""
3895
+ nets = self._nets()
3896
+ if len(nets) < 2:
3897
+ QMessageBox.information(self, "Compare nets", "Open at least two Petri nets to "
3898
+ "compare them.")
3899
+ return
3900
+ dialog = QDialog(self)
3901
+ dialog.setWindowTitle("Compare nets")
3902
+ form = QFormLayout(dialog)
3903
+ first, second = QComboBox(), QComboBox()
3904
+ for box in (first, second):
3905
+ for document in nets:
3906
+ box.addItem(document.name, document.id)
3907
+ selected = [i for i in self.selected_ids() if any(d.id == i for d in nets)]
3908
+ if selected:
3909
+ first.setCurrentIndex(first.findData(selected[0]))
3910
+ second.setCurrentIndex(1 if first.currentIndex() == 0 else 0)
3911
+ form.addRow(label("Do the two nets allow the same complete traces (silent steps "
3912
+ "ignored, transitions matched by label)?", "muted", wrap=True))
3913
+ form.addRow("Net", first)
3914
+ form.addRow("Compare with", second)
3915
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
3916
+ buttons.button(QDialogButtonBox.Ok).setText("Compare")
3917
+ buttons.accepted.connect(dialog.accept)
3918
+ buttons.rejected.connect(dialog.reject)
3919
+ form.addRow(buttons)
3920
+ if dialog.exec() != QDialog.Accepted or first.currentData() == second.currentData():
3921
+ return
3922
+ self.compare_nets(self._document(first.currentData()),
3923
+ self._document(second.currentData()))
3924
+
3925
+ def compare_nets(self, first, second, mapping: dict | None = None) -> None:
3926
+ from ...mining.compare_nets import compare_nets
3927
+ mine, other = self._petri_of(first), self._petri_of(second)
3928
+ self.statusBar().showMessage(f"Comparing {first.name} with {second.name}…")
3929
+
3930
+ def done(comparison) -> None:
3931
+ from .net_comparison import NetComparisonView
3932
+ dialog = getattr(self, "net_comparison_dialog", None)
3933
+ if dialog is None:
3934
+ dialog = QDialog(self)
3935
+ dialog.resize(560, 420)
3936
+ dialog.setLayout(QVBoxLayout())
3937
+ self.net_comparison_dialog = dialog
3938
+ while dialog.layout().count():
3939
+ item = dialog.layout().takeAt(0)
3940
+ if item.widget():
3941
+ item.widget().deleteLater()
3942
+ dialog.setWindowTitle(f"{first.name} vs {second.name}")
3943
+ view = NetComparisonView(
3944
+ comparison, f"“{first.name}”", f"“{second.name}”",
3945
+ replay=(lambda trace, _f: self.replay_trace(first, trace, mapping))
3946
+ if isinstance(self.pages.get(first.id), PetriNetPage) else None,
3947
+ recompare=lambda m: self.compare_nets(first, second, m))
3948
+ dialog.layout().addWidget(scroll(view), 1)
3949
+ self.statusBar().showMessage(comparison.summary(first.name, second.name), 8000)
3950
+ dialog.show()
3951
+ dialog.raise_()
3952
+
3953
+ run_in_background(lambda: compare_nets(mine, other, mapping), done,
3954
+ lambda message: self.statusBar().showMessage(
3955
+ f"Comparison failed: {message}", 8000))
3956
+
3957
+ def action_new_ts(self) -> None:
3958
+ """Type a transition system to study its regions."""
3959
+ from ...mining.transition_system import parse_transition_system
3960
+ text, ok = QInputDialog.getMultiLineText(
3961
+ self, "New transition system", "Transitions, as s0 -a-> s1 (one per line or "
3962
+ "separated by commas). Optional: initial: s0",
3963
+ "s0 -a-> s1, s0 -b-> s2\ns1 -b-> s3, s2 -a-> s3\ninitial: s0")
3964
+ if not ok or not text.strip():
3965
+ return
3966
+ names = {d.name for d in self.documents}
3967
+ number = 1
3968
+ while f"Transition system {number}" in names:
3969
+ number += 1
3970
+ try:
3971
+ ts = parse_transition_system(text, f"Transition system {number}")
3972
+ except ValueError as error:
3973
+ QMessageBox.warning(self, "New transition system", str(error))
3974
+ return
3975
+ self.add_document(TransitionSystemDocument(ts))
3976
+
3977
+ # ---------------------------------------------------------------- drag & drop
3978
+ def dragEnterEvent(self, event) -> None: # noqa: N802
3979
+ if event.mimeData().hasUrls():
3980
+ event.acceptProposedAction()
3981
+
3982
+ def dropEvent(self, event) -> None: # noqa: N802
3983
+ self._open_dropped([url.toLocalFile() for url in event.mimeData().urls()
3984
+ if url.isLocalFile()])
3985
+
3986
+ def _open_dropped(self, paths: list[str]) -> None:
3987
+ folders = [p for p in paths if Path(p).is_dir()]
3988
+ if folders and exercise_files(folders[0]) is not None:
3989
+ self.open_exercise(folders[0]) # an exercise: do it
3990
+ elif folders:
3991
+ self.open_workspace(folders[0]) # a folder: work in it
3992
+ self.open_files([p for p in paths if not Path(p).is_dir()])
3993
+
3994
+
3995
+ def main(argv: list[str] | None = None) -> int:
3996
+ argv = list(sys.argv if argv is None else argv)
3997
+ if sys.platform == "win32":
3998
+ # Without its own AppUserModelID, Windows groups the window under
3999
+ # python.exe and shows Python's icon in the taskbar.
4000
+ try:
4001
+ import ctypes
4002
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("OpenProcess.Studio")
4003
+ except (AttributeError, OSError):
4004
+ pass
4005
+ application = QApplication.instance() or QApplication(argv)
4006
+ application.setApplicationName(APPLICATION_NAME)
4007
+ application.setWindowIcon(app_icon())
4008
+ application.setStyle("Fusion")
4009
+ application.setFont(theme.ui_font(13))
4010
+ application.setStyleSheet(style.stylesheet())
4011
+ window = StudioWindow(persist=True)
4012
+ # Follow the system appearance live: every colour is looked up through
4013
+ # style.tokens() at paint time, so re-applying the stylesheet and
4014
+ # repainting is enough when the user switches light <-> dark.
4015
+ hints = application.styleHints()
4016
+ if hasattr(hints, "colorSchemeChanged"):
4017
+ hints.colorSchemeChanged.connect(
4018
+ lambda *_: (application.setStyleSheet(style.stylesheet()), window.restyle()))
4019
+ window.show()
4020
+ window.restore_session()
4021
+ QTimer.singleShot(3000, window.check_automatically)
4022
+ for path in argv[1:]:
4023
+ if path == "--new-cpn": # `openprocess-cpn` without a file
4024
+ window.action_new_cpn()
4025
+ elif not path.startswith("-"):
4026
+ window.open_files([path])
4027
+ return application.exec()
4028
+
4029
+
4030
+ if __name__ == "__main__":
4031
+ raise SystemExit(main())