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,978 @@
1
+ """The answer boxes of a worksheet (see :mod:`openprocess.learn.sheet`).
2
+
3
+ Every answer block of ``question.md`` becomes a :class:`TaskCard`: the box to
4
+ answer in (an *editor*, one per type), a **Check** button, and *Hint* and
5
+ *Show answer* when the block has them. Editors share a small interface:
6
+
7
+ * ``value()`` -- the answer as stored in ``my answers.json`` (JSON);
8
+ * ``set_value(value)`` -- put a saved answer back;
9
+ * ``changed`` -- emitted on every edit (the card saves and clears old feedback);
10
+ * ``show_result(result)`` -- mark what was wrong (the footprint's cells).
11
+
12
+ The card does not check anything itself: it asks (``check_requested``) and
13
+ the exercise view answers with :meth:`TaskCard.show_result`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from html import escape
20
+
21
+ from PySide6.QtCore import Qt, QTimer, Signal
22
+ from PySide6.QtWidgets import (
23
+ QButtonGroup, QCheckBox, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMenu,
24
+ QPlainTextEdit, QPushButton, QRadioButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget,
25
+ )
26
+
27
+ from ...learn import answers, notation
28
+ from ...learn.checks import CORRECT, INCORRECT, PARTIAL, UNKNOWN
29
+ from ..studio.markdown_view import MarkdownLabel
30
+ from ..studio.widgets import button, hbox, label
31
+
32
+ #: What the card's caption says above each kind of box.
33
+ CAPTIONS = {
34
+ "net": "Draw your net", "footprint": "Your footprint", "yesno": "Your answer",
35
+ "choice": "Your answer", "set": "Your answer", "trace": "Your firing sequence",
36
+ "number": "Your answer", "text": "Your answer", "open": "Your answer",
37
+ "marking": "Your marking", "markings": "Your markings", "tuple": "Your net as (P, T, F, m₀)",
38
+ "ts": "Your transition system", "matrix": "Your matrix", "cut": "Your cut",
39
+ "log": "Your log", "tree": "Your process tree", "replay": "Your replay",
40
+ "alignment": "Your alignment", "ranking": "Your ranking", "predict": "Your prediction",
41
+ "workflow": "Build your workflow",
42
+ }
43
+
44
+ #: Status of a task -> (chip text, status colour key).
45
+ STATUS = {
46
+ CORRECT: ("Correct", "good"), PARTIAL: ("Almost", "warning"),
47
+ INCORRECT: ("Not yet", "critical"), "done": ("Done", "good"),
48
+ }
49
+
50
+
51
+ def inline_markdown(text: str) -> str:
52
+ """``*x*``, ``**x**`` and `` `x` `` as rich text (option labels, captions)."""
53
+ html = escape(text)
54
+ html = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", html)
55
+ html = re.sub(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])", r"<i>\1</i>", html)
56
+ html = re.sub(r"`(.+?)`", r"<code>\1</code>", html)
57
+ return html
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Editors
62
+ # ---------------------------------------------------------------------------
63
+ class Editor(QWidget):
64
+ changed = Signal()
65
+
66
+ def value(self):
67
+ return None
68
+
69
+ def set_value(self, value) -> None:
70
+ pass
71
+
72
+ def show_result(self, result) -> None:
73
+ pass
74
+
75
+
76
+ class LineEditor(Editor):
77
+ """One line of text: sets, numbers, short answers, traces."""
78
+
79
+ def __init__(self, placeholder: str, preview=None, parent=None) -> None:
80
+ super().__init__(parent)
81
+ layout = QVBoxLayout(self)
82
+ layout.setContentsMargins(0, 0, 0, 0)
83
+ layout.setSpacing(4)
84
+ self.edit = QLineEdit()
85
+ self.edit.setObjectName("answerLine")
86
+ self.edit.setPlaceholderText(placeholder)
87
+ self.edit.textEdited.connect(lambda _text: self._edited())
88
+ self.row = hbox(self.edit, spacing=6)
89
+ layout.addLayout(self.row)
90
+ #: preview(text) -> (shown, ok): how the answer is read, as you type.
91
+ self.preview = preview
92
+ self.reading = label("", "answerReading", wrap=True)
93
+ self.reading.setHidden(True)
94
+ layout.addWidget(self.reading)
95
+
96
+ def _edited(self) -> None:
97
+ self._show_reading()
98
+ self.changed.emit()
99
+
100
+ def _show_reading(self) -> None:
101
+ if self.preview is None:
102
+ return
103
+ text = self.edit.text()
104
+ if not text.strip():
105
+ self.reading.setHidden(True)
106
+ return
107
+ shown, ok = self.preview(text)
108
+ self.reading.setText(shown)
109
+ self.reading.setProperty("ok", ok)
110
+ self.reading.style().unpolish(self.reading)
111
+ self.reading.style().polish(self.reading)
112
+ self.reading.setHidden(False)
113
+
114
+ def value(self):
115
+ return self.edit.text()
116
+
117
+ def set_value(self, value) -> None:
118
+ self.edit.setText("" if value is None else str(value))
119
+ self._show_reading()
120
+
121
+
122
+ def set_preview(text: str) -> tuple[str, bool]:
123
+ try:
124
+ items = answers.parse(text)
125
+ except answers.AnswerSyntaxError as error:
126
+ return f"Cannot read this yet: {error}.", False
127
+ if len(items) == 1 and isinstance(items[0], frozenset):
128
+ shown = answers.show(items[0])
129
+ else:
130
+ shown = answers.show(frozenset(items))
131
+ return f"Read as {shown}", True
132
+
133
+
134
+ def trace_preview(text: str) -> tuple[str, bool]:
135
+ try:
136
+ steps = answers.trace(text)
137
+ except answers.AnswerSyntaxError as error:
138
+ return f"Cannot read this yet: {error}.", False
139
+ return f"{len(steps)} step{'s' * (len(steps) != 1)}: " + " → ".join(steps), True
140
+
141
+
142
+ def number_preview(text: str) -> tuple[str, bool]:
143
+ try:
144
+ value = answers.number(text)
145
+ except (ValueError, ZeroDivisionError):
146
+ return "Not a number yet.", False
147
+ return f"Read as {value:g}", True
148
+
149
+
150
+ class OptionRow(QFrame):
151
+ """A choice option: a radio button or check box with rich text, all clickable."""
152
+
153
+ def __init__(self, text: str, multiple: bool, group: QButtonGroup) -> None:
154
+ super().__init__()
155
+ self.setObjectName("choiceOption")
156
+ self.setCursor(Qt.PointingHandCursor)
157
+ self.indicator = QCheckBox() if multiple else QRadioButton()
158
+ self.indicator.setCursor(Qt.PointingHandCursor)
159
+ group.addButton(self.indicator)
160
+ text_label = QLabel(inline_markdown(text))
161
+ text_label.setWordWrap(True)
162
+ text_label.setTextFormat(Qt.RichText)
163
+ text_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
164
+ layout = QHBoxLayout(self)
165
+ layout.setContentsMargins(10, 7, 10, 7)
166
+ layout.setSpacing(10)
167
+ layout.addWidget(self.indicator, 0, Qt.AlignTop)
168
+ layout.addWidget(text_label, 1)
169
+
170
+ def mousePressEvent(self, event) -> None: # noqa: N802
171
+ if event.button() == Qt.LeftButton:
172
+ self.indicator.click()
173
+ super().mousePressEvent(event)
174
+
175
+
176
+ class ChoiceEditor(Editor):
177
+ def __init__(self, options, parent=None) -> None:
178
+ super().__init__(parent)
179
+ layout = QVBoxLayout(self)
180
+ layout.setContentsMargins(0, 0, 0, 0)
181
+ layout.setSpacing(6)
182
+ self.multiple = sum(o.correct for o in options) > 1
183
+ self.group = QButtonGroup(self)
184
+ self.group.setExclusive(not self.multiple)
185
+ self.rows = []
186
+ for index, option in enumerate(options):
187
+ row = OptionRow(option.text, self.multiple, self.group)
188
+ self.group.setId(row.indicator, index)
189
+ layout.addWidget(row)
190
+ self.rows.append(row)
191
+ if self.multiple:
192
+ layout.addWidget(label("More than one is right: tick all of them.", "muted"))
193
+ self.group.buttonToggled.connect(lambda *_: self.changed.emit())
194
+
195
+ def value(self):
196
+ chosen = [i for i, row in enumerate(self.rows) if row.indicator.isChecked()]
197
+ return chosen if self.multiple else (chosen[0] if chosen else None)
198
+
199
+ def set_value(self, value) -> None:
200
+ chosen = set(value if isinstance(value, list) else [] if value is None else [value])
201
+ self.group.blockSignals(True)
202
+ for index, row in enumerate(self.rows):
203
+ row.indicator.setChecked(index in chosen)
204
+ self.group.blockSignals(False)
205
+
206
+
207
+ class YesNoEditor(Editor):
208
+ def __init__(self, parent=None) -> None:
209
+ super().__init__(parent)
210
+ self.group = QButtonGroup(self)
211
+ self.buttons = {}
212
+ row = hbox(spacing=6)
213
+ for key, text in (("yes", "Yes"), ("no", "No")):
214
+ pill = QPushButton(text)
215
+ pill.setObjectName("choicePill")
216
+ pill.setCheckable(True)
217
+ pill.setCursor(Qt.PointingHandCursor)
218
+ self.group.addButton(pill)
219
+ self.buttons[key] = pill
220
+ row.addWidget(pill)
221
+ row.addStretch(1)
222
+ self.setLayout(row)
223
+ self.group.buttonClicked.connect(lambda _b: self.changed.emit())
224
+
225
+ def value(self):
226
+ return next((key for key, pill in self.buttons.items() if pill.isChecked()), None)
227
+
228
+ def set_value(self, value) -> None:
229
+ if value in self.buttons:
230
+ self.buttons[value].setChecked(True)
231
+
232
+
233
+ class TextEditor(Editor):
234
+ """A few lines of free text (``open``)."""
235
+
236
+ def __init__(self, parent=None) -> None:
237
+ super().__init__(parent)
238
+ layout = QVBoxLayout(self)
239
+ layout.setContentsMargins(0, 0, 0, 0)
240
+ self.edit = QPlainTextEdit()
241
+ self.edit.setObjectName("answerText")
242
+ self.edit.setPlaceholderText("Write your answer here.")
243
+ self.edit.setFixedHeight(96)
244
+ self.edit.textChanged.connect(self.changed.emit)
245
+ layout.addWidget(self.edit)
246
+
247
+ def value(self):
248
+ return self.edit.toPlainText()
249
+
250
+ def set_value(self, value) -> None:
251
+ self.edit.blockSignals(True)
252
+ self.edit.setPlainText(value or "")
253
+ self.edit.blockSignals(False)
254
+
255
+
256
+ #: Click order of a footprint cell, and the keys that set each relation.
257
+ SYMBOLS = ["→", "←", "‖", "#"]
258
+ KEYS = {">": "→", "<": "←", "|": "‖", "#": "#", "=": "#", "p": "‖"}
259
+
260
+
261
+ class FootprintCell(QToolButton):
262
+ def __init__(self, editor: "FootprintEditor", row: str, column: str) -> None:
263
+ super().__init__()
264
+ self.setObjectName("footprintCell")
265
+ self.editor = editor
266
+ self.row, self.column = row, column
267
+ self.setCursor(Qt.PointingHandCursor)
268
+ self.setFixedSize(38, 32)
269
+ self.setFocusPolicy(Qt.StrongFocus)
270
+ self.setToolTip(f"{row} and {column}: click to change (or type > < | #)")
271
+ self.symbol = ""
272
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
273
+ self.customContextMenuRequested.connect(self._menu)
274
+
275
+ def set_symbol(self, symbol: str, wrong: bool = False) -> None:
276
+ self.symbol = symbol
277
+ self.setText(symbol)
278
+ self.set_wrong(wrong)
279
+
280
+ def set_wrong(self, wrong: bool) -> None:
281
+ if self.property("wrong") != wrong:
282
+ self.setProperty("wrong", wrong)
283
+ self.style().unpolish(self)
284
+ self.style().polish(self)
285
+
286
+ def _menu(self, position) -> None:
287
+ menu = QMenu(self)
288
+ for symbol, meaning in zip(SYMBOLS, ("causality", "inverse causality",
289
+ "parallel", "choice (never next to each other)")):
290
+ menu.addAction(f"{symbol} {meaning}", lambda s=symbol: self.editor.choose(self, s))
291
+ menu.addSeparator()
292
+ menu.addAction("Clear", lambda: self.editor.choose(self, ""))
293
+ menu.exec(self.mapToGlobal(position))
294
+
295
+ def keyPressEvent(self, event) -> None: # noqa: N802
296
+ text = event.text()
297
+ if text in KEYS:
298
+ self.editor.choose(self, KEYS[text])
299
+ elif event.key() in (Qt.Key_Backspace, Qt.Key_Delete):
300
+ self.editor.choose(self, "")
301
+ else:
302
+ super().keyPressEvent(event)
303
+
304
+
305
+ class FootprintEditor(Editor):
306
+ """A grid of activities × activities; each cell cycles → ← ‖ #."""
307
+
308
+ def __init__(self, activities: list[str], parent=None) -> None:
309
+ super().__init__(parent)
310
+ self.activities = activities
311
+ outer = QVBoxLayout(self)
312
+ outer.setContentsMargins(0, 0, 0, 0)
313
+ outer.setSpacing(8)
314
+ grid_host = QWidget()
315
+ grid_host.setObjectName("footprintGrid")
316
+ self.grid = QGridLayout(grid_host)
317
+ self.grid.setContentsMargins(0, 0, 0, 0)
318
+ self.grid.setHorizontalSpacing(3)
319
+ self.grid.setVerticalSpacing(3)
320
+ self.cells: dict[tuple[str, str], FootprintCell] = {}
321
+ for index, activity in enumerate(activities):
322
+ top = label(escape(activity), "footprintHeading")
323
+ top.setAlignment(Qt.AlignCenter)
324
+ self.grid.addWidget(top, 0, index + 1)
325
+ side = label(escape(activity), "footprintHeading")
326
+ side.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
327
+ self.grid.addWidget(side, index + 1, 0)
328
+ for r, a in enumerate(activities):
329
+ for c, b in enumerate(activities):
330
+ cell = FootprintCell(self, a, b)
331
+ cell.clicked.connect(lambda _=False, cell=cell: self.cycle(cell))
332
+ self.grid.addWidget(cell, r + 1, c + 1)
333
+ self.cells[(a, b)] = cell
334
+ outer.addLayout(hbox(grid_host, None))
335
+ outer.addWidget(label("Click a cell to change it (→ ← ‖ #), or type > < | # on "
336
+ "it.", "muted", wrap=True))
337
+ fill = button("Fill the empty cells with #", self.fill_choice, kind="ghost")
338
+ outer.addLayout(hbox(fill, None))
339
+
340
+ def cycle(self, cell: FootprintCell) -> None:
341
+ index = SYMBOLS.index(cell.symbol) + 1 if cell.symbol in SYMBOLS else 0
342
+ self.choose(cell, SYMBOLS[index] if index < len(SYMBOLS) else "")
343
+
344
+ def choose(self, cell: FootprintCell, symbol: str) -> None:
345
+ cell.set_symbol(symbol)
346
+ self.changed.emit()
347
+
348
+ def fill_choice(self) -> None:
349
+ for cell in self.cells.values():
350
+ if not cell.symbol:
351
+ cell.set_symbol("#")
352
+ self.changed.emit()
353
+
354
+ def value(self):
355
+ return {f"{a}\t{b}": cell.symbol for (a, b), cell in self.cells.items() if cell.symbol}
356
+
357
+ def set_value(self, value) -> None:
358
+ value = value if isinstance(value, dict) else {}
359
+ for (a, b), cell in self.cells.items():
360
+ cell.set_symbol(value.get(f"{a}\t{b}", ""))
361
+
362
+ def show_result(self, result) -> None:
363
+ wrong = set(result.wrong_cells) if result is not None else set()
364
+ for key, cell in self.cells.items():
365
+ cell.set_wrong(key in wrong and bool(cell.symbol))
366
+
367
+
368
+ class NetEditor(Editor):
369
+ """The net is drawn in the editor beside the sheet; here only a pointer to it."""
370
+
371
+ go_to_net = Signal()
372
+
373
+ def __init__(self, tab_name: str, parent=None) -> None:
374
+ super().__init__(parent)
375
+ layout = hbox(spacing=8)
376
+ self.setLayout(layout)
377
+ layout.addWidget(label(f"Draw it in <b>{escape(tab_name)}</b>, on the right. "
378
+ "It is saved as you go.", "muted", wrap=True), 1)
379
+ layout.addWidget(button(f"Show {tab_name}", self.go_to_net.emit))
380
+
381
+
382
+ class WorkflowEditor(Editor):
383
+ """The workflow is built on the canvas beside the sheet; here a pointer to it."""
384
+
385
+ go_to_workflow = Signal()
386
+
387
+ def __init__(self, tab_name: str = "Workflow", parent=None) -> None:
388
+ super().__init__(parent)
389
+ layout = hbox(spacing=8)
390
+ self.setLayout(layout)
391
+ layout.addWidget(label(f"Build it in <b>{escape(tab_name)}</b>, on the right: drag boxes "
392
+ "from the list and wire them. It is saved as you go.",
393
+ "muted", wrap=True), 1)
394
+ layout.addWidget(button(f"Show {tab_name}", self.go_to_workflow.emit))
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Notations typed in a text box (markings, cuts, trees, logs, transition
399
+ # systems, alignments): several lines, read back as you type
400
+ # ---------------------------------------------------------------------------
401
+ class NotationEditor(Editor):
402
+ """A few lines in one of the course's notations, with how it is read below."""
403
+
404
+ def __init__(self, placeholder: str, preview, lines: int = 3, parent=None) -> None:
405
+ super().__init__(parent)
406
+ layout = QVBoxLayout(self)
407
+ layout.setContentsMargins(0, 0, 0, 0)
408
+ layout.setSpacing(4)
409
+ self.edit = QPlainTextEdit()
410
+ self.edit.setObjectName("answerText")
411
+ self.edit.setPlaceholderText(placeholder)
412
+ self.edit.setTabChangesFocus(True)
413
+ self.edit.setFixedHeight(26 + 20 * lines)
414
+ self.edit.textChanged.connect(self._edited)
415
+ layout.addWidget(self.edit)
416
+ self.preview = preview
417
+ self.reading = label("", "answerReading", wrap=True)
418
+ self.reading.setHidden(True)
419
+ layout.addWidget(self.reading)
420
+
421
+ def _edited(self) -> None:
422
+ self._show_reading()
423
+ self.changed.emit()
424
+
425
+ def _show_reading(self) -> None:
426
+ text = self.edit.toPlainText()
427
+ if not text.strip():
428
+ self.reading.setHidden(True)
429
+ return
430
+ shown, ok = self.preview(text)
431
+ self.reading.setText(shown)
432
+ self.reading.setProperty("ok", ok)
433
+ self.reading.style().unpolish(self.reading)
434
+ self.reading.style().polish(self.reading)
435
+ self.reading.setHidden(False)
436
+
437
+ def value(self):
438
+ return self.edit.toPlainText()
439
+
440
+ def set_value(self, value) -> None:
441
+ self.edit.blockSignals(True)
442
+ self.edit.setPlainText("" if value is None else str(value))
443
+ self.edit.blockSignals(False)
444
+ self._show_reading()
445
+
446
+
447
+ def _reading(reader, show):
448
+ """A preview function from a notation reader and how to write it back."""
449
+ def preview(text: str) -> tuple[str, bool]:
450
+ try:
451
+ value = reader(text)
452
+ except answers.AnswerSyntaxError as error:
453
+ return f"Cannot read this yet: {error}.", False
454
+ except ValueError as error:
455
+ return f"Cannot read this yet: {error}.", False
456
+ return "Read as " + show(value), True
457
+ return preview
458
+
459
+
460
+ def _ts_preview(text: str) -> tuple[str, bool]:
461
+ from ...mining.transition_system import parse_transition_system
462
+ try:
463
+ ts = parse_transition_system(text)
464
+ except ValueError as error:
465
+ return f"Cannot read this yet: {error}.", False
466
+ states, arcs = len(ts.reachable()), len(ts.transitions)
467
+ initial = ", ".join(ts.initial) if ts.initial else "none (write initial: s0)"
468
+ return (f"Read as {states} reachable state{'s' * (states != 1)}, {arcs} "
469
+ f"transition{'s' * (arcs != 1)}; initial: {initial}"), True
470
+
471
+
472
+ def _alignment_preview(text: str) -> tuple[str, bool]:
473
+ try:
474
+ top, bottom = notation.alignment(text)
475
+ except answers.AnswerSyntaxError as error:
476
+ return f"Cannot read this yet: {error}.", False
477
+ kinds = {"sync": 0, "log": 0, "model": 0}
478
+ for a, b in zip(top, bottom):
479
+ kinds["log" if b == notation.SKIP else "model" if a == notation.SKIP else "sync"] += 1
480
+ return (f"Read as {len(top)} moves: {kinds['sync']} synchronous, {kinds['log']} log only "
481
+ f"(≫ below), {kinds['model']} model only (≫ above)"), True
482
+
483
+
484
+ NOTATIONS = {
485
+ "marking": ("e.g. [p1, p4^2] (p4 twice), or [] for the empty marking", 1,
486
+ _reading(notation.marking, notation.show_marking)),
487
+ "markings": ("one marking per item, e.g. [p1], [p2, p3], [p4]", 2,
488
+ _reading(notation.markings, notation.show_markings)),
489
+ "cut": ("the operator, then the groups: → {a} {b, c, e} {d}", 1,
490
+ _reading(notation.cut, notation.show_cut)),
491
+ "tree": ("e.g. →(a, ×(∧(b, c), e), d) (or seq, xor, and, loop)", 2,
492
+ _reading(notation.tree, str)),
493
+ "log": ("e.g. [<a, b, c>^2, <a, c>]", 2, _reading(notation.log, notation.show_log)),
494
+ "ts": ("one line per arc: s0 -a-> s1\n…\ninitial: s0", 5, _ts_preview),
495
+ "alignment": ("the log moves on the first line, the model moves on the second; "
496
+ "≫ (or >>) where there is no move", 2, _alignment_preview),
497
+ }
498
+
499
+
500
+ # ---------------------------------------------------------------------------
501
+ # (P, T, F, m0): four boxes
502
+ # ---------------------------------------------------------------------------
503
+ TUPLE_HINTS = {"P": "the places: {p1, p2, …}", "T": "the transitions: {a, b, …}",
504
+ "F": "the arcs as pairs: (p1, a), (a, p2), …", "m0": "the initial marking: [p1]"}
505
+
506
+
507
+ class TupleEditor(Editor):
508
+ """``(P, T, F, m₀)``: one line each, checked part by part."""
509
+
510
+ def __init__(self, parent=None) -> None:
511
+ super().__init__(parent)
512
+ grid = QGridLayout(self)
513
+ grid.setContentsMargins(0, 0, 0, 0)
514
+ grid.setHorizontalSpacing(8)
515
+ grid.setVerticalSpacing(5)
516
+ self.fields: dict[str, QLineEdit] = {}
517
+ self.marks: dict[str, QLabel] = {}
518
+ for row, key in enumerate(notation.TUPLE_FIELDS):
519
+ name = label({"m0": "m₀"}.get(key, key) + " =", "tupleName")
520
+ name.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
521
+ edit = QLineEdit()
522
+ edit.setObjectName("answerLine")
523
+ edit.setPlaceholderText(TUPLE_HINTS[key])
524
+ edit.textEdited.connect(lambda _t: self._edited())
525
+ mark = label("", "tupleMark")
526
+ mark.setFixedWidth(18)
527
+ grid.addWidget(name, row, 0)
528
+ grid.addWidget(edit, row, 1)
529
+ grid.addWidget(mark, row, 2)
530
+ self.fields[key] = edit
531
+ self.marks[key] = mark
532
+
533
+ def _edited(self) -> None:
534
+ self.show_result(None)
535
+ self.changed.emit()
536
+
537
+ def value(self):
538
+ return {key: edit.text() for key, edit in self.fields.items()}
539
+
540
+ def set_value(self, value) -> None:
541
+ value = value if isinstance(value, dict) else {}
542
+ for key, edit in self.fields.items():
543
+ edit.setText(str(value.get(key, "") or ""))
544
+
545
+ def show_result(self, result) -> None:
546
+ parts = getattr(result, "parts", None) or {}
547
+ for key, mark in self.marks.items():
548
+ verdict = parts.get(key)
549
+ mark.setText({CORRECT: "✓", PARTIAL: "◐", INCORRECT: "✕"}.get(verdict, ""))
550
+ mark.setProperty("state", {CORRECT: "good", PARTIAL: "warning",
551
+ INCORRECT: "critical"}.get(verdict, ""))
552
+ mark.style().unpolish(mark)
553
+ mark.style().polish(mark)
554
+
555
+
556
+ # ---------------------------------------------------------------------------
557
+ # Grids of numbers: a matrix, a replay table
558
+ # ---------------------------------------------------------------------------
559
+ class GridCell(QLineEdit):
560
+ def __init__(self, editor, key) -> None:
561
+ super().__init__()
562
+ self.setObjectName("gridCell")
563
+ self.key = key
564
+ self.setFixedSize(54, 30)
565
+ self.setAlignment(Qt.AlignCenter)
566
+ self.textEdited.connect(lambda _t: editor._cell_edited(self))
567
+
568
+ def set_wrong(self, wrong: bool) -> None:
569
+ if self.property("wrong") != wrong:
570
+ self.setProperty("wrong", wrong)
571
+ self.style().unpolish(self)
572
+ self.style().polish(self)
573
+
574
+
575
+ class MatrixEditor(Editor):
576
+ """A grid with the rows and columns given (an incidence matrix, M or M′)."""
577
+
578
+ def __init__(self, rows: list[str], columns: list[str], parent=None) -> None:
579
+ super().__init__(parent)
580
+ self.rows, self.columns = list(rows), list(columns)
581
+ outer = QVBoxLayout(self)
582
+ outer.setContentsMargins(0, 0, 0, 0)
583
+ outer.setSpacing(8)
584
+ host = QWidget()
585
+ host.setObjectName("footprintGrid")
586
+ grid = QGridLayout(host)
587
+ grid.setContentsMargins(0, 0, 0, 0)
588
+ grid.setHorizontalSpacing(3)
589
+ grid.setVerticalSpacing(3)
590
+ for index, column in enumerate(self.columns):
591
+ top = label(escape(column), "footprintHeading")
592
+ top.setAlignment(Qt.AlignCenter)
593
+ grid.addWidget(top, 0, index + 1)
594
+ self.cells: dict[tuple[str, str], GridCell] = {}
595
+ for r, row in enumerate(self.rows):
596
+ side = label(escape(row), "footprintHeading")
597
+ side.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
598
+ grid.addWidget(side, r + 1, 0)
599
+ for c, column in enumerate(self.columns):
600
+ cell = GridCell(self, (row, column))
601
+ grid.addWidget(cell, r + 1, c + 1)
602
+ self.cells[(row, column)] = cell
603
+ outer.addLayout(hbox(host, None))
604
+ outer.addWidget(label("A number in every cell (0 where nothing happens). Tab moves "
605
+ "to the next cell.", "muted", wrap=True))
606
+
607
+ def _cell_edited(self, cell) -> None:
608
+ cell.set_wrong(False)
609
+ self.changed.emit()
610
+
611
+ def value(self):
612
+ return {"rows": self.rows, "columns": self.columns,
613
+ "cells": {f"{r}\t{c}": cell.text() for (r, c), cell in self.cells.items()
614
+ if cell.text().strip()}}
615
+
616
+ def set_value(self, value) -> None:
617
+ cells = (value or {}).get("cells", {}) if isinstance(value, dict) else {}
618
+ for (r, c), cell in self.cells.items():
619
+ cell.setText(str(cells.get(f"{r}\t{c}", "")))
620
+
621
+ def show_result(self, result) -> None:
622
+ wrong = {(answers.name(r), answers.name(c))
623
+ for r, c in (result.wrong_cells if result is not None else [])}
624
+ for (r, c), cell in self.cells.items():
625
+ cell.set_wrong((answers.name(r), answers.name(c)) in wrong)
626
+
627
+
628
+ class ReplayEditor(Editor):
629
+ """Produced, consumed, missing and remaining tokens, one row per trace."""
630
+
631
+ def __init__(self, traces: list[tuple[str, ...]], parent=None) -> None:
632
+ super().__init__(parent)
633
+ self.traces = [tuple(t) for t in traces]
634
+ outer = QVBoxLayout(self)
635
+ outer.setContentsMargins(0, 0, 0, 0)
636
+ outer.setSpacing(8)
637
+ host = QWidget()
638
+ host.setObjectName("footprintGrid")
639
+ grid = QGridLayout(host)
640
+ grid.setContentsMargins(0, 0, 0, 0)
641
+ grid.setHorizontalSpacing(3)
642
+ grid.setVerticalSpacing(3)
643
+ for index, column in enumerate(notation.REPLAY_COLUMNS):
644
+ top = label(column, "footprintHeading")
645
+ top.setToolTip({"p": "produced", "c": "consumed", "m": "missing",
646
+ "r": "remaining"}[column])
647
+ top.setAlignment(Qt.AlignCenter)
648
+ grid.addWidget(top, 0, index + 1)
649
+ self.cells: dict[tuple[tuple[str, ...], str], GridCell] = {}
650
+ for r, trace in enumerate(self.traces):
651
+ side = label(escape("⟨" + ", ".join(trace) + "⟩"), "footprintHeading")
652
+ side.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
653
+ grid.addWidget(side, r + 1, 0)
654
+ for c, column in enumerate(notation.REPLAY_COLUMNS):
655
+ cell = GridCell(self, (trace, column))
656
+ grid.addWidget(cell, r + 1, c + 1)
657
+ self.cells[(trace, column)] = cell
658
+ outer.addLayout(hbox(host, None))
659
+ outer.addWidget(label("p produced, c consumed, m missing, r remaining; count the "
660
+ "token in the source and the sink too.", "muted", wrap=True))
661
+
662
+ def _cell_edited(self, cell) -> None:
663
+ cell.set_wrong(False)
664
+ self.changed.emit()
665
+
666
+ @staticmethod
667
+ def _key(trace) -> str:
668
+ return "⟨" + ", ".join(trace) + "⟩"
669
+
670
+ def value(self):
671
+ rows: dict[str, dict] = {}
672
+ for (trace, column), cell in self.cells.items():
673
+ if cell.text().strip():
674
+ rows.setdefault(self._key(trace), {})[column] = cell.text()
675
+ return rows
676
+
677
+ def set_value(self, value) -> None:
678
+ value = value if isinstance(value, dict) else {}
679
+ for (trace, column), cell in self.cells.items():
680
+ row = value.get(self._key(trace)) or {}
681
+ cell.setText(str(row.get(column, "") if isinstance(row, dict) else ""))
682
+
683
+ def show_result(self, result) -> None:
684
+ wrong = set()
685
+ for shown, column in (result.wrong_cells if result is not None else []):
686
+ try:
687
+ wrong.add((tuple(answers.name(a) for a in answers.trace(shown)), column))
688
+ except answers.AnswerSyntaxError:
689
+ continue
690
+ for (trace, column), cell in self.cells.items():
691
+ cell.set_wrong((tuple(answers.name(a) for a in trace), column) in wrong)
692
+
693
+
694
+ # ---------------------------------------------------------------------------
695
+ # A ranking: the names in order, best first
696
+ # ---------------------------------------------------------------------------
697
+ class RankingEditor(Editor):
698
+ """The candidates as a list to reorder (▲ ▼, or drag), best first."""
699
+
700
+ def __init__(self, candidates: list[str], parent=None) -> None:
701
+ super().__init__(parent)
702
+ from PySide6.QtWidgets import QAbstractItemView, QListWidget
703
+ self.candidates = list(candidates)
704
+ layout = QVBoxLayout(self)
705
+ layout.setContentsMargins(0, 0, 0, 0)
706
+ layout.setSpacing(6)
707
+ self.list = QListWidget()
708
+ self.list.setObjectName("rankingList")
709
+ self.list.setDragDropMode(QAbstractItemView.InternalMove)
710
+ self.list.setSelectionMode(QAbstractItemView.SingleSelection)
711
+ self.list.setFixedHeight(10 + 30 * max(1, len(self.candidates)))
712
+ for name in self.candidates:
713
+ self.list.addItem(name)
714
+ self.list.model().rowsMoved.connect(lambda *_: self._moved())
715
+ buttons = hbox(button("▲ Up", lambda: self.move(-1), kind="ghost"),
716
+ button("▼ Down", lambda: self.move(1), kind="ghost"),
717
+ label("Best first: 1 is the highest.", "muted"), None, spacing=6)
718
+ layout.addWidget(self.list)
719
+ layout.addLayout(buttons)
720
+ self._renumber()
721
+
722
+ def _renumber(self) -> None:
723
+ for index in range(self.list.count()):
724
+ item = self.list.item(index)
725
+ name = item.data(Qt.UserRole) or item.text().split(". ", 1)[-1]
726
+ item.setData(Qt.UserRole, name)
727
+ item.setText(f"{index + 1}. {name}")
728
+
729
+ def _moved(self) -> None:
730
+ self._renumber()
731
+ self.changed.emit()
732
+
733
+ def move(self, delta: int) -> None:
734
+ row = self.list.currentRow()
735
+ if row < 0 or not 0 <= row + delta < self.list.count():
736
+ return
737
+ item = self.list.takeItem(row)
738
+ self.list.insertItem(row + delta, item)
739
+ self.list.setCurrentRow(row + delta)
740
+ self._moved()
741
+
742
+ def value(self):
743
+ return [self.list.item(i).data(Qt.UserRole) for i in range(self.list.count())]
744
+
745
+ def set_value(self, value) -> None:
746
+ if not isinstance(value, list) or sorted(map(str, value)) != sorted(self.candidates):
747
+ return
748
+ self.list.clear()
749
+ for name in value:
750
+ self.list.addItem(str(name))
751
+ self._renumber()
752
+
753
+
754
+ # ---------------------------------------------------------------------------
755
+ # The card around an editor
756
+ # ---------------------------------------------------------------------------
757
+ class TaskCard(QFrame):
758
+ """One answer box with its Check, Hint and Show answer."""
759
+
760
+ check_requested = Signal(object) # the task
761
+ changed = Signal(object) # the task (save the answer)
762
+ solution_requested = Signal(object) # the task (the card asks for the text)
763
+
764
+ def __init__(self, task, editor: Editor, folder=None, parent=None,
765
+ exam: bool = False) -> None:
766
+ super().__init__(parent)
767
+ self.setObjectName("taskCard")
768
+ self.task = task
769
+ self.editor = editor
770
+ self.folder = folder
771
+ self.status: str | None = None
772
+ #: The share of the block's points the last check gave (for partial answers).
773
+ self.share: float = 0.0
774
+ #: In an exam there is no hint, no answer, and Check only says if it is right.
775
+ self.exam = exam
776
+ layout = QVBoxLayout(self)
777
+ layout.setContentsMargins(14, 11, 14, 12)
778
+ layout.setSpacing(9)
779
+
780
+ caption = CAPTIONS.get(task.type, "Your answer")
781
+ if task.points != 1:
782
+ caption += f" · {task.points:g} points"
783
+ self.caption = label(caption.upper(), "taskCaption")
784
+ self.chip = label("", "statusChip")
785
+ self.chip.setHidden(True)
786
+ layout.addLayout(hbox(self.caption, None, self.chip))
787
+ layout.addWidget(editor)
788
+
789
+ self.check_button = button("Check", lambda: self.check_requested.emit(self.task),
790
+ kind="primary")
791
+ self.check_button.setVisible(task.checkable)
792
+ self.hint_button = button("Hint", self.toggle_hint, kind="ghost")
793
+ self.hint_button.setVisible(bool(task.hint) and not exam)
794
+ answer_text = "Show model answer" if task.type == "open" else "Show answer"
795
+ self.answer_button = button(answer_text, self.reveal_solution, kind="ghost")
796
+ # A drawn net or a free answer has a model answer only when the author wrote one.
797
+ self.answer_button.setVisible((task.type not in ("net", "open") or bool(task.solution))
798
+ and not exam)
799
+ footer = hbox(self.check_button, self.hint_button, self.answer_button, None, spacing=6)
800
+ layout.addLayout(footer)
801
+
802
+ self.feedback = label("", "feedback", wrap=True)
803
+ self.feedback.setTextFormat(Qt.RichText)
804
+ self.feedback.setHidden(True)
805
+ layout.addWidget(self.feedback)
806
+ #: Extra detail under the feedback (a net comparison).
807
+ self.detail_host = QVBoxLayout()
808
+ self.detail_host.setContentsMargins(0, 0, 0, 0)
809
+ layout.addLayout(self.detail_host)
810
+
811
+ self.hint_label = self._note("Hint", task.hint or "")
812
+ layout.addWidget(self.hint_label)
813
+ self.solution_label = self._note("Answer", "")
814
+ layout.addWidget(self.solution_label)
815
+ # Open questions: after the model answer, say how you did.
816
+ self.assess_row = QWidget()
817
+ self.assess_row.setLayout(hbox(label("How did you do?", "muted"),
818
+ button("I had it", lambda: self.assess(True)),
819
+ button("Not quite", lambda: self.assess(False)),
820
+ None, spacing=6))
821
+ self.assess_row.setHidden(True)
822
+ layout.addWidget(self.assess_row)
823
+
824
+ editor.changed.connect(self._edited)
825
+
826
+ def _note(self, title: str, text: str) -> QFrame:
827
+ note = QFrame()
828
+ note.setObjectName("taskNote")
829
+ note_layout = QVBoxLayout(note)
830
+ note_layout.setContentsMargins(10, 8, 10, 9)
831
+ note_layout.setSpacing(3)
832
+ note_layout.addWidget(label(title.upper(), "taskCaption"))
833
+ note.body = MarkdownLabel(text, self.folder)
834
+ note_layout.addWidget(note.body)
835
+ note.setHidden(True)
836
+ return note
837
+
838
+ # -- feedback ------------------------------------------------------------------------
839
+ def _edited(self) -> None:
840
+ # Feedback is about the answer as it was: it goes when you change it.
841
+ if self.status in (CORRECT, PARTIAL, INCORRECT) or not self.feedback.isHidden():
842
+ self.set_status(None)
843
+ self.feedback.setHidden(True)
844
+ self._clear_detail()
845
+ self.editor.show_result(None)
846
+ self.changed.emit(self.task)
847
+
848
+ def set_status(self, status: str | None) -> None:
849
+ self.status = status
850
+ shown = STATUS.get(status)
851
+ self.chip.setHidden(shown is None)
852
+ if shown is not None:
853
+ text, colour = shown
854
+ self.chip.setText(("✓ " if colour == "good" else "") + text)
855
+ self.chip.setProperty("state", colour)
856
+ self.chip.style().unpolish(self.chip)
857
+ self.chip.style().polish(self.chip)
858
+ self.setProperty("state", (shown or ("", ""))[1])
859
+ self.style().unpolish(self)
860
+ self.style().polish(self)
861
+
862
+ def show_checking(self) -> None:
863
+ self.feedback.setText("Checking…")
864
+ self.feedback.setProperty("state", "")
865
+ self._repolish(self.feedback)
866
+ self.feedback.setHidden(False)
867
+ self.check_button.setEnabled(False)
868
+
869
+ def lock(self) -> None:
870
+ """The exam is over: the answer stays as it is and can still be read."""
871
+ self.editor.setEnabled(False)
872
+ self.check_button.setEnabled(False)
873
+ self.assess_row.setHidden(True)
874
+
875
+ def show_result(self, result, detail: QWidget | None = None) -> None:
876
+ self.check_button.setEnabled(True)
877
+ self._clear_detail()
878
+ status = result.status
879
+ self.share = float(getattr(result, "share", 0.0) or 0.0)
880
+ icon = {CORRECT: "✓", PARTIAL: "◐", INCORRECT: "✕"}.get(status, "")
881
+ self.feedback.setText(f"<b>{icon}</b>&nbsp; {escape(result.message)}" if icon
882
+ else escape(result.message))
883
+ self.feedback.setProperty("state", STATUS.get(status, ("", ""))[1])
884
+ self._repolish(self.feedback)
885
+ self.feedback.setHidden(False)
886
+ self.set_status(status if status != UNKNOWN else self.status)
887
+ self.editor.show_result(result)
888
+ if detail is not None:
889
+ self.detail_host.addWidget(detail)
890
+
891
+ def _clear_detail(self) -> None:
892
+ while self.detail_host.count():
893
+ item = self.detail_host.takeAt(0)
894
+ if item.widget():
895
+ item.widget().hide()
896
+ item.widget().deleteLater()
897
+
898
+ @staticmethod
899
+ def _repolish(widget) -> None:
900
+ widget.style().unpolish(widget)
901
+ widget.style().polish(widget)
902
+
903
+ # -- hint and answer -------------------------------------------------------------------
904
+ def toggle_hint(self) -> None:
905
+ self.hint_label.setHidden(not self.hint_label.isHidden())
906
+ self.hint_button.setText("Hide hint" if not self.hint_label.isHidden() else "Hint")
907
+
908
+ def reveal_solution(self) -> None:
909
+ self.solution_requested.emit(self.task)
910
+
911
+ def show_solution(self, text: str) -> None:
912
+ if not text:
913
+ text = "No model answer for this one."
914
+ self.solution_label.body.set_markdown(text)
915
+ self.solution_label.setHidden(False)
916
+ self.answer_button.setHidden(True)
917
+ if self.task.type == "open" and self.status != "done":
918
+ self.assess_row.setHidden(False)
919
+
920
+ def assess(self, had_it: bool) -> None:
921
+ self.assess_row.setHidden(True)
922
+ self.set_status("done" if had_it else INCORRECT)
923
+ self.changed.emit(self.task)
924
+
925
+
926
+ def make_editor(task, activities=None, net_tab: str = "Your net", shape=None,
927
+ kind: str | None = None) -> Editor:
928
+ """The editor for ``task``. ``activities`` are the footprint's; ``shape`` is
929
+ what a grid needs (a matrix's rows and columns, a replay's traces, a
930
+ ranking's candidates); ``kind`` overrides the task's type (a prediction
931
+ answered as a set, a number…)."""
932
+ kind = kind or task.type
933
+ if kind == "set":
934
+ return LineEditor("e.g. {a, b}", set_preview)
935
+ if kind == "trace":
936
+ return LineEditor("e.g. register, send letter", trace_preview)
937
+ if kind == "number":
938
+ return LineEditor("a number, e.g. 0.75 or 3/4", number_preview)
939
+ if kind == "text":
940
+ return LineEditor("your answer")
941
+ if kind == "choice":
942
+ return ChoiceEditor(task.options)
943
+ if kind == "yesno":
944
+ return YesNoEditor()
945
+ if kind == "footprint":
946
+ return FootprintEditor(activities or [])
947
+ if kind == "net":
948
+ return NetEditor(net_tab)
949
+ if kind == "workflow":
950
+ return WorkflowEditor()
951
+ if kind in NOTATIONS:
952
+ placeholder, lines, preview = NOTATIONS[kind]
953
+ return NotationEditor(placeholder, preview, lines)
954
+ if kind == "tuple":
955
+ return TupleEditor()
956
+ if kind == "matrix":
957
+ if shape is None:
958
+ return NotationEditor("the column names on the first line, then one row per line:\n"
959
+ ". a b\np1 -1 1", _reading(notation.matrix,
960
+ notation.show_matrix), 4)
961
+ rows, columns = shape
962
+ return MatrixEditor(rows, columns)
963
+ if kind == "replay":
964
+ return ReplayEditor(shape or [])
965
+ if kind == "ranking":
966
+ return RankingEditor(shape or [])
967
+ if kind == "predict":
968
+ from ...learn.checks import _predict_kind
969
+ return make_editor(task, activities, net_tab, shape, _predict_kind(task))
970
+ return TextEditor()
971
+
972
+
973
+ def debounce(parent, delay: int, slot) -> QTimer:
974
+ timer = QTimer(parent)
975
+ timer.setSingleShot(True)
976
+ timer.setInterval(delay)
977
+ timer.timeout.connect(slot)
978
+ return timer