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,1181 @@
1
+ """OpenProcess Learn: the window for working through an exercise pack.
2
+
3
+ Learn sits on top of the app (its pages, its canvases, its workflows) and
4
+ uses :mod:`openprocess.learn` to read worksheets and check answers. Opening an
5
+ exercise (or a pack of them) swaps the whole window for this view, the way
6
+ a log and a net each have a page of their own::
7
+
8
+ +--------------------------------------------------------------------------+
9
+ | ‹ Overview Demo exercises · 2 Soundness ● ● ◐ ○ ‹ 2 / 4 › Exit |
10
+ +-------------------------------+------------------------------------------+
11
+ | SOUNDNESS | [ Given net | Your net ] |
12
+ | Exercise 2.1 — Spot the flaw | |
13
+ | | the net editor, the log, the |
14
+ | The net shows how … | transition system: whatever the |
15
+ | a. Is the net a WF-net? | exercise gives |
16
+ | ┌ YOUR ANSWER ──── ✓ Correct ┐| |
17
+ | │ (Yes) ( No ) │| |
18
+ | │ [Check] Hint Show answer │| |
19
+ | └────────────────────────────┘| |
20
+ +-------------------------------+------------------------------------------+
21
+
22
+ * **The worksheet** (left) is ``question.md`` top to bottom, with an answer
23
+ box wherever the author put one (see :mod:`openprocess.learn.sheet` and
24
+ :mod:`.answer_boxes`). Answers are saved as you type, in the exercise's
25
+ folder, and checked on request.
26
+ * **The materials** (right) are what the exercise gives: the log, the
27
+ transition system, the given net, and your net for each *draw a net*
28
+ answer. Results that would give answers away (soundness, the footprint,
29
+ discovered models, regions …) are hidden until revealed
30
+ (:mod:`.concealment`).
31
+ * **Notes** (✎ in the top bar) opens scratch paper under the worksheet, for
32
+ working things out: markings, firing sequences, sets. It is kept with the
33
+ exercise, in ``my notes.md``.
34
+ * **The top bar** goes back to the pack's overview, steps through the
35
+ exercises, and shows how far you are (one dot per exercise). In an
36
+ **exam** (``exam: yes`` in ``pack.md``) it shows the clock; there are no
37
+ hints, no answers and nothing to reveal, and when the time is up the
38
+ answers are locked. ⋯ exports the marks.
39
+ * **A workflow block** adds a *Workflow* tab beside the sheet: the full
40
+ Workflows page on ``my workflow.cpnflow``, saved as you build.
41
+
42
+ Nothing here touches the sidebar's documents: *Exit* returns to the window
43
+ exactly as it was.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from pathlib import Path
49
+
50
+ from PySide6.QtCore import QTimer, QUrl, Qt, Signal
51
+ from PySide6.QtGui import QDesktopServices, QPixmap
52
+ from PySide6.QtWidgets import (
53
+ QFileDialog, QFrame, QHBoxLayout, QInputDialog, QLabel, QMenu, QMessageBox, QPlainTextEdit,
54
+ QSizePolicy, QSplitter, QStackedWidget, QToolButton, QVBoxLayout, QWidget,
55
+ )
56
+
57
+ from ...learn.checks import CORRECT, INCORRECT, PARTIAL, Context, Result, TaskError
58
+ from ...learn.checks import check as check_answer
59
+ from ...learn.checks import expected, footprint_of, model_answer_text
60
+ from ...learn.exam import ExamState, marks_csv
61
+ from ...learn.pack import Exercise, Pack, load_pack
62
+ from .answer_boxes import TaskCard, debounce, make_editor
63
+ from .concealment import Concealment
64
+ from ..studio.markdown_view import MarkdownLabel
65
+ from ..studio.widgets import SegmentedControl, button, hbox, label, scroll
66
+ from ..studio.workers import run_in_background
67
+
68
+ #: How long after the last keystroke an answer is saved (milliseconds).
69
+ SAVE_DELAY = 400
70
+ #: How long after the last edit your net is saved.
71
+ NET_SAVE_DELAY = 800
72
+ #: How long after the last edit your workflow is saved.
73
+ WORKFLOW_SAVE_DELAY = 1200
74
+
75
+
76
+ def _tool(text: str, tooltip: str, slot, name: str = "exerciseBarButton") -> QToolButton:
77
+ tool = QToolButton()
78
+ tool.setObjectName(name)
79
+ tool.setText(text)
80
+ tool.setToolTip(tooltip)
81
+ tool.setCursor(Qt.PointingHandCursor)
82
+ tool.clicked.connect(slot)
83
+ return tool
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # The mode: top bar, overview, one exercise at a time
88
+ # ---------------------------------------------------------------------------
89
+ class LearnMode(QWidget):
90
+ """The whole window while doing exercises (see the module docstring)."""
91
+
92
+ exit_requested = Signal()
93
+ status = Signal(str)
94
+ #: What the window's title should say: the exercise, or the pack on its overview.
95
+ title_changed = Signal(str)
96
+
97
+ def __init__(self, parent=None) -> None:
98
+ super().__init__(parent)
99
+ self.setObjectName("exerciseMode")
100
+ self.pack: Pack | None = None
101
+ self.index: int | None = None
102
+ self.view: ExerciseView | None = None
103
+ #: The exam's clock (None outside an exam).
104
+ self.exam: ExamState | None = None
105
+ self._clock = QTimer(self)
106
+ self._clock.setInterval(1000)
107
+ self._clock.timeout.connect(self._tick)
108
+ root = QVBoxLayout(self)
109
+ root.setContentsMargins(0, 0, 0, 0)
110
+ root.setSpacing(0)
111
+ root.addWidget(self._build_bar())
112
+ self.stack = QStackedWidget()
113
+ self.home = QWidget()
114
+ self.home_area = scroll(self.home)
115
+ self.stack.addWidget(self.home_area)
116
+ root.addWidget(self.stack, 1)
117
+
118
+ # -- the bar ---------------------------------------------------------------------------
119
+ def _build_bar(self) -> QWidget:
120
+ bar = QFrame()
121
+ bar.setObjectName("exerciseBar")
122
+ layout = QHBoxLayout(bar)
123
+ layout.setContentsMargins(12, 8, 12, 8)
124
+ layout.setSpacing(8)
125
+ self.overview_button = _tool("‹ Overview", "All the exercises of this pack",
126
+ self.show_home)
127
+ layout.addWidget(self.overview_button)
128
+ self.where = label("", "exerciseWhere")
129
+ self.where.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
130
+ layout.addWidget(self.where, 1)
131
+ #: The exam's clock: how long is left (hidden outside an exam).
132
+ self.clock = label("", "examClock")
133
+ self.clock.setHidden(True)
134
+ layout.addWidget(self.clock)
135
+ self.dots_host = QWidget()
136
+ self.dots = QHBoxLayout(self.dots_host)
137
+ self.dots.setContentsMargins(0, 0, 0, 0)
138
+ self.dots.setSpacing(5)
139
+ layout.addWidget(self.dots_host)
140
+ layout.addSpacing(10)
141
+ self.previous_button = _tool("‹", "Previous exercise", lambda: self.step(-1))
142
+ self.position = label("", "exercisePosition")
143
+ self.next_button = _tool("›", "Next exercise", lambda: self.step(1))
144
+ for widget in (self.previous_button, self.position, self.next_button):
145
+ layout.addWidget(widget)
146
+ #: Scratch notes: open in every exercise once opened in one.
147
+ self.notes_open = False
148
+ self.notes_button = _tool("✎ Notes", "Scratch paper for this exercise, saved with "
149
+ "it", lambda: self.set_notes(not self.notes_open))
150
+ self.notes_button.setCheckable(True)
151
+ layout.addWidget(self.notes_button)
152
+ self.more_button = _tool("⋯", "More", lambda: None)
153
+ self.more_button.setPopupMode(QToolButton.InstantPopup)
154
+ self.more_menu = QMenu(self.more_button)
155
+ self.more_menu.aboutToShow.connect(self._fill_more_menu)
156
+ self.more_button.setMenu(self.more_menu)
157
+ layout.addWidget(self.more_button)
158
+ self.exit_button = button("Exit", self.leave, tooltip="Back to your folder and files")
159
+ self.exit_button.setObjectName("exerciseExit")
160
+ layout.addWidget(self.exit_button)
161
+ return bar
162
+
163
+ def _fill_more_menu(self) -> None:
164
+ menu = self.more_menu
165
+ menu.clear()
166
+ view = self.view if self.stack.currentWidget() is self.view else None
167
+ in_exam = self.pack is not None and self.pack.is_exam
168
+ if view is not None:
169
+ if not in_exam:
170
+ reveal = menu.addAction("Reveal Every Hidden Result", view.concealment.reveal_all)
171
+ reveal.setEnabled(view.concealment.anything_hidden)
172
+ again = menu.addAction("Start This Exercise Again…", self.reset_exercise)
173
+ again.setEnabled(not self.closed)
174
+ menu.addAction(_reveal_label(), lambda: _reveal(view.exercise.folder))
175
+ menu.addSeparator()
176
+ elif self.pack is not None:
177
+ menu.addAction(_reveal_label(), lambda: _reveal(self.pack.root))
178
+ menu.addSeparator()
179
+ if self.pack is not None:
180
+ menu.addAction("Export Marks…", self.export_marks)
181
+ if self.pack.wants_name:
182
+ menu.addAction("Change Your Name…", lambda: self.ask_name(force=True))
183
+ menu.addAction("Writing Exercise Packs", lambda: open_help("exercise-packs"))
184
+
185
+ def _update_bar(self) -> None:
186
+ in_exercise = self.view is not None and self.stack.currentWidget() is self.view
187
+ count = len(self.pack.exercises) if self.pack else 0
188
+ self.overview_button.setVisible(in_exercise and count > 1)
189
+ for widget in (self.previous_button, self.position, self.next_button, self.dots_host):
190
+ widget.setVisible(in_exercise and count > 1)
191
+ self.notes_button.setVisible(in_exercise)
192
+ if in_exercise:
193
+ self.notes_button.setChecked(self.view.notes_visible)
194
+ if self.pack is None:
195
+ return
196
+ self._tick()
197
+ if in_exercise:
198
+ exercise = self.pack.exercises[self.index]
199
+ chapter = self.pack.chapter(exercise)
200
+ self.where.setText(" · ".join(x for x in (self.pack.title, chapter) if x))
201
+ self.position.setText(f"{self.index + 1} / {count}")
202
+ self.previous_button.setEnabled(self.index > 0)
203
+ self.next_button.setEnabled(self.index < count - 1)
204
+ else:
205
+ self.where.setText(self.pack.title)
206
+ self._fill_dots()
207
+ if in_exercise:
208
+ self.title_changed.emit(f"{self.view.exercise.title} — {self.pack.title}")
209
+ else:
210
+ self.title_changed.emit(self.pack.title)
211
+
212
+ def _fill_dots(self) -> None:
213
+ while self.dots.count():
214
+ item = self.dots.takeAt(0)
215
+ if item.widget():
216
+ item.widget().deleteLater()
217
+ if self.pack is None or len(self.pack.exercises) > 24:
218
+ return # too many to show as dots
219
+ for index, exercise in enumerate(self.pack.exercises):
220
+ state = exercise.summary().state
221
+ dot = _tool("", f"{exercise.title} — " + {"done": "done", "started": "started",
222
+ "new": "not started"}[state],
223
+ lambda _=False, i=index: self.open_index(i), "progressDot")
224
+ dot.setProperty("state", state)
225
+ dot.setProperty("current", index == self.index and self.view is not None
226
+ and self.stack.currentWidget() is self.view)
227
+ dot.setFixedSize(12, 12)
228
+ self.dots.addWidget(dot)
229
+
230
+ # -- the exam's clock and the student's name ----------------------------------------------
231
+ @property
232
+ def closed(self) -> bool:
233
+ """Is the exam over (its answers can be read, not changed)?"""
234
+ return self.exam is not None and self.exam.closed()
235
+
236
+ def _tick(self) -> None:
237
+ if self.exam is None:
238
+ self.clock.setHidden(True)
239
+ self._clock.stop()
240
+ return
241
+ self.clock.setText(self.exam.clock_text())
242
+ self.clock.setProperty("state", "over" if self.closed else
243
+ "soon" if (self.exam.remaining() is not None and
244
+ self.exam.remaining().total_seconds() < 300) else "")
245
+ self.clock.style().unpolish(self.clock)
246
+ self.clock.style().polish(self.clock)
247
+ self.clock.setHidden(False)
248
+ if self.closed:
249
+ self._clock.stop()
250
+ if self.view is not None and not self.view.locked:
251
+ self.view.lock()
252
+ self.status.emit("Time is up: your answers are kept as they are")
253
+ elif self.exam.deadline is not None and not self._clock.isActive():
254
+ self._clock.start()
255
+
256
+ def ask_name(self, force: bool = False) -> None:
257
+ """A pack with variants needs the student's name (kept as my name.txt)."""
258
+ if self.pack is None or (self.pack.student() and not force):
259
+ return
260
+ name, ok = QInputDialog.getText(
261
+ self, "Your name", "This pack gives every student their own variant.\n"
262
+ "Your name decides yours, so type it as on the course list:",
263
+ text=self.pack.student() or "")
264
+ if ok and name.strip():
265
+ try:
266
+ self.pack.set_student(name)
267
+ except OSError as error:
268
+ self.status.emit(f"Could not keep your name: {error}")
269
+ if self.view is not None:
270
+ index = self.index
271
+ self.close_view()
272
+ self.index = None
273
+ self.open_index(index)
274
+
275
+ def export_marks(self) -> None:
276
+ """The pack's marks (points per exercise and per answer box) as CSV."""
277
+ if self.pack is None:
278
+ return
279
+ if self.view is not None:
280
+ self.view.flush()
281
+ from ..studio.widgets import suggested_path
282
+ path, _ = QFileDialog.getSaveFileName(self, "Export marks",
283
+ suggested_path(f"{self.pack.title} marks.csv"),
284
+ "CSV (*.csv)")
285
+ if not path:
286
+ return
287
+ try:
288
+ Path(path).write_text(marks_csv(self.pack), encoding="utf-8")
289
+ except OSError as error:
290
+ QMessageBox.warning(self, "Export marks", f"Could not write {path}:\n\n{error}")
291
+ return
292
+ self.status.emit(f"Exported the marks to {Path(path).name}")
293
+
294
+ # -- opening ---------------------------------------------------------------------------
295
+ def open_pack(self, root: str | Path, start: str | Path | None = None) -> bool:
296
+ """Show the pack at ``root``: its overview, or ``start`` (an exercise in it)."""
297
+ self.close_view()
298
+ self.pack = load_pack(root)
299
+ if not self.pack.exercises:
300
+ self.pack = None
301
+ self.exam = None
302
+ return False
303
+ self.exam = None
304
+ if self.pack.is_exam:
305
+ self.exam = ExamState.load(self.pack)
306
+ try:
307
+ self.exam.start()
308
+ except OSError as error:
309
+ self.status.emit(f"Could not note when the exam started: {error}")
310
+ if self.pack.wants_name:
311
+ self.ask_name()
312
+ self._build_home()
313
+ index = self.pack.index(start) if start is not None else None
314
+ if index is None and len(self.pack.exercises) == 1:
315
+ index = 0
316
+ if index is not None:
317
+ self.open_index(index)
318
+ else:
319
+ self.show_home()
320
+ return True
321
+
322
+ def open_index(self, index: int) -> None:
323
+ if self.pack is None or not 0 <= index < len(self.pack.exercises):
324
+ return
325
+ if self.view is not None and self.index == index:
326
+ self.stack.setCurrentWidget(self.view)
327
+ self._update_bar()
328
+ return
329
+ self.close_view()
330
+ self.index = index
331
+ # Read it again: the author may have changed the files meanwhile.
332
+ exercise = Exercise(self.pack.exercises[index].files, self.pack.settings)
333
+ self.pack.exercises[index] = exercise
334
+ self.view = ExerciseView(exercise, self.pack.chapter(exercise),
335
+ has_next=index < len(self.pack.exercises) - 1,
336
+ notes=self.notes_open, exam=self.pack.is_exam,
337
+ locked=self.closed)
338
+ self.view.notes_closed.connect(lambda: self.set_notes(False))
339
+ self.view.status.connect(self.status.emit)
340
+ self.view.progress_changed.connect(self._update_bar)
341
+ self.view.next_requested.connect(lambda: self.step(1))
342
+ self.stack.addWidget(self.view)
343
+ self.stack.setCurrentWidget(self.view)
344
+ self._update_bar()
345
+
346
+ def set_notes(self, show: bool) -> None:
347
+ """Open or close the Notes pane (and keep it so for the next exercise)."""
348
+ self.notes_open = show
349
+ if self.view is not None:
350
+ self.view.set_notes_visible(show)
351
+ self.notes_button.setChecked(show)
352
+
353
+ def step(self, delta: int) -> None:
354
+ if self.index is not None:
355
+ self.open_index(self.index + delta)
356
+
357
+ def show_home(self) -> None:
358
+ if self.view is not None:
359
+ self.view.flush()
360
+ self._build_home()
361
+ self.stack.setCurrentWidget(self.home_area)
362
+ self._update_bar()
363
+
364
+ def close_view(self) -> None:
365
+ if self.view is not None:
366
+ self.view.flush()
367
+ self.view.shutdown()
368
+ self.stack.removeWidget(self.view)
369
+ self.view.deleteLater()
370
+ self.view = None
371
+
372
+ def leave(self) -> None:
373
+ self.close_view()
374
+ self.exit_requested.emit()
375
+
376
+ def reset_exercise(self) -> None:
377
+ view = self.view
378
+ if view is None:
379
+ return
380
+ answer = QMessageBox.question(
381
+ self, "Start again", f"Start “{view.exercise.title}” again? Your answers and "
382
+ "nets for it are deleted.", QMessageBox.Yes | QMessageBox.Cancel)
383
+ if answer != QMessageBox.Yes:
384
+ return
385
+ index = self.index
386
+ view.discard()
387
+ self.close_view()
388
+ view.exercise.reset()
389
+ self.index = None
390
+ self.open_index(index)
391
+
392
+ # -- the overview -------------------------------------------------------------------------
393
+ def _build_home(self) -> None:
394
+ # A new page each time (the scroll area deletes the old one).
395
+ self.home = QWidget()
396
+ self.home.setObjectName("exerciseHome")
397
+ layout = QVBoxLayout(self.home)
398
+ pack = self.pack
399
+ column = QVBoxLayout()
400
+ column.setSpacing(10)
401
+ column.setContentsMargins(0, 32, 0, 40)
402
+ column.addWidget(label("EXERCISES", "sheetChapter"))
403
+ column.addWidget(label(pack.title, "sheetTitle", wrap=True))
404
+ if pack.intro:
405
+ column.addWidget(MarkdownLabel(pack.intro, pack.root))
406
+ summaries = [e.summary() for e in pack.exercises]
407
+ done = sum(s.state == "done" for s in summaries)
408
+ progress = f"{done} of {len(summaries)} exercises done"
409
+ points = sum(s.points for s in summaries)
410
+ if points and (pack.is_exam or any(t.points != 1 for e in pack.exercises
411
+ for t in e.sheet.tasks)):
412
+ progress += f" · {sum(s.earned for s in summaries):g} of {points:g} points"
413
+ if pack.is_exam:
414
+ progress += " · an exam: no hints, no answers, and the clock in the top bar"
415
+ column.addWidget(label(progress, "muted", wrap=True))
416
+ upcoming = next((i for i, s in enumerate(summaries) if s.state != "done"), 0)
417
+ start = button("Continue" if any(s.tried for s in summaries) else "Start",
418
+ lambda: self.open_index(upcoming), kind="primary")
419
+ column.addLayout(hbox(start, None))
420
+ column.addSpacing(12)
421
+ number = 0
422
+ for chapter, exercises in pack.chapters():
423
+ if chapter:
424
+ column.addSpacing(6)
425
+ column.addWidget(label(chapter.upper(), "sectionLabel"))
426
+ for exercise in exercises:
427
+ summary = summaries[number]
428
+ column.addWidget(ExerciseRow(exercise, summary, lambda i=number:
429
+ self.open_index(i)))
430
+ number += 1
431
+ column.addStretch(1)
432
+ holder = QWidget()
433
+ holder.setLayout(column)
434
+ holder.setMaximumWidth(720)
435
+ holder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
436
+ layout.setContentsMargins(24, 0, 24, 0)
437
+ layout.addLayout(_centred(holder))
438
+ self.home_area.setWidget(self.home)
439
+
440
+
441
+ def _centred(widget: QWidget) -> QHBoxLayout:
442
+ """``widget`` as wide as it may be (its maximum width), in the middle."""
443
+ row = QHBoxLayout()
444
+ row.addStretch(1)
445
+ row.addWidget(widget, 100)
446
+ row.addStretch(1)
447
+ return row
448
+
449
+
450
+ class ExerciseRow(QFrame):
451
+ """An exercise in the overview: its state, title and how far you got."""
452
+
453
+ def __init__(self, exercise: Exercise, summary, open_it) -> None:
454
+ super().__init__()
455
+ self.setObjectName("exerciseRow")
456
+ self.setCursor(Qt.PointingHandCursor)
457
+ self.open_it = open_it
458
+ layout = QHBoxLayout(self)
459
+ layout.setContentsMargins(14, 11, 14, 11)
460
+ layout.setSpacing(12)
461
+ mark = label({"done": "✓", "started": "◐", "new": "○"}[summary.state], "rowMark")
462
+ mark.setProperty("state", summary.state)
463
+ mark.setFixedWidth(18)
464
+ layout.addWidget(mark)
465
+ text = QVBoxLayout()
466
+ text.setSpacing(1)
467
+ text.addWidget(label(exercise.title, "rowTitle", wrap=True))
468
+ detail = (f"{summary.done} of {summary.total} answers done" if summary.total
469
+ else "Read and try it")
470
+ if exercise.error:
471
+ detail = "This exercise has a mistake in it: " + exercise.error
472
+ text.addWidget(label(detail, "muted", wrap=True))
473
+ layout.addLayout(text, 1)
474
+ layout.addWidget(label("›", "rowChevron"))
475
+
476
+ def mousePressEvent(self, event) -> None: # noqa: N802
477
+ if event.button() == Qt.LeftButton:
478
+ self.open_it()
479
+ super().mousePressEvent(event)
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # One exercise
484
+ # ---------------------------------------------------------------------------
485
+ class ExerciseView(QWidget):
486
+ """The worksheet beside the exercise's materials."""
487
+
488
+ status = Signal(str)
489
+ progress_changed = Signal()
490
+ next_requested = Signal()
491
+ #: The Notes pane's ✕ was clicked.
492
+ notes_closed = Signal()
493
+
494
+ def __init__(self, exercise: Exercise, chapter: str = "", has_next: bool = False,
495
+ notes: bool = False, parent=None, exam: bool = False,
496
+ locked: bool = False) -> None:
497
+ super().__init__(parent)
498
+ self.setObjectName("exerciseView")
499
+ self.exercise = exercise
500
+ self.context = Context(exercise)
501
+ self.concealment = Concealment(self)
502
+ #: An exam: no hints, no answers, nothing revealed.
503
+ self.exam = exam
504
+ self.concealment.locked = exam
505
+ #: The exam is over: answers can be read, not changed.
506
+ self.locked = False
507
+ self.workflow_page = None
508
+ self._workflow_timer = None
509
+ self.progress = exercise.load_progress()
510
+ self.cards: dict[str, TaskCard] = {}
511
+ #: Material tabs: (name, page); and the net page of each net task.
512
+ self.materials: list[tuple[str, QWidget]] = []
513
+ self.net_pages: dict[str, QWidget] = {}
514
+ self.given_net_page = None
515
+ self._net_timers: dict[str, object] = {}
516
+ self._save_timer = debounce(self, SAVE_DELAY, self.save_progress)
517
+
518
+ self._build_materials()
519
+ worksheet = self._build_worksheet(chapter, has_next)
520
+ # The worksheet with the Notes pane under it.
521
+ self.notes_panel = self._build_notes()
522
+ left = QSplitter(Qt.Vertical)
523
+ left.setObjectName("notesSplitter")
524
+ left.setHandleWidth(6)
525
+ left.addWidget(worksheet)
526
+ left.addWidget(self.notes_panel)
527
+ left.setStretchFactor(0, 3)
528
+ left.setStretchFactor(1, 2)
529
+ left.setChildrenCollapsible(False)
530
+ self.left_splitter = left
531
+ # Notes written earlier open with the exercise, so they are not forgotten.
532
+ self.set_notes_visible(notes or bool(self.notes.toPlainText().strip()))
533
+ worksheet = left
534
+ layout = QHBoxLayout(self)
535
+ layout.setContentsMargins(0, 0, 0, 0)
536
+ if self.materials:
537
+ splitter = QSplitter(Qt.Horizontal)
538
+ splitter.setObjectName("exerciseSplitter")
539
+ splitter.setHandleWidth(1)
540
+ splitter.addWidget(worksheet)
541
+ splitter.addWidget(self.material_area)
542
+ splitter.setStretchFactor(0, 2)
543
+ splitter.setStretchFactor(1, 3)
544
+ splitter.setSizes([460, 760])
545
+ splitter.setChildrenCollapsible(False)
546
+ self.splitter = splitter
547
+ layout.addWidget(splitter)
548
+ else:
549
+ layout.addWidget(worksheet)
550
+ for card in self.cards.values():
551
+ self._restore(card)
552
+ if locked:
553
+ self.lock()
554
+
555
+ def lock(self) -> None:
556
+ """Time is up: every answer box is read-only."""
557
+ self.locked = True
558
+ for card in self.cards.values():
559
+ card.lock()
560
+ for page in self.net_pages.values():
561
+ if hasattr(page, "mode_switch"):
562
+ page.mode_switch.set_index(1)
563
+ page.mode_switch.buttons[0].hide()
564
+ if self.workflow_page is not None:
565
+ self.workflow_page.setEnabled(False)
566
+
567
+ # -- notes ---------------------------------------------------------------------------------
568
+ def _build_notes(self) -> QWidget:
569
+ panel = QFrame()
570
+ panel.setObjectName("notesPanel")
571
+ column = QVBoxLayout(panel)
572
+ column.setContentsMargins(26, 10, 22, 14)
573
+ column.setSpacing(6)
574
+ close = _tool("✕", "Close the notes (they are kept)", self.notes_closed.emit)
575
+ column.addLayout(hbox(label("NOTES", "taskCaption"),
576
+ label("Scratch paper for this exercise, saved in its folder",
577
+ "muted"), None, close, spacing=10))
578
+ self.notes = QPlainTextEdit()
579
+ self.notes.setObjectName("notesEdit")
580
+ self.notes.setPlaceholderText("Work things out here: markings, firing sequences, "
581
+ "sets, a footprint…")
582
+ self.notes.setPlainText(self.exercise.load_notes())
583
+ self._notes_timer = debounce(self, SAVE_DELAY, self.save_notes)
584
+ self.notes.textChanged.connect(self._notes_timer.start)
585
+ column.addWidget(self.notes, 1)
586
+ panel.setMinimumHeight(120)
587
+ return panel
588
+
589
+ @property
590
+ def notes_visible(self) -> bool:
591
+ return not self.notes_panel.isHidden()
592
+
593
+ def set_notes_visible(self, show: bool) -> None:
594
+ self.notes_panel.setHidden(not show)
595
+ if show:
596
+ total = sum(self.left_splitter.sizes()) or 800
597
+ if self.left_splitter.sizes()[1] < 120:
598
+ self.left_splitter.setSizes([int(total * 0.62), int(total * 0.38)])
599
+ self.notes.setFocus()
600
+
601
+ def save_notes(self) -> None:
602
+ self._notes_timer.stop()
603
+ try:
604
+ self.exercise.save_notes(self.notes.toPlainText())
605
+ except OSError as error:
606
+ self.status.emit(f"Could not save your notes: {error}")
607
+
608
+ # -- materials ----------------------------------------------------------------------------
609
+ def _build_materials(self) -> None:
610
+ files = self.exercise.files
611
+ if files.log is not None:
612
+ page = self._log_page(files.log)
613
+ if page is not None:
614
+ self.materials.append(("Log", page))
615
+ if files.ts is not None:
616
+ page = self._ts_page(files.ts)
617
+ if page is not None:
618
+ self.materials.append(("Transition system", page))
619
+ net_tasks = self.exercise.net_tasks()
620
+ if files.net is not None and (not net_tasks or any(
621
+ t.type == "trace" for t in self.exercise.sheet.tasks)
622
+ or all(self.exercise.start_net(t) != files.net for t in net_tasks)):
623
+ page = self._net_page(files.net, None, editable=False)
624
+ if page is not None:
625
+ self.given_net_page = page
626
+ self.materials.append(("Given net", page))
627
+ for number, task in enumerate(net_tasks, 1):
628
+ name = "Your net" if len(net_tasks) == 1 else f"Your net {number}"
629
+ page = self._net_page(self.exercise.start_net(task),
630
+ self.exercise.answer_net_path(task), editable=True,
631
+ task=task)
632
+ if page is not None:
633
+ self.net_pages[task.id] = page
634
+ self.materials.append((name, page))
635
+ workflow_tasks = [t for t in self.exercise.sheet.tasks if t.type == "workflow"]
636
+ if workflow_tasks:
637
+ page = self._workflow_page(workflow_tasks[0])
638
+ if page is not None:
639
+ self.workflow_page = page
640
+ self.materials.append(("Workflow", page))
641
+ self.material_area = QWidget()
642
+ self.material_area.setObjectName("materials")
643
+ column = QVBoxLayout(self.material_area)
644
+ column.setContentsMargins(0, 10, 0, 0)
645
+ column.setSpacing(6)
646
+ self.tab_row = hbox(margins=(16, 0, 16, 0))
647
+ column.addLayout(self.tab_row)
648
+ self.material_stack = QStackedWidget()
649
+ for _, page in self.materials:
650
+ self.material_stack.addWidget(scroll(page, horizontal=True))
651
+ column.addWidget(self.material_stack, 1)
652
+ self.material_tabs = None
653
+ self._make_tabs()
654
+ # Your net first when there is one to draw: that is where the work is.
655
+ if self.net_pages and len(self.materials) > 1 and not self.exercise.files.log \
656
+ and not self.exercise.files.ts:
657
+ self.show_material(next(iter(self.net_pages.values())))
658
+
659
+ def _make_tabs(self) -> None:
660
+ """One tab per material (again after a discovered model was added)."""
661
+ if self.material_tabs is not None:
662
+ self.tab_row.removeWidget(self.material_tabs)
663
+ self.material_tabs.deleteLater()
664
+ while self.tab_row.count():
665
+ self.tab_row.takeAt(0)
666
+ self.material_tabs = SegmentedControl([name for name, _ in self.materials],
667
+ compact=True)
668
+ self.material_tabs.setVisible(len(self.materials) > 1)
669
+ self.material_tabs.changed.connect(self.material_stack.setCurrentIndex)
670
+ self.tab_row.addWidget(self.material_tabs)
671
+ self.tab_row.addStretch(1)
672
+
673
+ def show_material(self, page: QWidget) -> None:
674
+ for index, (_, candidate) in enumerate(self.materials):
675
+ if candidate is page:
676
+ self.material_tabs.set_index(index)
677
+ self.material_stack.setCurrentIndex(index)
678
+
679
+ def _log_page(self, path: Path):
680
+ from ...mining.csv_import import guess_mapping, read_csv, sniff
681
+ from ...mining.log import EventLog, parse_simple_log
682
+ from ...mining.xes import read_xes
683
+ from ..studio.documents import LogDocument
684
+ from ..studio.log_page import LogPage
685
+ try:
686
+ lower = path.name.lower()
687
+ if lower.endswith(".txt"):
688
+ text = path.read_text(encoding="utf-8", errors="replace")
689
+ document = LogDocument(EventLog.from_simple_log(parse_simple_log(text), "L"),
690
+ path=str(path), notation=text.strip())
691
+ elif lower.endswith(".csv"):
692
+ document = LogDocument(read_csv(str(path), guess_mapping(sniff(str(path))[1])),
693
+ path=str(path))
694
+ else:
695
+ document = LogDocument(read_xes(str(path)), path=str(path))
696
+ except Exception as error: # noqa: BLE001
697
+ self.status.emit(f"Could not read {path.name}: {error}")
698
+ return None
699
+ self.log_document = document
700
+ page = LogPage(document)
701
+ page.edit_button.hide()
702
+ page.header.hide()
703
+ page.open_model.connect(self._open_model)
704
+ page.status.connect(self.status.emit)
705
+ page.set_concealment(self.concealment)
706
+ return page
707
+
708
+ def _ts_page(self, path: Path):
709
+ from ...mining.transition_system import parse_transition_system
710
+ from ..studio.documents import TransitionSystemDocument
711
+ from ..studio.regions_view import TransitionSystemPage
712
+ try:
713
+ ts = parse_transition_system(path.read_text(encoding="utf-8", errors="replace"),
714
+ "Transition system")
715
+ except Exception as error: # noqa: BLE001
716
+ self.status.emit(f"Could not read {path.name}: {error}")
717
+ return None
718
+ # No path: trying changes to the system never writes over the given file.
719
+ page = TransitionSystemPage(TransitionSystemDocument(ts))
720
+ page.header.hide()
721
+ page.open_model.connect(self._open_model)
722
+ page.status.connect(self.status.emit)
723
+ page.set_concealment(self.concealment)
724
+ return page
725
+
726
+ def _net_page(self, start: Path | None, target: Path | None, editable: bool, task=None):
727
+ from ...mining.pnml import read_pnml
728
+ from ...model.plain import from_petri_net, new_plain_net
729
+ from ..studio.documents import CpnDocument
730
+ from ..studio.petri_page import PetriNetPage
731
+ source = target if target is not None and target.exists() else start
732
+ try:
733
+ if source is not None:
734
+ petri = read_pnml(str(source))
735
+ petri.name = source.stem
736
+ net = from_petri_net(petri)
737
+ else:
738
+ net = new_plain_net(target.stem if target is not None else "my answer")
739
+ except Exception as error: # noqa: BLE001
740
+ self.status.emit(f"Could not read {source.name}: {error}")
741
+ return None
742
+ path = str(target) if target is not None and target.exists() else None
743
+ page = PetriNetPage(CpnDocument(net, path=path))
744
+ page.header.hide()
745
+ page.status.connect(self.status.emit)
746
+ page.set_concealment(self.concealment)
747
+ if editable:
748
+ page.edited.connect(lambda t=task, p=page, target=target: self._net_edited(t, p,
749
+ target))
750
+ else:
751
+ # The given net stays as it is: play it, but not change it.
752
+ page.mode_switch.set_index(1)
753
+ page.mode_switch.buttons[0].hide()
754
+ return page
755
+
756
+ def _workflow_page(self, task):
757
+ """The workflow you build, on the canvas (my workflow.cpnflow), started
758
+ from the block's ``start:`` file or from the exercise's log."""
759
+ from ...flow import Workflow, load
760
+ from ..flow.page import WorkflowPage
761
+ from ..studio.documents import WorkflowDocument
762
+ target = self.exercise.workflow_path
763
+ library = self.context.library
764
+ try:
765
+ if target.exists():
766
+ workflow, _record = load(target, library)
767
+ else:
768
+ start = self.exercise.files.file(task.get("start") or "")
769
+ if start is not None and start.suffix.lower() == ".cpnflow":
770
+ workflow, _record = load(start, library)
771
+ else:
772
+ workflow = Workflow("my workflow", library)
773
+ log = self.exercise.files.log
774
+ if log is not None:
775
+ if log.suffix.lower() == ".txt":
776
+ workflow.add("typed_log", {"text": log.read_text(
777
+ encoding="utf-8", errors="replace").strip(), "name": "L"},
778
+ (0.0, 150.0))
779
+ else:
780
+ workflow.add("open_log", {"file": log.name}, (0.0, 150.0))
781
+ workflow.name = target.stem
782
+ except Exception as error: # noqa: BLE001
783
+ self.status.emit(f"Could not open the workflow: {error}")
784
+ return None
785
+ document = WorkflowDocument(workflow, path=str(target) if target.exists() else None)
786
+ page = WorkflowPage(document, library, self.exercise.folder)
787
+ page.keep_button.hide()
788
+ page.status.connect(self.status.emit)
789
+ page.edited.connect(lambda t=task: self._workflow_edited(t))
790
+ page.saved.connect(lambda t=task: self._workflow_saved(t))
791
+ QTimer.singleShot(0, lambda: page.run_from(None))
792
+ return page
793
+
794
+ def _workflow_edited(self, task) -> None:
795
+ if self._workflow_timer is None:
796
+ self._workflow_timer = debounce(self, WORKFLOW_SAVE_DELAY, self._save_workflow)
797
+ self._workflow_timer.start()
798
+ card = self.cards.get(task.id)
799
+ if card is not None and card.status in (CORRECT, PARTIAL, INCORRECT):
800
+ card.editor.changed.emit() # the old verdict no longer applies
801
+
802
+ def _workflow_saved(self, task) -> None:
803
+ card = self.cards.get(task.id)
804
+ if card is not None:
805
+ self._answer_changed(task)
806
+
807
+ def _save_workflow(self) -> None:
808
+ page = self.workflow_page
809
+ if page is None:
810
+ return
811
+ try:
812
+ page.write_to(str(self.exercise.workflow_path), quiet=True)
813
+ except OSError as error:
814
+ self.status.emit(f"Could not save your workflow: {error}")
815
+
816
+ def _open_model(self, document) -> None:
817
+ """A model discovered from the log (or synthesised): one more tab."""
818
+ from ..studio.model_page import ModelPage
819
+ logs = [self.log_document] if getattr(self, "log_document", None) else []
820
+ page = ModelPage(document, lambda: logs)
821
+ page.header.hide()
822
+ if hasattr(page, "set_concealment"):
823
+ page.set_concealment(self.concealment)
824
+ self.materials.append((document.name, page))
825
+ self.material_stack.addWidget(scroll(page, horizontal=True))
826
+ self._make_tabs()
827
+ self.show_material(page)
828
+
829
+ def _net_edited(self, task, page, target: Path) -> None:
830
+ timer = self._net_timers.get(task.id)
831
+ if timer is None:
832
+ timer = debounce(self, NET_SAVE_DELAY, lambda: self._save_net(page, target))
833
+ self._net_timers[task.id] = timer
834
+ timer.start()
835
+ card = self.cards.get(task.id)
836
+ if card is not None and card.status in (CORRECT, PARTIAL, INCORRECT):
837
+ card.editor.changed.emit() # the old verdict no longer applies
838
+
839
+ def _save_net(self, page, target: Path) -> None:
840
+ if not page.document.dirty and page.document.path:
841
+ return
842
+ if not page.document.path:
843
+ page._take_file_name(target)
844
+ if not page._write(target, quiet=True):
845
+ self.status.emit(f"Could not save {target.name}: "
846
+ f"{getattr(page, 'save_error', 'unknown error')}")
847
+
848
+ # -- the worksheet ----------------------------------------------------------------------------
849
+ def _build_worksheet(self, chapter: str, has_next: bool) -> QWidget:
850
+ sheet = self.exercise.sheet
851
+ folder = self.exercise.folder
852
+ column = QVBoxLayout()
853
+ column.setContentsMargins(0, 26, 0, 40)
854
+ column.setSpacing(12)
855
+ if chapter:
856
+ column.addWidget(label(chapter.split(" › ")[-1].upper(), "sheetChapter"))
857
+ self.title = label(self.exercise.title, "sheetTitle", wrap=True)
858
+ column.addWidget(self.title)
859
+ if self.exercise.error:
860
+ problem = label("This exercise has a mistake in it, so its answer boxes are "
861
+ "missing: " + self.exercise.error, "sheetProblem", wrap=True)
862
+ column.addWidget(problem)
863
+ question = self.exercise.files.question
864
+ if question.suffix.lower() not in (".md", ".txt"):
865
+ column.addWidget(self._picture_or_pdf(question))
866
+ net_names = {task_id: name for name, page in self.materials
867
+ for task_id, net_page in self.net_pages.items() if net_page is page}
868
+ for block in sheet.blocks:
869
+ if isinstance(block, str):
870
+ column.addWidget(MarkdownLabel(block, folder))
871
+ continue
872
+ card = self._card(block, net_names.get(block.id, "Your net"))
873
+ if card is not None:
874
+ column.addWidget(card)
875
+ # The whole worked answer, unless the boxes already carry it part by part.
876
+ if self.exercise.files.answer_text is not None and not any(
877
+ t.solution for t in sheet.tasks):
878
+ column.addWidget(self._worked_answer())
879
+ self.summary_label = label("", "muted")
880
+ footer = hbox(self.summary_label, None, spacing=8)
881
+ if has_next:
882
+ footer.addWidget(button("Next exercise ›", self.next_requested.emit,
883
+ kind="primary"))
884
+ column.addSpacing(8)
885
+ column.addLayout(footer)
886
+ column.addStretch(1)
887
+ holder = QWidget()
888
+ holder.setObjectName("sheetColumn")
889
+ holder.setLayout(column)
890
+ holder.setMaximumWidth(720)
891
+ holder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
892
+ page = QWidget()
893
+ page.setObjectName("worksheet")
894
+ outer = QHBoxLayout(page)
895
+ outer.setContentsMargins(26, 0, 22, 0)
896
+ outer.addWidget(holder)
897
+ if not self.materials:
898
+ outer.removeWidget(holder)
899
+ outer.addLayout(_centred(holder))
900
+ area = scroll(page)
901
+ area.setObjectName("worksheetArea")
902
+ area.setMinimumWidth(340)
903
+ self._update_summary()
904
+ return area
905
+
906
+ def _shape(self, task):
907
+ """What a grid editor is built from: a matrix's rows and columns, a
908
+ replay's traces, a ranking's candidates (None: nothing needed)."""
909
+ from ...learn import notation
910
+ if task.type == "matrix":
911
+ rows = [r.strip() for r in (task.get("rows") or "").split(",") if r.strip()]
912
+ columns = [c.strip() for c in (task.get("columns") or "").split(",") if c.strip()]
913
+ if rows and columns:
914
+ return rows, columns
915
+ right = expected(self.context, task)
916
+ matrix = notation.matrix(right) if isinstance(right, str) else right
917
+ return list(matrix.rows), list(matrix.columns)
918
+ if task.type == "replay":
919
+ if task.get("trace"):
920
+ from ...learn import answers
921
+ return [tuple(answers.trace(task.get("trace")))]
922
+ return sorted(self.context.simple_log(task.get("log")))
923
+ if task.type == "ranking":
924
+ return [n.strip() for n in (task.get("over") or "").split(",") if n.strip()]
925
+ return None
926
+
927
+ def _card(self, task, net_name: str) -> TaskCard | None:
928
+ activities, shape = None, None
929
+ try:
930
+ if task.type == "footprint":
931
+ activities = footprint_of(self.context, task).activities
932
+ shape = self._shape(task)
933
+ except Exception as error: # noqa: BLE001
934
+ return self._broken(task, error)
935
+ editor = make_editor(task, activities, net_name, shape)
936
+ card = TaskCard(task, editor, self.exercise.folder, exam=self.exam)
937
+ if task.type == "net":
938
+ page = self.net_pages.get(task.id)
939
+ editor.go_to_net.connect(lambda p=page: p is not None and self.show_material(p))
940
+ if task.type == "workflow":
941
+ editor.go_to_workflow.connect(
942
+ lambda: self.workflow_page is not None and self.show_material(self.workflow_page))
943
+ if task.type == "trace" and self.given_net_page is not None:
944
+ editor.row.addWidget(button("Play in net", lambda t=task, e=editor:
945
+ self.play_trace(e.value()),
946
+ tooltip="Fire these steps in the given net's token "
947
+ "game, as far as they go"))
948
+ card.check_requested.connect(self.check)
949
+ card.changed.connect(self._answer_changed)
950
+ card.solution_requested.connect(self._show_solution)
951
+ self.cards[task.id] = card
952
+ return card
953
+
954
+ def _broken(self, task, error) -> QWidget:
955
+ return label(f"This answer box cannot be shown: {error}", "sheetProblem", wrap=True)
956
+
957
+ def _picture_or_pdf(self, path: Path) -> QWidget:
958
+ if path.suffix.lower() == ".pdf":
959
+ try:
960
+ from PySide6.QtPdf import QPdfDocument
961
+ from PySide6.QtPdfWidgets import QPdfView
962
+ except ImportError:
963
+ return button(f"Open {path.name}", lambda: QDesktopServices.openUrl(
964
+ QUrl.fromLocalFile(str(path))), kind="primary")
965
+ view = QPdfView()
966
+ document = QPdfDocument(view)
967
+ document.load(str(path))
968
+ view.setDocument(document)
969
+ view.setPageMode(QPdfView.PageMode.MultiPage)
970
+ view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
971
+ view.setMinimumHeight(520)
972
+ self._pdf = document
973
+ return view
974
+ picture = QLabel()
975
+ pixmap = QPixmap(str(path))
976
+ picture.setPixmap(pixmap.scaledToWidth(640, Qt.SmoothTransformation)
977
+ if pixmap.width() > 640 else pixmap)
978
+ return picture
979
+
980
+ def _worked_answer(self) -> QWidget:
981
+ text = self.exercise.files.answer_text.read_text(encoding="utf-8", errors="replace")
982
+ holder = QFrame()
983
+ holder.setObjectName("taskNote")
984
+ column = QVBoxLayout(holder)
985
+ column.setContentsMargins(12, 10, 12, 10)
986
+ body = MarkdownLabel(text, self.exercise.folder)
987
+ body.setHidden(True)
988
+ show = button("Show the worked answer", kind="ghost")
989
+ show.clicked.connect(lambda: (body.setHidden(False), show.setHidden(True)))
990
+ column.addWidget(label("WORKED ANSWER", "taskCaption"))
991
+ column.addWidget(show, 0, Qt.AlignLeft)
992
+ column.addWidget(body)
993
+ self.worked_answer = body
994
+ return holder
995
+
996
+ # -- answers and checking ---------------------------------------------------------------------
997
+ def _restore(self, card: TaskCard) -> None:
998
+ saved = self.progress.get(card.task.id, {})
999
+ if card.task.type not in ("net", "workflow") and "answer" in saved:
1000
+ card.editor.set_value(saved["answer"])
1001
+ status = saved.get("status")
1002
+ if status:
1003
+ card.set_status(status)
1004
+ card.share = float(saved.get("share", 0) or 0)
1005
+
1006
+ def _answer_changed(self, task) -> None:
1007
+ card = self.cards[task.id]
1008
+ entry = dict(self.progress.get(task.id, {}))
1009
+ if task.type not in ("net", "workflow"):
1010
+ entry["answer"] = card.editor.value()
1011
+ if card.status:
1012
+ entry["status"] = card.status
1013
+ else:
1014
+ entry.pop("status", None)
1015
+ if card.status == PARTIAL and card.share:
1016
+ entry["share"] = round(card.share, 4)
1017
+ else:
1018
+ entry.pop("share", None)
1019
+ self.progress[task.id] = entry
1020
+ self._save_timer.start()
1021
+ self._update_summary()
1022
+
1023
+ def save_progress(self) -> None:
1024
+ self._save_timer.stop()
1025
+ try:
1026
+ self.exercise.save_progress(self.progress)
1027
+ except OSError as error:
1028
+ self.status.emit(f"Could not save your answers: {error}")
1029
+ return
1030
+ self.progress_changed.emit()
1031
+
1032
+ def _update_summary(self) -> None:
1033
+ if not hasattr(self, "summary_label"):
1034
+ return
1035
+ tasks = self.exercise.sheet.tasks
1036
+ done = sum(1 for t in tasks if (self.cards.get(t.id) and self.cards[t.id].status
1037
+ in (CORRECT, "done")))
1038
+ text = f"{done} of {len(tasks)} answers done" if tasks else ""
1039
+ if tasks and (self.exam or any(t.points != 1 for t in tasks)):
1040
+ summary = self.exercise.summary(self.progress)
1041
+ text += f" · {summary.earned:g} of {summary.points:g} points"
1042
+ self.summary_label.setText(text)
1043
+
1044
+ def check(self, task) -> None:
1045
+ card = self.cards[task.id]
1046
+ if self.locked:
1047
+ return
1048
+ if task.type == "workflow" and self.workflow_page is not None:
1049
+ self.flush() # the checker reads the saved file
1050
+ if task.type == "net":
1051
+ page = self.net_pages.get(task.id)
1052
+ if page is None:
1053
+ return
1054
+ answer = page.petri_net()
1055
+ else:
1056
+ answer = card.editor.value()
1057
+ card.show_checking()
1058
+ exercise, context = self.exercise, self.context
1059
+
1060
+ def compute():
1061
+ try:
1062
+ return check_answer(exercise, task, answer, context)
1063
+ except TaskError as error:
1064
+ return Result("unknown", f"This answer box cannot be checked: {error}.")
1065
+
1066
+ def done(result) -> None:
1067
+ if task.id not in self.cards:
1068
+ return
1069
+ card.show_result(result, self._comparison_detail(task, result))
1070
+ self._answer_changed(task)
1071
+ self.save_progress()
1072
+ self._update_summary()
1073
+
1074
+ def failed(message: str) -> None:
1075
+ if task.id in self.cards:
1076
+ card.show_result(Result("unknown", f"The check failed: {message}"))
1077
+
1078
+ if task.type in ("choice", "text", "open"):
1079
+ done(compute())
1080
+ else:
1081
+ run_in_background(compute, done, failed)
1082
+
1083
+ def _comparison_detail(self, task, result) -> QWidget | None:
1084
+ comparison = getattr(result, "comparison", None)
1085
+ if comparison is None or comparison.equivalent:
1086
+ return None
1087
+ from ..studio.net_comparison import NetComparisonView
1088
+ page = self.net_pages.get(task.id)
1089
+ return NetComparisonView(comparison, "yours", "the model answer",
1090
+ replay=(lambda trace, _f, p=page: self._replay(p, trace))
1091
+ if page is not None else None)
1092
+
1093
+ def _show_solution(self, task) -> None:
1094
+ card = self.cards[task.id]
1095
+ card.show_solution(model_answer_text(self.exercise, task, self.context))
1096
+
1097
+ # -- the token game -----------------------------------------------------------------------------
1098
+ def play_trace(self, text: str) -> None:
1099
+ from ...learn import answers
1100
+ if self.given_net_page is None:
1101
+ return
1102
+ try:
1103
+ steps = answers.trace(text)
1104
+ except answers.AnswerSyntaxError as error:
1105
+ self.status.emit(f"Could not read the steps: {error}")
1106
+ return
1107
+ self.show_material(self.given_net_page)
1108
+ self._replay(self.given_net_page, steps)
1109
+
1110
+ def _replay(self, page, trace) -> None:
1111
+ from ...mining.analysis import check_workflow_net
1112
+ from ...mining.compare_nets import replayable_prefix
1113
+ if page is None:
1114
+ return
1115
+ petri = page.petri_net()
1116
+ source = None
1117
+ if not petri.initial_marking:
1118
+ workflow = check_workflow_net(petri)
1119
+ source = workflow.source if workflow.is_workflow_net else None
1120
+ path, done = replayable_prefix(petri, trace)
1121
+ self.show_material(page)
1122
+ page.replay(path, source)
1123
+ shown = "⟨" + ", ".join(trace) + "⟩"
1124
+ if done < len(trace):
1125
+ self.status.emit(f"{shown}: the net can do the first {done} step(s), then not "
1126
+ f"“{trace[done]}”")
1127
+ else:
1128
+ self.status.emit(f"{shown}: replayed")
1129
+
1130
+ # -- leaving ------------------------------------------------------------------------------------
1131
+ def flush(self) -> None:
1132
+ """Save everything now (leaving the exercise or quitting)."""
1133
+ if self._save_timer.isActive():
1134
+ self.save_progress()
1135
+ if self._notes_timer.isActive():
1136
+ self.save_notes()
1137
+ for task_id, timer in self._net_timers.items():
1138
+ if timer.isActive():
1139
+ timer.stop()
1140
+ timer.timeout.emit()
1141
+ if self._workflow_timer is not None and self._workflow_timer.isActive():
1142
+ self._workflow_timer.stop()
1143
+ self._save_workflow()
1144
+
1145
+ def discard(self) -> None:
1146
+ """Starting again: nothing pending may be written afterwards."""
1147
+ self._save_timer.stop()
1148
+ self._notes_timer.stop()
1149
+ for timer in self._net_timers.values():
1150
+ timer.stop()
1151
+ if self._workflow_timer is not None:
1152
+ self._workflow_timer.stop()
1153
+
1154
+ def shutdown(self) -> None:
1155
+ for _, page in self.materials:
1156
+ if hasattr(page, "stop"):
1157
+ page.stop()
1158
+ if hasattr(page, "shutdown"):
1159
+ page.shutdown()
1160
+
1161
+
1162
+ # ---------------------------------------------------------------------------
1163
+ # Helpers
1164
+ # ---------------------------------------------------------------------------
1165
+ def _reveal_label() -> str:
1166
+ import sys
1167
+ return {"darwin": "Show in Finder", "win32": "Show in Explorer"}.get(
1168
+ sys.platform, "Open Folder")
1169
+
1170
+
1171
+ def _reveal(path: Path) -> None:
1172
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
1173
+
1174
+
1175
+ def open_help(name: str, parent=None) -> None:
1176
+ """One of the guides in ``docs/`` (exercise-packs, references) in a window."""
1177
+ from ..studio.definition_view import show_guide
1178
+ show_guide(name, parent)
1179
+
1180
+
1181
+ __all__ = ["LearnMode", "ExerciseView"]