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