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,899 @@
|
|
|
1
|
+
"""Small reusable widgets: cards, stat tiles, segmented controls, chips.
|
|
2
|
+
|
|
3
|
+
These are deliberately plain ``QWidget`` subclasses styled through object
|
|
4
|
+
names (``#card``, ``#statValue`` ...) so that every look-and-feel decision
|
|
5
|
+
stays in :mod:`style`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt, Signal
|
|
11
|
+
from PySide6.QtGui import QColor, QFontMetricsF, QPainter, QPainterPath, QPen
|
|
12
|
+
from PySide6.QtWidgets import (
|
|
13
|
+
QButtonGroup, QFrame, QMenu, QHBoxLayout, QLabel, QLayout, QPushButton, QSizePolicy, QStyle, QStyledItemDelegate,
|
|
14
|
+
QVBoxLayout, QWidget,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from .. import theme
|
|
18
|
+
from . import style
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Layout helpers
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
def shortcut_text(keys: str) -> str:
|
|
25
|
+
"""A shortcut as this computer writes it: ``"Ctrl+S"`` is shown as ⌘S on
|
|
26
|
+
a Mac and as Ctrl+S on Windows and Linux (Qt swaps Ctrl and ⌘ for the
|
|
27
|
+
actual key presses in the same way)."""
|
|
28
|
+
from PySide6.QtGui import QKeySequence
|
|
29
|
+
return QKeySequence(keys).toString(QKeySequence.NativeText)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def modifier_text(name: str) -> str:
|
|
33
|
+
"""A modifier key's name as this computer writes it: ``"Ctrl"`` is ⌘ on a
|
|
34
|
+
Mac, ``"Alt"`` is ⌥, ``"Shift"`` is ⇧; unchanged elsewhere."""
|
|
35
|
+
import sys
|
|
36
|
+
if sys.platform != "darwin":
|
|
37
|
+
return name
|
|
38
|
+
return {"Ctrl": "⌘", "Alt": "⌥", "Shift": "⇧", "Meta": "⌃"}.get(name, name)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hbox(*widgets, spacing: int = 8, margins=(0, 0, 0, 0), stretch_last=False) -> QHBoxLayout:
|
|
42
|
+
layout = QHBoxLayout()
|
|
43
|
+
layout.setSpacing(spacing)
|
|
44
|
+
layout.setContentsMargins(*margins)
|
|
45
|
+
for widget in widgets:
|
|
46
|
+
if widget is None:
|
|
47
|
+
layout.addStretch(1)
|
|
48
|
+
elif isinstance(widget, int):
|
|
49
|
+
layout.addSpacing(widget)
|
|
50
|
+
elif isinstance(widget, QWidget):
|
|
51
|
+
layout.addWidget(widget)
|
|
52
|
+
else:
|
|
53
|
+
layout.addLayout(widget)
|
|
54
|
+
if stretch_last:
|
|
55
|
+
layout.addStretch(1)
|
|
56
|
+
return layout
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def vbox(*widgets, spacing: int = 8, margins=(0, 0, 0, 0)) -> QVBoxLayout:
|
|
60
|
+
layout = QVBoxLayout()
|
|
61
|
+
layout.setSpacing(spacing)
|
|
62
|
+
layout.setContentsMargins(*margins)
|
|
63
|
+
for widget in widgets:
|
|
64
|
+
if widget is None:
|
|
65
|
+
layout.addStretch(1)
|
|
66
|
+
elif isinstance(widget, int):
|
|
67
|
+
layout.addSpacing(widget)
|
|
68
|
+
elif isinstance(widget, QWidget):
|
|
69
|
+
layout.addWidget(widget)
|
|
70
|
+
else:
|
|
71
|
+
layout.addLayout(widget)
|
|
72
|
+
return layout
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def label(text: str = "", name: str | None = None, wrap: bool = False,
|
|
76
|
+
selectable: bool = False) -> QLabel:
|
|
77
|
+
widget = QLabel(text)
|
|
78
|
+
if name:
|
|
79
|
+
widget.setObjectName(name)
|
|
80
|
+
widget.setWordWrap(wrap)
|
|
81
|
+
if wrap:
|
|
82
|
+
# Wrapped labels must be allowed to grow vertically, otherwise some
|
|
83
|
+
# layouts give them their one-line height and the text is clipped.
|
|
84
|
+
widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
|
|
85
|
+
if selectable:
|
|
86
|
+
widget.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
87
|
+
return widget
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def button(text: str, slot=None, kind: str | None = None, tooltip: str | None = None) -> QPushButton:
|
|
91
|
+
widget = QPushButton(text)
|
|
92
|
+
if kind:
|
|
93
|
+
widget.setObjectName(kind)
|
|
94
|
+
if slot is not None:
|
|
95
|
+
widget.clicked.connect(slot)
|
|
96
|
+
if tooltip:
|
|
97
|
+
widget.setToolTip(tooltip)
|
|
98
|
+
widget.setCursor(Qt.PointingHandCursor)
|
|
99
|
+
return widget
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Card(QFrame):
|
|
103
|
+
"""A rounded surface with an optional title row."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, title: str | None = None, caption: str | None = None,
|
|
106
|
+
padding: int = 16, parent=None) -> None:
|
|
107
|
+
super().__init__(parent)
|
|
108
|
+
self.setObjectName("card")
|
|
109
|
+
self.body = QVBoxLayout(self)
|
|
110
|
+
self.body.setContentsMargins(padding, padding - 2, padding, padding)
|
|
111
|
+
self.body.setSpacing(10)
|
|
112
|
+
self.header = QHBoxLayout()
|
|
113
|
+
self.header.setSpacing(8)
|
|
114
|
+
if title:
|
|
115
|
+
self.title_label = label(title, "cardTitle")
|
|
116
|
+
self.header.addWidget(self.title_label)
|
|
117
|
+
self.header.addStretch(1)
|
|
118
|
+
if title or caption:
|
|
119
|
+
self.body.addLayout(self.header)
|
|
120
|
+
if caption:
|
|
121
|
+
self.caption_label = label(caption, "cardCaption", wrap=True)
|
|
122
|
+
self.body.addWidget(self.caption_label)
|
|
123
|
+
#: Items before this index are chrome (title, caption); see clear().
|
|
124
|
+
self.fixed_count = self.body.count()
|
|
125
|
+
|
|
126
|
+
def clear(self) -> None:
|
|
127
|
+
"""Remove all content, keeping the title and caption."""
|
|
128
|
+
while self.body.count() > self.fixed_count:
|
|
129
|
+
item = self.body.takeAt(self.body.count() - 1)
|
|
130
|
+
if item.widget():
|
|
131
|
+
item.widget().hide()
|
|
132
|
+
item.widget().deleteLater()
|
|
133
|
+
elif item.layout():
|
|
134
|
+
_delete_layout(item.layout())
|
|
135
|
+
|
|
136
|
+
def add(self, item, stretch: int = 0):
|
|
137
|
+
if isinstance(item, QWidget):
|
|
138
|
+
self.body.addWidget(item, stretch)
|
|
139
|
+
else:
|
|
140
|
+
self.body.addLayout(item, stretch)
|
|
141
|
+
if self.concealed:
|
|
142
|
+
_set_visible(item, False)
|
|
143
|
+
return item
|
|
144
|
+
|
|
145
|
+
# -- hidden in an exercise (see concealment.py) ---------------------------------
|
|
146
|
+
concealed = False
|
|
147
|
+
|
|
148
|
+
def set_concealment(self, concealment, key: str) -> None:
|
|
149
|
+
"""Follow ``concealment`` (None: never hidden) for the result ``key``."""
|
|
150
|
+
previous = getattr(self, "_concealment", None)
|
|
151
|
+
if previous is not None and previous is not concealment:
|
|
152
|
+
try:
|
|
153
|
+
previous.changed.disconnect(self._follow_concealment)
|
|
154
|
+
except (RuntimeError, TypeError):
|
|
155
|
+
pass
|
|
156
|
+
if concealment is not None and concealment is not previous:
|
|
157
|
+
concealment.changed.connect(self._follow_concealment)
|
|
158
|
+
self._concealment, self._conceal_key = concealment, key
|
|
159
|
+
self._follow_concealment()
|
|
160
|
+
|
|
161
|
+
def _follow_concealment(self) -> None:
|
|
162
|
+
concealment = getattr(self, "_concealment", None)
|
|
163
|
+
hidden = concealment is not None and concealment.hidden(self._conceal_key)
|
|
164
|
+
if hidden == self.concealed:
|
|
165
|
+
return
|
|
166
|
+
if hidden and not hasattr(self, "reveal_row"):
|
|
167
|
+
from ..learn.concealment import RESULTS
|
|
168
|
+
what = RESULTS.get(self._conceal_key, "this result")
|
|
169
|
+
self.reveal_row = QFrame()
|
|
170
|
+
self.reveal_row.setObjectName("revealRow")
|
|
171
|
+
reveal = button("Reveal", lambda: self._concealment and self._concealment.reveal(
|
|
172
|
+
self._conceal_key), tooltip=f"Show {what}")
|
|
173
|
+
reveal.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
|
174
|
+
self.reveal_button = reveal
|
|
175
|
+
text = QVBoxLayout()
|
|
176
|
+
text.setSpacing(1)
|
|
177
|
+
text.addWidget(label("Hidden in this exercise", "revealTitle"))
|
|
178
|
+
text.addWidget(label(f"Work out {what} yourself first.", "muted", wrap=True))
|
|
179
|
+
row = hbox(spacing=10, margins=(12, 9, 10, 9))
|
|
180
|
+
row.addLayout(text, 1)
|
|
181
|
+
row.addWidget(reveal, 0, Qt.AlignVCenter)
|
|
182
|
+
self.reveal_row.setLayout(row)
|
|
183
|
+
self.body.insertWidget(self.fixed_count, self.reveal_row)
|
|
184
|
+
self.fixed_count += 1
|
|
185
|
+
self.concealed = hidden
|
|
186
|
+
if hasattr(self, "reveal_row"):
|
|
187
|
+
self.reveal_row.setVisible(hidden)
|
|
188
|
+
# In an exam nothing is revealed: the button goes, the notice stays.
|
|
189
|
+
self.reveal_button.setVisible(not getattr(concealment, "locked", False))
|
|
190
|
+
for index in range(self.fixed_count, self.body.count()):
|
|
191
|
+
_set_visible(self.body.itemAt(index), not hidden)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _set_visible(item, visible: bool) -> None:
|
|
195
|
+
"""Show or hide a widget, or every widget in a layout (or layout item)."""
|
|
196
|
+
if isinstance(item, QWidget):
|
|
197
|
+
item.setVisible(visible)
|
|
198
|
+
return
|
|
199
|
+
widget = item.widget() if hasattr(item, "widget") else None
|
|
200
|
+
if widget is not None:
|
|
201
|
+
widget.setVisible(visible)
|
|
202
|
+
return
|
|
203
|
+
layout = item.layout() if hasattr(item, "layout") and not hasattr(item, "count") \
|
|
204
|
+
else item
|
|
205
|
+
if layout is None or not hasattr(layout, "count"):
|
|
206
|
+
return
|
|
207
|
+
for index in range(layout.count()):
|
|
208
|
+
_set_visible(layout.itemAt(index), visible)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _delete_layout(layout) -> None:
|
|
212
|
+
while layout.count():
|
|
213
|
+
item = layout.takeAt(0)
|
|
214
|
+
if item.widget():
|
|
215
|
+
item.widget().hide()
|
|
216
|
+
item.widget().deleteLater()
|
|
217
|
+
elif item.layout():
|
|
218
|
+
_delete_layout(item.layout())
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class StatTile(QFrame):
|
|
222
|
+
"""label · value · sub-line, the standard figure tile."""
|
|
223
|
+
|
|
224
|
+
def __init__(self, caption: str, value: str = "–", sub: str = "", parent=None) -> None:
|
|
225
|
+
super().__init__(parent)
|
|
226
|
+
self.setObjectName("card")
|
|
227
|
+
layout = QVBoxLayout(self)
|
|
228
|
+
layout.setContentsMargins(14, 11, 14, 12)
|
|
229
|
+
layout.setSpacing(2)
|
|
230
|
+
self.caption = label(caption, "statLabel")
|
|
231
|
+
self.value = label(value, "statValue")
|
|
232
|
+
self.sub = label(sub, "statSub", wrap=True)
|
|
233
|
+
self.value.setWordWrap(True)
|
|
234
|
+
layout.addWidget(self.caption)
|
|
235
|
+
layout.addWidget(self.value)
|
|
236
|
+
layout.addWidget(self.sub)
|
|
237
|
+
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
238
|
+
|
|
239
|
+
def set(self, value: str, sub: str = "") -> None:
|
|
240
|
+
self.value.setText(value)
|
|
241
|
+
self.sub.setText(sub)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class SegmentedControl(QFrame):
|
|
245
|
+
"""A row of mutually exclusive pill buttons (like NSSegmentedControl)."""
|
|
246
|
+
|
|
247
|
+
changed = Signal(int)
|
|
248
|
+
|
|
249
|
+
def __init__(self, labels: list[str], parent=None, compact: bool = False) -> None:
|
|
250
|
+
super().__init__(parent)
|
|
251
|
+
self.setObjectName("segmented")
|
|
252
|
+
# Compact: tighter padding and a smaller font, for narrow panels.
|
|
253
|
+
self.setProperty("compact", compact)
|
|
254
|
+
layout = QHBoxLayout(self)
|
|
255
|
+
layout.setContentsMargins(2, 2, 2, 2)
|
|
256
|
+
layout.setSpacing(2)
|
|
257
|
+
self.group = QButtonGroup(self)
|
|
258
|
+
self.group.setExclusive(True)
|
|
259
|
+
self.buttons: list[QPushButton] = []
|
|
260
|
+
for index, text in enumerate(labels):
|
|
261
|
+
item = QPushButton(text)
|
|
262
|
+
item.setCheckable(True)
|
|
263
|
+
item.setCursor(Qt.PointingHandCursor)
|
|
264
|
+
self.group.addButton(item, index)
|
|
265
|
+
layout.addWidget(item)
|
|
266
|
+
self.buttons.append(item)
|
|
267
|
+
if self.buttons:
|
|
268
|
+
self.buttons[0].setChecked(True)
|
|
269
|
+
self.group.idClicked.connect(self.changed.emit)
|
|
270
|
+
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
|
271
|
+
|
|
272
|
+
def index(self) -> int:
|
|
273
|
+
return self.group.checkedId()
|
|
274
|
+
|
|
275
|
+
def set_index(self, index: int) -> None:
|
|
276
|
+
if 0 <= index < len(self.buttons):
|
|
277
|
+
self.buttons[index].setChecked(True)
|
|
278
|
+
self.changed.emit(index)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class Verdict(QWidget):
|
|
282
|
+
"""Icon + property name + explanation, coloured by status.
|
|
283
|
+
|
|
284
|
+
The icon glyph and the word carry the meaning; colour only reinforces it.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
def __init__(self, name: str, status: str, detail: str, parent=None,
|
|
288
|
+
definition: str | None = None, instance: list[str] | None = None) -> None:
|
|
289
|
+
"""``definition`` is a key of :mod:`openprocess.mining.definitions` (by default
|
|
290
|
+
looked up from ``name``); ``instance`` are LaTeX lines that fill the
|
|
291
|
+
definition in for the net at hand. With a definition, hovering over the
|
|
292
|
+
property shows it and clicking opens it (see :mod:`.definition_view`)."""
|
|
293
|
+
super().__init__(parent)
|
|
294
|
+
from html import escape
|
|
295
|
+
from ...mining.definitions import lookup
|
|
296
|
+
known = lookup(definition or name)
|
|
297
|
+
layout = QHBoxLayout(self)
|
|
298
|
+
layout.setContentsMargins(0, 4, 0, 4)
|
|
299
|
+
layout.setSpacing(10)
|
|
300
|
+
badge = _StatusBadge(status)
|
|
301
|
+
layout.addWidget(badge, 0, Qt.AlignTop)
|
|
302
|
+
text = QVBoxLayout()
|
|
303
|
+
text.setSpacing(1)
|
|
304
|
+
if known is not None:
|
|
305
|
+
muted = style.tokens().text_muted
|
|
306
|
+
title = label(f"<span>{escape(name)} <span style='color: {muted}; "
|
|
307
|
+
"font-weight: 400'>ⓘ</span></span>")
|
|
308
|
+
else:
|
|
309
|
+
title = label(name)
|
|
310
|
+
title.setStyleSheet("font-weight: 600;")
|
|
311
|
+
text.addWidget(title)
|
|
312
|
+
#: The title label (tests click it to open the definition).
|
|
313
|
+
self.title = title
|
|
314
|
+
if detail:
|
|
315
|
+
text.addWidget(label(detail, "muted", wrap=True, selectable=True))
|
|
316
|
+
layout.addLayout(text, 1)
|
|
317
|
+
if known is not None:
|
|
318
|
+
from .definition_view import attach_definition
|
|
319
|
+
attach_definition(self, known.key, instance, targets=[badge, title])
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class _StatusBadge(QWidget):
|
|
323
|
+
def __init__(self, status: str) -> None:
|
|
324
|
+
super().__init__()
|
|
325
|
+
self.status = status
|
|
326
|
+
self.setFixedSize(20, 20)
|
|
327
|
+
|
|
328
|
+
def paintEvent(self, _event) -> None: # noqa: N802
|
|
329
|
+
painter = QPainter(self)
|
|
330
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
331
|
+
colour = QColor(style.STATUS.get(self.status, style.tokens().text_muted))
|
|
332
|
+
painter.setPen(Qt.NoPen)
|
|
333
|
+
painter.setBrush(colour)
|
|
334
|
+
painter.drawEllipse(QRectF(1, 1, 18, 18))
|
|
335
|
+
painter.setPen(QColor("#ffffff") if self.status != "warning" else QColor("#0b0b0b"))
|
|
336
|
+
font = painter.font()
|
|
337
|
+
font.setBold(True)
|
|
338
|
+
font.setPixelSize(12)
|
|
339
|
+
painter.setFont(font)
|
|
340
|
+
painter.drawText(QRectF(0, 0, 20, 20), Qt.AlignCenter,
|
|
341
|
+
style.STATUS_ICON.get(self.status, "?"))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def status_for(value: bool | None) -> str:
|
|
345
|
+
return "unknown" if value is None else ("good" if value else "critical")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def fitness_status(value: float) -> str:
|
|
349
|
+
"""Traffic-light bands for a quality value in [0, 1]."""
|
|
350
|
+
if value >= 0.95:
|
|
351
|
+
return "good"
|
|
352
|
+
if value >= 0.8:
|
|
353
|
+
return "warning"
|
|
354
|
+
return "critical"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# Activity chips
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
def paint_chips(painter: QPainter, rect: QRectF, sequence: list[tuple[str, str, str | None]],
|
|
361
|
+
font, max_chip: float = 150.0) -> float:
|
|
362
|
+
"""Paint ``(text, fill, outline)`` chips left to right inside ``rect``.
|
|
363
|
+
|
|
364
|
+
Returns the x coordinate after the last chip. Chips that do not fit are
|
|
365
|
+
replaced by a ``+n`` counter, so a long variant never overflows its row.
|
|
366
|
+
"""
|
|
367
|
+
metrics = QFontMetricsF(font)
|
|
368
|
+
painter.setFont(font)
|
|
369
|
+
x = rect.left()
|
|
370
|
+
height = min(rect.height(), metrics.height() + 8)
|
|
371
|
+
y = rect.center().y() - height / 2
|
|
372
|
+
for index, (text, fill, outline) in enumerate(sequence):
|
|
373
|
+
shown = metrics.elidedText(text, Qt.ElideRight, max_chip - 16)
|
|
374
|
+
width = metrics.horizontalAdvance(shown) + 16
|
|
375
|
+
remaining = len(sequence) - index
|
|
376
|
+
if x + width > rect.right() - (34 if remaining > 1 else 0):
|
|
377
|
+
more = f"+{remaining}"
|
|
378
|
+
painter.setPen(QColor(style.tokens().text_muted))
|
|
379
|
+
painter.drawText(QRectF(x + 2, y, 40, height), Qt.AlignVCenter | Qt.AlignLeft, more)
|
|
380
|
+
return x + 40
|
|
381
|
+
path = QPainterPath()
|
|
382
|
+
path.addRoundedRect(QRectF(x, y, width, height), height / 2, height / 2)
|
|
383
|
+
painter.setPen(QPen(QColor(outline), 1.5) if outline else Qt.NoPen)
|
|
384
|
+
painter.setBrush(QColor(fill))
|
|
385
|
+
painter.drawPath(path)
|
|
386
|
+
painter.setPen(QColor(style.text_on(fill)))
|
|
387
|
+
painter.drawText(QRectF(x, y, width, height), Qt.AlignCenter, shown)
|
|
388
|
+
x += width + 4
|
|
389
|
+
return x
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class SequenceDelegate(QStyledItemDelegate):
|
|
393
|
+
"""Draws a list/table cell's ``Qt.UserRole`` value as activity chips.
|
|
394
|
+
|
|
395
|
+
The role holds ``list[(text, fill, outline)]``.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
def paint(self, painter: QPainter, option, index) -> None:
|
|
399
|
+
sequence = index.data(Qt.UserRole)
|
|
400
|
+
if not sequence:
|
|
401
|
+
super().paint(painter, option, index)
|
|
402
|
+
return
|
|
403
|
+
painter.save()
|
|
404
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
405
|
+
if option.state & QStyle.State_Selected:
|
|
406
|
+
painter.fillRect(option.rect, QColor(style.tokens().accent_soft))
|
|
407
|
+
font = option.font
|
|
408
|
+
theme.set_px(font, max(theme.px(font) - 1, 9))
|
|
409
|
+
paint_chips(painter, QRectF(option.rect).adjusted(6, 3, -6, -3), sequence, font)
|
|
410
|
+
painter.restore()
|
|
411
|
+
|
|
412
|
+
def sizeHint(self, option, index) -> QSize: # noqa: N802
|
|
413
|
+
base = super().sizeHint(option, index)
|
|
414
|
+
return QSize(base.width(), max(base.height(), 30))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class BarDelegate(QStyledItemDelegate):
|
|
418
|
+
"""Draws a number with a thin horizontal bar (``Qt.UserRole`` = fraction)."""
|
|
419
|
+
|
|
420
|
+
def __init__(self, colour_role: int | None = None, parent=None) -> None:
|
|
421
|
+
super().__init__(parent)
|
|
422
|
+
self.colour_role = colour_role
|
|
423
|
+
|
|
424
|
+
def paint(self, painter: QPainter, option, index) -> None:
|
|
425
|
+
fraction = index.data(Qt.UserRole)
|
|
426
|
+
if fraction is None:
|
|
427
|
+
super().paint(painter, option, index)
|
|
428
|
+
return
|
|
429
|
+
painter.save()
|
|
430
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
431
|
+
if option.state & QStyle.State_Selected:
|
|
432
|
+
painter.fillRect(option.rect, QColor(style.tokens().accent_soft))
|
|
433
|
+
rect = QRectF(option.rect).adjusted(8, 0, -8, 0)
|
|
434
|
+
text = str(index.data(Qt.DisplayRole) or "")
|
|
435
|
+
metrics = QFontMetricsF(option.font)
|
|
436
|
+
text_width = 64.0
|
|
437
|
+
bar_rect = QRectF(rect.left() + text_width + 6, rect.center().y() - 4,
|
|
438
|
+
max(rect.width() - text_width - 6, 10) * float(fraction), 8)
|
|
439
|
+
colour = index.data(self.colour_role) if self.colour_role else None
|
|
440
|
+
painter.setPen(Qt.NoPen)
|
|
441
|
+
painter.setBrush(QColor(colour or style.categorical(0)))
|
|
442
|
+
if bar_rect.width() > 0.5:
|
|
443
|
+
painter.drawRoundedRect(bar_rect, 3, 3)
|
|
444
|
+
painter.setPen(QColor(style.tokens().text))
|
|
445
|
+
painter.drawText(QRectF(rect.left(), rect.top(), text_width, rect.height()),
|
|
446
|
+
Qt.AlignVCenter | Qt.AlignRight, metrics.elidedText(
|
|
447
|
+
text, Qt.ElideRight, text_width))
|
|
448
|
+
painter.restore()
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class ColourDot(QWidget):
|
|
452
|
+
"""A small filled circle: the identity mark beside a legend label."""
|
|
453
|
+
|
|
454
|
+
def __init__(self, colour: str, size: int = 10) -> None:
|
|
455
|
+
super().__init__()
|
|
456
|
+
self.colour = colour
|
|
457
|
+
self.setFixedSize(size, size)
|
|
458
|
+
|
|
459
|
+
def paintEvent(self, _event) -> None: # noqa: N802
|
|
460
|
+
painter = QPainter(self)
|
|
461
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
462
|
+
painter.setPen(Qt.NoPen)
|
|
463
|
+
painter.setBrush(QColor(self.colour))
|
|
464
|
+
painter.drawEllipse(self.rect().adjusted(0, 0, -1, -1))
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def is_dark() -> bool:
|
|
468
|
+
return theme.is_dark()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class ElidedLabel(QLabel):
|
|
472
|
+
"""A one-line label that shortens itself with "…" instead of forcing width.
|
|
473
|
+
|
|
474
|
+
A normal QLabel insists on being as wide as its text, which is how a long
|
|
475
|
+
file name in a header can push the whole window wider than the screen.
|
|
476
|
+
This one reports a tiny minimum width and elides when squeezed; the full
|
|
477
|
+
text is always available as a tooltip.
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
def __init__(self, text: str = "", name: str | None = None) -> None:
|
|
481
|
+
super().__init__(text)
|
|
482
|
+
if name:
|
|
483
|
+
self.setObjectName(name)
|
|
484
|
+
self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
|
485
|
+
self.setMinimumWidth(40)
|
|
486
|
+
self.hint = ""
|
|
487
|
+
self.setToolTip(text)
|
|
488
|
+
|
|
489
|
+
def setText(self, text: str) -> None: # noqa: N802 - Qt naming
|
|
490
|
+
super().setText(text)
|
|
491
|
+
self._update_tooltip()
|
|
492
|
+
|
|
493
|
+
def set_hint(self, hint: str) -> None:
|
|
494
|
+
"""A second tooltip line that stays when the text changes ("Double-click to rename")."""
|
|
495
|
+
self.hint = hint
|
|
496
|
+
self._update_tooltip()
|
|
497
|
+
|
|
498
|
+
def _update_tooltip(self) -> None:
|
|
499
|
+
self.setToolTip(self.text() + (f"\n{self.hint}" if self.hint else ""))
|
|
500
|
+
|
|
501
|
+
def paintEvent(self, _event) -> None: # noqa: N802
|
|
502
|
+
painter = QPainter(self)
|
|
503
|
+
painter.setPen(self.palette().color(self.foregroundRole()))
|
|
504
|
+
painter.setFont(self.font())
|
|
505
|
+
rect = self.contentsRect()
|
|
506
|
+
text = self.fontMetrics().elidedText(self.text(), Qt.ElideRight, rect.width())
|
|
507
|
+
painter.drawText(rect, int(self.alignment() | Qt.AlignVCenter), text)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class PageHeader(QWidget):
|
|
511
|
+
"""Large title, muted subtitle, and a right-aligned row of actions.
|
|
512
|
+
|
|
513
|
+
Double-clicking the title emits :attr:`title_double_clicked`; the main
|
|
514
|
+
window uses it to rename the document.
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
title_double_clicked = Signal()
|
|
518
|
+
|
|
519
|
+
def __init__(self, title: str = "", subtitle: str = "", parent=None) -> None:
|
|
520
|
+
super().__init__(parent)
|
|
521
|
+
self.setObjectName("pageHeader")
|
|
522
|
+
layout = QHBoxLayout(self)
|
|
523
|
+
layout.setContentsMargins(20, 16, 20, 10)
|
|
524
|
+
layout.setSpacing(12)
|
|
525
|
+
text = QVBoxLayout()
|
|
526
|
+
text.setSpacing(2)
|
|
527
|
+
self.title = ElidedLabel(title, "pageTitle")
|
|
528
|
+
self.subtitle = ElidedLabel(subtitle, "pageSubtitle")
|
|
529
|
+
text.addWidget(self.title)
|
|
530
|
+
text.addWidget(self.subtitle)
|
|
531
|
+
layout.addLayout(text, 1)
|
|
532
|
+
self.actions = QHBoxLayout()
|
|
533
|
+
self.actions.setSpacing(8)
|
|
534
|
+
layout.addLayout(self.actions)
|
|
535
|
+
|
|
536
|
+
def set_text(self, title: str, subtitle: str) -> None:
|
|
537
|
+
self.title.setText(title)
|
|
538
|
+
self.subtitle.setText(subtitle)
|
|
539
|
+
|
|
540
|
+
def mouseDoubleClickEvent(self, event) -> None: # noqa: N802 - Qt naming
|
|
541
|
+
# Labels ignore mouse events, so a double-click on the title lands here.
|
|
542
|
+
if self.title.geometry().contains(event.position().toPoint()):
|
|
543
|
+
self.title_double_clicked.emit()
|
|
544
|
+
event.accept()
|
|
545
|
+
return
|
|
546
|
+
super().mouseDoubleClickEvent(event)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class _RoundedMenus(QObject):
|
|
550
|
+
"""Gives every pop-up -- menus, the list a combo box drops down, tooltips --
|
|
551
|
+
a see-through window, so the stylesheet's rounded corners show (otherwise
|
|
552
|
+
the corners are filled in square)."""
|
|
553
|
+
|
|
554
|
+
POPUPS = ("QComboBoxPrivateContainer", "QTipLabel")
|
|
555
|
+
|
|
556
|
+
def eventFilter(self, watched, event) -> bool: # noqa: N802
|
|
557
|
+
if event.type() == QEvent.Show and isinstance(watched, QWidget) \
|
|
558
|
+
and watched.metaObject().className() == "QComboBoxPrivateContainer":
|
|
559
|
+
self._fit_list(watched)
|
|
560
|
+
if event.type() == QEvent.Polish and isinstance(watched, QWidget) \
|
|
561
|
+
and (isinstance(watched, QMenu) or watched.metaObject().className()
|
|
562
|
+
in self.POPUPS) \
|
|
563
|
+
and not watched.testAttribute(Qt.WA_WState_Created):
|
|
564
|
+
watched.setAttribute(Qt.WA_TranslucentBackground)
|
|
565
|
+
import sys
|
|
566
|
+
if sys.platform != "darwin":
|
|
567
|
+
# Windows and Linux draw a square shadow; macOS follows the shape.
|
|
568
|
+
watched.setWindowFlag(Qt.NoDropShadowWindowHint)
|
|
569
|
+
return False
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@staticmethod
|
|
573
|
+
def _fit_list(container: QWidget) -> None:
|
|
574
|
+
"""A combo box's list is as wide as the box: widen it to its longest
|
|
575
|
+
entry, so nothing is cut off ("Activity (co…lete events)")."""
|
|
576
|
+
from PySide6.QtWidgets import QAbstractItemView
|
|
577
|
+
view = container.findChild(QAbstractItemView)
|
|
578
|
+
if view is None or view.model() is None:
|
|
579
|
+
return
|
|
580
|
+
needed = view.sizeHintForColumn(0) + 2 * view.frameWidth() + 24
|
|
581
|
+
if view.verticalScrollBar().isVisible():
|
|
582
|
+
needed += view.verticalScrollBar().width()
|
|
583
|
+
if needed > container.width():
|
|
584
|
+
container.resize(needed, container.height())
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
_rounded_menus: _RoundedMenus | None = None
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def round_menus(application) -> None:
|
|
591
|
+
"""Round the corners of every pop-up in the app (once per application)."""
|
|
592
|
+
global _rounded_menus
|
|
593
|
+
if _rounded_menus is None:
|
|
594
|
+
_rounded_menus = _RoundedMenus(application)
|
|
595
|
+
application.installEventFilter(_rounded_menus)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
class NoticeBar(QFrame):
|
|
599
|
+
"""A bar above a page about its file: "changed on disk", "missing", …
|
|
600
|
+
|
|
601
|
+
Hidden until :meth:`show_notice`; the buttons are (text, slot) pairs.
|
|
602
|
+
"""
|
|
603
|
+
|
|
604
|
+
def __init__(self, parent=None) -> None:
|
|
605
|
+
super().__init__(parent)
|
|
606
|
+
self.setObjectName("noticeBar")
|
|
607
|
+
layout = QHBoxLayout(self)
|
|
608
|
+
layout.setContentsMargins(14, 8, 10, 8)
|
|
609
|
+
layout.setSpacing(8)
|
|
610
|
+
self.text = QLabel()
|
|
611
|
+
self.text.setWordWrap(True)
|
|
612
|
+
layout.addWidget(self.text, 1)
|
|
613
|
+
self.buttons = QHBoxLayout()
|
|
614
|
+
self.buttons.setSpacing(6)
|
|
615
|
+
layout.addLayout(self.buttons)
|
|
616
|
+
self.kind: str | None = None
|
|
617
|
+
self.setVisible(False)
|
|
618
|
+
|
|
619
|
+
def show_notice(self, kind: str, text: str, actions: list) -> None:
|
|
620
|
+
"""Show ``text`` with buttons; ``kind`` names the notice ("changed", "missing")."""
|
|
621
|
+
self.kind = kind
|
|
622
|
+
self.text.setText(text)
|
|
623
|
+
while self.buttons.count():
|
|
624
|
+
widget = self.buttons.takeAt(0).widget()
|
|
625
|
+
if widget is not None:
|
|
626
|
+
widget.deleteLater()
|
|
627
|
+
for index, (caption, slot) in enumerate(actions):
|
|
628
|
+
self.buttons.addWidget(button(caption, slot, kind="primary" if index == 0 else None))
|
|
629
|
+
self.setVisible(True)
|
|
630
|
+
|
|
631
|
+
def clear(self, kind: str | None = None) -> None:
|
|
632
|
+
"""Hide the bar (only if it shows ``kind``, when given)."""
|
|
633
|
+
if kind is None or self.kind == kind:
|
|
634
|
+
self.kind = None
|
|
635
|
+
self.setVisible(False)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ---------------------------------------------------------------------------
|
|
639
|
+
# Where file dialogs start
|
|
640
|
+
# ---------------------------------------------------------------------------
|
|
641
|
+
#: The open workspace folder ("" when there is none). Save and open dialogs
|
|
642
|
+
#: start there, so a new net is saved into the week you are working on.
|
|
643
|
+
_dialog_folder = ""
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def set_dialog_folder(folder: str | None) -> None:
|
|
647
|
+
global _dialog_folder
|
|
648
|
+
_dialog_folder = folder or ""
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def dialog_folder() -> str:
|
|
652
|
+
return _dialog_folder
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def suggested_path(file_name: str) -> str:
|
|
656
|
+
"""``file_name`` inside the workspace folder, or bare (the dialog's own choice)."""
|
|
657
|
+
if _dialog_folder:
|
|
658
|
+
from pathlib import Path
|
|
659
|
+
return str(Path(_dialog_folder) / file_name)
|
|
660
|
+
return file_name
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def scroll(widget: QWidget, horizontal: bool = False) -> "QScrollArea":
|
|
664
|
+
"""Wrap ``widget`` in a frameless scroll area.
|
|
665
|
+
|
|
666
|
+
With ``horizontal=False`` the content always matches the viewport width
|
|
667
|
+
(only vertical scrolling). With ``True`` the content may also scroll
|
|
668
|
+
sideways when the viewport is narrower than its minimum width -- used
|
|
669
|
+
around whole pages so that the *window* can always be made smaller.
|
|
670
|
+
"""
|
|
671
|
+
from PySide6.QtWidgets import QScrollArea
|
|
672
|
+
area = QScrollArea()
|
|
673
|
+
area.setWidgetResizable(True)
|
|
674
|
+
if not horizontal:
|
|
675
|
+
area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
676
|
+
area.setFrameShape(QFrame.NoFrame)
|
|
677
|
+
area.setWidget(widget)
|
|
678
|
+
return area
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
class LegendSwatch(QWidget):
|
|
682
|
+
"""A small painted key: a colour ramp, a line (thin/thick/dashed), or a dot."""
|
|
683
|
+
|
|
684
|
+
def __init__(self, kind: str, colour: str | None = None, width: float = 1.5) -> None:
|
|
685
|
+
super().__init__()
|
|
686
|
+
self.kind, self.colour, self.line_width = kind, colour, width
|
|
687
|
+
self.setFixedSize(46 if kind in ("ramp", "widths") else 30, 16)
|
|
688
|
+
|
|
689
|
+
def paintEvent(self, _event) -> None: # noqa: N802
|
|
690
|
+
t = style.tokens()
|
|
691
|
+
painter = QPainter(self)
|
|
692
|
+
painter.setRenderHint(QPainter.Antialiasing)
|
|
693
|
+
colour = QColor(self.colour or t.text_secondary)
|
|
694
|
+
if self.kind == "ramp":
|
|
695
|
+
steps = 6
|
|
696
|
+
w = self.width() / steps
|
|
697
|
+
for i in range(steps):
|
|
698
|
+
painter.setPen(Qt.NoPen)
|
|
699
|
+
painter.setBrush(QColor(style.sequential(0.1 + 0.8 * i / (steps - 1))))
|
|
700
|
+
painter.drawRoundedRect(QRectF(i * w, 3, w - 1, 10), 2, 2)
|
|
701
|
+
elif self.kind == "widths":
|
|
702
|
+
for i, width in enumerate((1.0, 2.5, 4.5)):
|
|
703
|
+
pen = QPen(colour, width)
|
|
704
|
+
pen.setCapStyle(Qt.RoundCap)
|
|
705
|
+
painter.setPen(pen)
|
|
706
|
+
x = 4 + i * 14
|
|
707
|
+
painter.drawLine(QPointF(x, 12), QPointF(x + 8, 4))
|
|
708
|
+
elif self.kind == "dot":
|
|
709
|
+
painter.setPen(Qt.NoPen)
|
|
710
|
+
painter.setBrush(colour)
|
|
711
|
+
painter.drawEllipse(QRectF(9, 2, 12, 12))
|
|
712
|
+
else:
|
|
713
|
+
pen = QPen(colour, self.line_width, Qt.DashLine if self.kind == "dashed" else Qt.SolidLine)
|
|
714
|
+
pen.setCapStyle(Qt.FlatCap)
|
|
715
|
+
painter.setPen(pen)
|
|
716
|
+
painter.drawLine(QPointF(2, 8), QPointF(28, 8))
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
class FlowLayout(QLayout):
|
|
720
|
+
"""Lays widgets out left to right and wraps onto new lines when needed.
|
|
721
|
+
|
|
722
|
+
Used for toolbars and legends, so a narrow window wraps them instead of
|
|
723
|
+
forcing the whole page wider than the screen.
|
|
724
|
+
"""
|
|
725
|
+
|
|
726
|
+
def __init__(self, parent=None, spacing: int = 8, line_spacing: int = 6) -> None:
|
|
727
|
+
super().__init__(parent)
|
|
728
|
+
self._items = []
|
|
729
|
+
self._spacing = spacing
|
|
730
|
+
self._line_spacing = line_spacing
|
|
731
|
+
self.setContentsMargins(0, 0, 0, 0)
|
|
732
|
+
|
|
733
|
+
def addItem(self, item) -> None: # noqa: N802
|
|
734
|
+
self._items.append(item)
|
|
735
|
+
|
|
736
|
+
def count(self) -> int:
|
|
737
|
+
return len(self._items)
|
|
738
|
+
|
|
739
|
+
def itemAt(self, index: int): # noqa: N802
|
|
740
|
+
return self._items[index] if 0 <= index < len(self._items) else None
|
|
741
|
+
|
|
742
|
+
def takeAt(self, index: int): # noqa: N802
|
|
743
|
+
return self._items.pop(index) if 0 <= index < len(self._items) else None
|
|
744
|
+
|
|
745
|
+
def expandingDirections(self): # noqa: N802
|
|
746
|
+
return Qt.Orientations(0)
|
|
747
|
+
|
|
748
|
+
def hasHeightForWidth(self) -> bool: # noqa: N802
|
|
749
|
+
return True
|
|
750
|
+
|
|
751
|
+
def heightForWidth(self, width: int) -> int: # noqa: N802
|
|
752
|
+
return self._arrange(QRect(0, 0, width, 0), apply=False)
|
|
753
|
+
|
|
754
|
+
def setGeometry(self, rect: QRect) -> None: # noqa: N802
|
|
755
|
+
super().setGeometry(rect)
|
|
756
|
+
self._arrange(rect, apply=True)
|
|
757
|
+
|
|
758
|
+
def sizeHint(self) -> QSize: # noqa: N802
|
|
759
|
+
return self.minimumSize()
|
|
760
|
+
|
|
761
|
+
def minimumSize(self) -> QSize: # noqa: N802
|
|
762
|
+
size = QSize()
|
|
763
|
+
for item in self._items:
|
|
764
|
+
size = size.expandedTo(item.minimumSize())
|
|
765
|
+
margins = self.contentsMargins()
|
|
766
|
+
return size + QSize(margins.left() + margins.right(), margins.top() + margins.bottom())
|
|
767
|
+
|
|
768
|
+
def _arrange(self, rect: QRect, apply: bool) -> int:
|
|
769
|
+
margins = self.contentsMargins()
|
|
770
|
+
area = rect.adjusted(margins.left(), margins.top(), -margins.right(), -margins.bottom())
|
|
771
|
+
x, y, line_height = area.x(), area.y(), 0
|
|
772
|
+
for item in self._items:
|
|
773
|
+
widget = item.widget()
|
|
774
|
+
if widget is not None and widget.isHidden():
|
|
775
|
+
continue
|
|
776
|
+
hint = item.sizeHint()
|
|
777
|
+
# Never wider than the layout: a long label gets the whole line
|
|
778
|
+
# (and elides or wraps) instead of running off the edge.
|
|
779
|
+
hint.setWidth(min(hint.width(), max(area.width(), 1)))
|
|
780
|
+
if x + hint.width() > area.right() + 1 and line_height > 0:
|
|
781
|
+
x = area.x()
|
|
782
|
+
y += line_height + self._line_spacing
|
|
783
|
+
line_height = 0
|
|
784
|
+
if apply:
|
|
785
|
+
item.setGeometry(QRect(QPoint(x, y), hint))
|
|
786
|
+
x += hint.width() + self._spacing
|
|
787
|
+
line_height = max(line_height, hint.height())
|
|
788
|
+
return y + line_height - rect.y() + margins.bottom()
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def flow(*widgets, spacing: int = 8) -> QWidget:
|
|
792
|
+
"""A widget holding ``widgets`` in a :class:`FlowLayout` (ints = extra gaps)."""
|
|
793
|
+
host = QWidget()
|
|
794
|
+
layout = FlowLayout(host, spacing=spacing)
|
|
795
|
+
for widget in widgets:
|
|
796
|
+
if isinstance(widget, int):
|
|
797
|
+
spacer = QWidget()
|
|
798
|
+
spacer.setFixedSize(max(widget - spacing, 0), 1)
|
|
799
|
+
layout.addWidget(spacer)
|
|
800
|
+
elif widget is not None:
|
|
801
|
+
layout.addWidget(widget)
|
|
802
|
+
host.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
|
803
|
+
return host
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
class Legend(QWidget):
|
|
807
|
+
"""A wrapped row of (swatch, explanation) pairs under a chart or graph."""
|
|
808
|
+
|
|
809
|
+
def __init__(self) -> None:
|
|
810
|
+
super().__init__()
|
|
811
|
+
self.layout_ = FlowLayout(self, spacing=16, line_spacing=4)
|
|
812
|
+
self.layout_.setContentsMargins(0, 2, 0, 0)
|
|
813
|
+
|
|
814
|
+
def set_items(self, items: list[tuple["LegendSwatch", str]]) -> None:
|
|
815
|
+
while self.layout_.count():
|
|
816
|
+
entry = self.layout_.takeAt(0)
|
|
817
|
+
if entry.widget():
|
|
818
|
+
entry.widget().hide() # gone now, not at the next event loop
|
|
819
|
+
entry.widget().deleteLater()
|
|
820
|
+
for swatch, text in items:
|
|
821
|
+
pair = QWidget()
|
|
822
|
+
row = QHBoxLayout(pair)
|
|
823
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
824
|
+
row.setSpacing(6)
|
|
825
|
+
row.addWidget(swatch)
|
|
826
|
+
row.addWidget(label(text, "muted"))
|
|
827
|
+
self.layout_.addWidget(pair)
|
|
828
|
+
self.layout_.invalidate()
|
|
829
|
+
self.updateGeometry()
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def short_names(names: list[str]) -> list[str]:
|
|
833
|
+
"""Distinct abbreviations for column headers: the initials of each word
|
|
834
|
+
(``check credit`` -> ``cc``), numbered where two would clash; names of up
|
|
835
|
+
to three characters stay as they are."""
|
|
836
|
+
result = []
|
|
837
|
+
for name in names:
|
|
838
|
+
words = name.replace("_", " ").split()
|
|
839
|
+
result.append(name if len(name) <= 3 else "".join(w[0] for w in words).lower()
|
|
840
|
+
or name[:3])
|
|
841
|
+
for index, value in enumerate(result):
|
|
842
|
+
clashes = [i for i, other in enumerate(result) if other == value]
|
|
843
|
+
if len(clashes) > 1:
|
|
844
|
+
for number, i in enumerate(clashes, 1):
|
|
845
|
+
result[i] = f"{value}{number}"
|
|
846
|
+
return result
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def footprint_table(fp, compact: bool = False) -> "QTableWidget":
|
|
850
|
+
"""A footprint matrix (row activity vs column activity: → ← ‖ #) as a
|
|
851
|
+
table, tinted by relation. Used for logs and for models alike.
|
|
852
|
+
|
|
853
|
+
``compact`` (narrow panels): columns are headed by short names, with the
|
|
854
|
+
full name in the header's tooltip."""
|
|
855
|
+
from PySide6.QtWidgets import QAbstractItemView, QTableWidget, QTableWidgetItem
|
|
856
|
+
from ...mining.footprint import CAUSAL, INVERSE, PARALLEL
|
|
857
|
+
activities = fp.activities
|
|
858
|
+
table = QTableWidget(len(activities), len(activities))
|
|
859
|
+
headers = short_names(activities) if compact else activities
|
|
860
|
+
table.setHorizontalHeaderLabels(headers)
|
|
861
|
+
if compact:
|
|
862
|
+
for index, name in enumerate(activities):
|
|
863
|
+
table.horizontalHeaderItem(index).setToolTip(name)
|
|
864
|
+
table.setVerticalHeaderLabels(activities)
|
|
865
|
+
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
866
|
+
table.setSelectionMode(QAbstractItemView.NoSelection)
|
|
867
|
+
table.horizontalHeader().setDefaultSectionSize(
|
|
868
|
+
40 if compact else max(52, min(140, 900 // max(len(activities), 1))))
|
|
869
|
+
table.verticalHeader().setDefaultSectionSize(30 if compact else 34)
|
|
870
|
+
table.horizontalHeader().setDefaultAlignment(Qt.AlignCenter)
|
|
871
|
+
t = style.tokens()
|
|
872
|
+
tint = {CAUSAL: style.qc(style.categorical(0), 0.16),
|
|
873
|
+
INVERSE: style.qc(style.categorical(0), 0.08),
|
|
874
|
+
PARALLEL: style.qc(style.categorical(1), 0.18)}
|
|
875
|
+
big = theme.ui_font(13 if compact else 15)
|
|
876
|
+
for i, a in enumerate(activities):
|
|
877
|
+
for j, b in enumerate(activities):
|
|
878
|
+
relation = fp.relation(a, b)
|
|
879
|
+
cell = QTableWidgetItem(relation)
|
|
880
|
+
cell.setTextAlignment(Qt.AlignCenter)
|
|
881
|
+
cell.setFont(big)
|
|
882
|
+
cell.setToolTip({"→": f"{a} → {b}: causality ({a} > {b}, never {b} > {a})",
|
|
883
|
+
"←": f"{a} ← {b}: inverse causality",
|
|
884
|
+
"‖": f"{a} ‖ {b}: parallel (both orders occur)",
|
|
885
|
+
"#": f"{a} # {b}: never directly follow each other"}[relation])
|
|
886
|
+
if relation in tint:
|
|
887
|
+
cell.setBackground(tint[relation])
|
|
888
|
+
else:
|
|
889
|
+
cell.setForeground(QColor(t.text_muted))
|
|
890
|
+
table.setItem(i, j, cell)
|
|
891
|
+
# No bigger than its cells: a small matrix should not sit in a large grey
|
|
892
|
+
# area. A large one still scrolls.
|
|
893
|
+
frame = 2 * table.frameWidth()
|
|
894
|
+
table.setMaximumWidth(table.verticalHeader().sizeHint().width() + len(activities)
|
|
895
|
+
* table.horizontalHeader().defaultSectionSize() + frame + 2)
|
|
896
|
+
if not compact: # (the Analysis tab sets the height of the compact one)
|
|
897
|
+
table.setMaximumHeight(table.horizontalHeader().sizeHint().height() + len(activities)
|
|
898
|
+
* table.verticalHeader().defaultSectionSize() + frame + 2)
|
|
899
|
+
return table
|