openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,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