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