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