openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,503 @@
1
+ """Design tokens and the stylesheet for the Studio window.
2
+
3
+ Every colour is named by *role* and has a light and a dark value, resolved
4
+ through :func:`tokens` at the moment it is needed, so the whole window
5
+ follows the system appearance.
6
+
7
+ Chart colours follow a validated data-visualisation palette:
8
+
9
+ * **categorical** -- eight hues in a *fixed order*, assigned to activities by
10
+ frequency rank (most frequent activity = slot 1). The order was checked for
11
+ colour-vision-deficiency separation between neighbours; a ninth activity
12
+ never gets a generated hue but folds into a neutral grey "Other". Colour
13
+ always follows the activity, so filtering never repaints the survivors.
14
+ * **sequential** -- one blue ramp, light to dark, for magnitudes (frequency
15
+ in the process map).
16
+ * **status** -- good / warning / critical, reserved for verdicts (sound,
17
+ fitting) and always shown with an icon and a word, never colour alone.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ from PySide6.QtGui import QColor
25
+
26
+ from .. import theme
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Tokens:
31
+ page: str # window background behind content
32
+ sidebar: str # source list background
33
+ surface: str # cards, canvas paper
34
+ surface_alt: str # table stripes, hovered rows, inputs
35
+ border: str # hairlines
36
+ text: str
37
+ text_secondary: str
38
+ text_muted: str
39
+ accent: str
40
+ accent_soft: str
41
+ accent_text: str # text on accent fills
42
+ grid: str
43
+ axis: str
44
+ canvas: str
45
+ node_fill: str
46
+ node_stroke: str
47
+ silent_fill: str
48
+ selection: str
49
+
50
+
51
+ LIGHT = Tokens(
52
+ page="#f5f5f7", sidebar="#ebebee", surface="#ffffff", surface_alt="#f6f6f8",
53
+ border="#e1e1e6", text="#1d1d1f", text_secondary="#52514e", text_muted="#86868b",
54
+ accent="#0a84ff", accent_soft="#dcebff", accent_text="#ffffff",
55
+ grid="#e1e0d9", axis="#c3c2b7", canvas="#fcfcfb", node_fill="#ffffff",
56
+ node_stroke="#3a3a3c", silent_fill="#3a3a3c", selection="#0a84ff",
57
+ )
58
+
59
+ DARK = Tokens(
60
+ page="#1c1c1e", sidebar="#242427", surface="#2a2a2d", surface_alt="#323236",
61
+ border="#3a3a3e", text="#f5f5f7", text_secondary="#c3c2b7", text_muted="#98989d",
62
+ accent="#0a84ff", accent_soft="#153a63", accent_text="#ffffff",
63
+ grid="#2c2c2a", axis="#383835", canvas="#1a1a19", node_fill="#2a2a2d",
64
+ node_stroke="#d1d1d6", silent_fill="#d1d1d6", selection="#409cff",
65
+ )
66
+
67
+
68
+ def tokens() -> Tokens:
69
+ return DARK if theme.is_dark() else LIGHT
70
+
71
+
72
+ def qc(hex_value: str, alpha: float = 1.0) -> QColor:
73
+ colour = QColor(hex_value)
74
+ colour.setAlphaF(alpha)
75
+ return colour
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Data colours
80
+ # ---------------------------------------------------------------------------
81
+ CATEGORICAL_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100",
82
+ "#e87ba4", "#008300", "#4a3aa7", "#e34948"]
83
+ CATEGORICAL_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500",
84
+ "#d55181", "#008300", "#9085e9", "#e66767"]
85
+ OTHER_LIGHT, OTHER_DARK = "#a8a7a0", "#6b6a66"
86
+
87
+ SEQUENTIAL_BLUE = ["#cde2fb", "#b7d3f6", "#9ec5f4", "#86b6ef", "#6da7ec", "#5598e7",
88
+ "#3987e5", "#2a78d6", "#256abf", "#1c5cab", "#184f95", "#104281", "#0d366b"]
89
+
90
+ STATUS = {"good": "#0ca30c", "warning": "#fab219", "serious": "#ec835a", "critical": "#d03b3b"}
91
+ STATUS_ICON = {"good": "✓", "warning": "!", "serious": "!", "critical": "✕", "unknown": "?",
92
+ "info": "i"}
93
+
94
+
95
+ def categorical(slot: int) -> str:
96
+ palette = CATEGORICAL_DARK if theme.is_dark() else CATEGORICAL_LIGHT
97
+ if 0 <= slot < len(palette):
98
+ return palette[slot]
99
+ return OTHER_DARK if theme.is_dark() else OTHER_LIGHT
100
+
101
+
102
+ def sequential(fraction: float) -> str:
103
+ """A step of the blue ramp for a magnitude in [0, 1]."""
104
+ fraction = min(max(fraction, 0.0), 1.0)
105
+ # Do not use the palest steps for real data: they vanish into the surface.
106
+ low, high = 1, len(SEQUENTIAL_BLUE) - 2
107
+ return SEQUENTIAL_BLUE[round(low + fraction * (high - low))]
108
+
109
+
110
+ def text_on(fill: str) -> str:
111
+ """Ink colour that stays legible on ``fill``."""
112
+ colour = QColor(fill)
113
+ luminance = 0.2126 * colour.redF() + 0.7152 * colour.greenF() + 0.0722 * colour.blueF()
114
+ return "#0b0b0b" if luminance > 0.55 else "#ffffff"
115
+
116
+
117
+ class ActivityColours:
118
+ """Stable activity -> colour assignment for one log (by frequency rank)."""
119
+
120
+ def __init__(self, activities_by_frequency: list[str]) -> None:
121
+ self.rank = {name: i for i, name in enumerate(activities_by_frequency)}
122
+
123
+ def slot(self, activity: str) -> int:
124
+ return self.rank.get(activity, 99)
125
+
126
+ def colour(self, activity: str) -> str:
127
+ return categorical(self.slot(activity))
128
+
129
+ def is_other(self, activity: str) -> bool:
130
+ return self.slot(activity) >= len(CATEGORICAL_LIGHT)
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Stylesheet
135
+ # ---------------------------------------------------------------------------
136
+ RADIUS = 10
137
+
138
+
139
+ def _arrow_images(colour: str) -> dict[str, str]:
140
+ """Small up/down chevrons (spin boxes, combo boxes) and the tick and dash of a
141
+ check box, as PNG files.
142
+
143
+ A stylesheet that styles a spin box's buttons must also supply the
144
+ arrows (Qt then stops drawing its own), and stylesheets can only take
145
+ images from files -- so they are painted once per colour into a cache
146
+ folder. Returns ``{"up": path, "down": path}`` with forward slashes.
147
+ """
148
+ import tempfile
149
+ from pathlib import Path
150
+ from PySide6.QtCore import QPointF, Qt
151
+ from PySide6.QtGui import QImage, QPainter, QPen
152
+ folder = Path(tempfile.gettempdir()) / "openprocess-ui"
153
+ folder.mkdir(exist_ok=True)
154
+ paths = {}
155
+ for name, points in (("up", ((3, 10), (8, 5), (13, 10))),
156
+ ("down", ((3, 6), (8, 11), (13, 6))),
157
+ ("check", ((4, 8.5), (7, 11.5), (12.5, 5))),
158
+ ("dash", ((4.5, 8), (11.5, 8)))):
159
+ path = folder / f"chevron-{name}-{colour.lstrip('#')}.png"
160
+ if not path.exists():
161
+ image = QImage(32, 32, QImage.Format_ARGB32)
162
+ image.fill(Qt.transparent)
163
+ painter = QPainter(image)
164
+ painter.setRenderHint(QPainter.Antialiasing)
165
+ painter.scale(2, 2)
166
+ pen = QPen(QColor(colour), 1.8, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
167
+ painter.setPen(pen)
168
+ painter.drawPolyline([QPointF(x, y) for x, y in points])
169
+ painter.end()
170
+ image.save(str(path))
171
+ paths[name] = path.as_posix()
172
+ return paths
173
+
174
+
175
+ def stylesheet() -> str:
176
+ t = tokens()
177
+ # Outlines of check boxes, radio buttons and slider handles: the text
178
+ # colour, faded -- visible on light and dark (the axis grey all but
179
+ # vanished on the dark surface).
180
+ outline = qc(t.text, 0.38).name(QColor.HexArgb)
181
+ track = qc(t.text, 0.16).name(QColor.HexArgb)
182
+ arrows = _arrow_images(t.text_secondary)
183
+ ticks = _arrow_images(t.accent_text) # white, on the accent fill
184
+ return f"""
185
+ QWidget {{ color: {t.text}; }}
186
+ QMainWindow, #studioRoot {{ background: {t.page}; }}
187
+ QDialog, QMessageBox {{ background: {t.page}; }}
188
+ QToolTip {{ background: {t.surface}; color: {t.text}; border: 1px solid {t.border};
189
+ padding: 6px 8px; border-radius: 8px; }}
190
+
191
+ /* ---- sidebar -------------------------------------------------------- */
192
+ #sidebar {{ background: {t.sidebar}; border-right: 1px solid {t.border}; }}
193
+ #sidebar QTreeWidget {{ background: transparent; border: none; outline: none; }}
194
+ #sidebar QTreeWidget::item {{ padding: 5px 28px 5px 6px; border-radius: 7px; margin: 1px 6px; }}
195
+ #sidebar QTreeWidget::item:hover {{ background: {qc(t.text, 0.06).name(QColor.HexArgb)}; }}
196
+ #sidebar QTreeWidget::item:selected {{ background: {t.accent}; color: {t.accent_text}; }}
197
+ #sidebarTitle {{ color: {t.text}; font-weight: 700; font-size: 15px; padding: 0 2px; }}
198
+ #sidebarCaption {{ color: {t.text_muted}; font-size: 10px; font-weight: 700;
199
+ letter-spacing: 0.8px; padding: 0 2px; }}
200
+ #sidebarMenuButton {{ background: transparent; border: none; border-radius: 6px;
201
+ color: {t.text_secondary}; font-size: 16px; padding: 0 6px 2px 6px; }}
202
+ #sidebarMenuButton:hover {{ background: {qc(t.text, 0.08).name(QColor.HexArgb)}; }}
203
+ #sidebarMenuButton::menu-indicator {{ image: none; width: 0; }}
204
+ #sidebarToggle {{ background: transparent; border: none; border-radius: 6px; padding: 4px; }}
205
+ #rail {{ background: {t.sidebar}; border-right: 1px solid {t.border}; }}
206
+ #sidebarToggle:hover {{ background: {qc(t.text, 0.08).name(QColor.HexArgb)}; }}
207
+ #sidebarFooter QPushButton {{ background: transparent; border: none; color: {t.text_secondary};
208
+ padding: 6px 8px; border-radius: 6px; text-align: left; }}
209
+ #sidebarFooter QPushButton:hover {{ background: {qc(t.text, 0.06).name(QColor.HexArgb)}; }}
210
+
211
+ /* ---- page chrome -------------------------------------------------- */
212
+ #pageHeader {{ background: {t.page}; }}
213
+ #pageTitle {{ font-size: 22px; font-weight: 700; }}
214
+ #pageSubtitle {{ color: {t.text_muted}; }}
215
+ #noticeBar {{ background: {qc(STATUS["warning"], 0.16).name(QColor.HexArgb)};
216
+ border-bottom: 1px solid {qc(STATUS["warning"], 0.45).name(QColor.HexArgb)}; }}
217
+ #noticeBar QLabel {{ color: {t.text}; }}
218
+ #updateBar {{ background: {t.accent_soft}; border-bottom: 1px solid {t.border}; }}
219
+ #updateBar QLabel {{ color: {t.text}; }}
220
+ #updateBarClose {{ background: transparent; border: none; border-radius: 6px;
221
+ color: {t.text_secondary}; padding: 3px 7px; }}
222
+ #updateBarClose:hover {{ background: {qc(t.text, 0.08).name(QColor.HexArgb)}; }}
223
+ #card {{ background: {t.surface}; border: 1px solid {t.border}; border-radius: {RADIUS}px; }}
224
+ #cardTitle {{ font-weight: 600; font-size: 13px; }}
225
+ /* A section inside a card (no second frame around it). */
226
+ #flatCard {{ background: transparent; border: none; }}
227
+ #cardCaption, #muted {{ color: {t.text_muted}; }}
228
+ #statLabel {{ color: {t.text_muted}; font-size: 12px; }}
229
+ #statValue {{ font-size: 22px; font-weight: 600; }}
230
+ #statSub {{ color: {t.text_muted}; font-size: 11px; }}
231
+ #sectionLabel {{ color: {t.text_muted}; font-size: 11px; font-weight: 700;
232
+ letter-spacing: 0.6px; }}
233
+
234
+ /* The canvas's "out of view — show it" hint (see panning.py). */
235
+ QPushButton#offscreenHint {{ background: {t.accent}; border: none; color: {t.accent_text};
236
+ border-radius: 13px; padding: 5px 14px; font-weight: 600; }}
237
+ QPushButton#offscreenHint:hover {{ background: {QColor(t.accent).lighter(110).name()}; }}
238
+
239
+ /* ---- exercise mode -------------------------------------------------------- */
240
+ #exerciseBar {{ background: {t.page}; border-bottom: 1px solid {t.border}; }}
241
+ #exerciseWhere {{ color: {t.text_secondary}; font-weight: 600; }}
242
+ #exercisePosition {{ color: {t.text_muted}; font-size: 12px; min-width: 34px;
243
+ qproperty-alignment: AlignCenter; }}
244
+ #exerciseBarButton {{ background: transparent; border: none; border-radius: 6px;
245
+ color: {t.text_secondary}; padding: 4px 9px; font-size: 13px; }}
246
+ #exerciseBarButton:hover {{ background: {qc(t.text, 0.08).name(QColor.HexArgb)};
247
+ color: {t.text}; }}
248
+ #exerciseBarButton:disabled {{ color: {qc(t.text, 0.22).name(QColor.HexArgb)}; }}
249
+ #exerciseBarButton::menu-indicator {{ image: none; width: 0; }}
250
+ QPushButton#exerciseExit {{ padding: 4px 14px; }}
251
+ #progressDot {{ border-radius: 6px; border: 1.5px solid {qc(t.text, 0.28).name(QColor.HexArgb)};
252
+ background: transparent; padding: 0; }}
253
+ #progressDot[state="started"] {{ border-color: {STATUS["warning"]};
254
+ background: {qc(STATUS["warning"], 0.35).name(QColor.HexArgb)}; }}
255
+ #progressDot[state="done"] {{ border-color: {STATUS["good"]}; background: {STATUS["good"]}; }}
256
+ #progressDot[current="true"] {{ border: 2px solid {t.accent}; }}
257
+ #exerciseHome, #worksheet {{ background: {t.page}; }}
258
+ #materials {{ background: {t.page}; }}
259
+ #sheetChapter {{ color: {t.accent}; font-size: 11px; font-weight: 700;
260
+ letter-spacing: 0.8px; }}
261
+ #sheetTitle {{ font-size: 24px; font-weight: 700; }}
262
+ #sheetProblem {{ color: {t.text}; background: {qc(STATUS["warning"], 0.16).name(QColor.HexArgb)};
263
+ border-radius: 8px; padding: 8px 10px; }}
264
+ QLabel#markdown {{ font-size: 14px; line-height: 150%; }}
265
+ #notesPanel {{ background: {t.page}; border-top: 1px solid {t.border}; }}
266
+ QPlainTextEdit#notesEdit {{ font-size: 14px; padding: 8px 10px; }}
267
+ #notesOverlay {{ background: {t.surface}; border: 1px solid {t.border}; border-radius: 12px; }}
268
+ #notesOverlayButton {{ background: transparent; border: none; border-radius: 6px;
269
+ padding: 2px 7px; color: {t.text_secondary}; font-size: 14px; }}
270
+ #notesOverlayButton:hover {{ background: {qc(t.text, 0.08).name(QColor.HexArgb)}; color: {t.text}; }}
271
+ QToolButton#notesStatusButton {{ background: {t.surface}; border: 1px solid {t.border};
272
+ border-radius: 7px; padding: 2px 10px; margin: 2px 6px; color: {t.text_secondary}; }}
273
+ QToolButton#notesStatusButton:hover {{ color: {t.text}; }}
274
+ QToolButton#notesStatusButton:checked {{ background: {t.accent_soft}; color: {t.text};
275
+ border-color: {qc(t.accent, 0.45).name(QColor.HexArgb)}; }}
276
+ #exerciseBarButton:checked {{ background: {t.accent_soft}; color: {t.text}; }}
277
+ #taskCard {{ background: {t.surface}; border: 1px solid {t.border}; border-radius: 12px; }}
278
+ #taskCard[state="good"] {{ border-color: {qc(STATUS["good"], 0.55).name(QColor.HexArgb)}; }}
279
+ #taskCard[state="critical"] {{ border-color: {qc(STATUS["critical"], 0.45).name(QColor.HexArgb)}; }}
280
+ #taskCard[state="warning"] {{ border-color: {qc(STATUS["warning"], 0.7).name(QColor.HexArgb)}; }}
281
+ #taskCaption {{ color: {t.text_muted}; font-size: 10px; font-weight: 700;
282
+ letter-spacing: 0.8px; }}
283
+ #statusChip {{ font-size: 11px; font-weight: 600; border-radius: 9px; padding: 2px 8px; }}
284
+ #statusChip[state="good"] {{ color: {STATUS["good"]};
285
+ background: {qc(STATUS["good"], 0.13).name(QColor.HexArgb)}; }}
286
+ #statusChip[state="warning"] {{ color: {t.text};
287
+ background: {qc(STATUS["warning"], 0.25).name(QColor.HexArgb)}; }}
288
+ #statusChip[state="critical"] {{ color: {STATUS["critical"]};
289
+ background: {qc(STATUS["critical"], 0.12).name(QColor.HexArgb)}; }}
290
+ #feedback {{ border-radius: 8px; padding: 7px 10px;
291
+ background: {qc(t.text, 0.05).name(QColor.HexArgb)}; }}
292
+ #feedback[state="good"] {{ background: {qc(STATUS["good"], 0.12).name(QColor.HexArgb)}; }}
293
+ #feedback[state="warning"] {{ background: {qc(STATUS["warning"], 0.2).name(QColor.HexArgb)}; }}
294
+ #feedback[state="critical"] {{ background: {qc(STATUS["critical"], 0.1).name(QColor.HexArgb)}; }}
295
+ #taskNote {{ background: {qc(t.accent, 0.07).name(QColor.HexArgb)};
296
+ border-left: 3px solid {qc(t.accent, 0.55).name(QColor.HexArgb)}; border-radius: 6px; }}
297
+ #taskNote QLabel {{ background: transparent; }}
298
+ #answerReading {{ color: {t.text_muted}; font-size: 12px; }}
299
+ #answerReading[ok="false"] {{ color: {STATUS["critical"]}; }}
300
+ QLineEdit#answerLine {{ padding: 6px 9px; font-size: 14px; }}
301
+ #choiceOption {{ border: 1px solid {t.border}; border-radius: 8px; background: {t.surface}; }}
302
+ #choiceOption:hover {{ border-color: {t.accent}; }}
303
+ #choiceOption QLabel {{ background: transparent; }}
304
+ QPushButton#choicePill {{ min-width: 56px; padding: 5px 16px; border-radius: 12px; }}
305
+ QPushButton#choicePill:checked {{ background: {t.accent}; border-color: {t.accent};
306
+ color: {t.accent_text}; font-weight: 600; }}
307
+ QToolButton#footprintCell {{ background: {t.surface_alt}; border: 1px solid {t.border};
308
+ border-radius: 6px; font-size: 15px; }}
309
+ QToolButton#footprintCell:hover {{ border-color: {t.accent}; }}
310
+ QToolButton#footprintCell:focus {{ border: 1.5px solid {t.accent}; }}
311
+ QToolButton#footprintCell[wrong="true"] {{ border: 2px solid {STATUS["critical"]};
312
+ background: {qc(STATUS["critical"], 0.08).name(QColor.HexArgb)}; }}
313
+ #footprintHeading {{ color: {t.text_secondary}; font-weight: 600; padding: 0 4px; }}
314
+ QLineEdit#gridCell {{ background: {t.surface_alt}; border: 1px solid {t.border};
315
+ border-radius: 6px; font-size: 14px; padding: 2px 4px; }}
316
+ QLineEdit#gridCell:focus {{ border: 1.5px solid {t.accent}; }}
317
+ QLineEdit#gridCell[wrong="true"] {{ border: 2px solid {STATUS["critical"]};
318
+ background: {qc(STATUS["critical"], 0.08).name(QColor.HexArgb)}; }}
319
+ #tupleName {{ color: {t.text_secondary}; font-weight: 600; font-size: 14px; min-width: 34px; }}
320
+ #tupleMark {{ font-weight: 700; font-size: 14px; }}
321
+ #tupleMark[state="good"] {{ color: {STATUS["good"]}; }}
322
+ #tupleMark[state="warning"] {{ color: {STATUS["warning"]}; }}
323
+ #tupleMark[state="critical"] {{ color: {STATUS["critical"]}; }}
324
+ QListWidget#rankingList {{ background: {t.surface_alt}; border: 1px solid {t.border};
325
+ border-radius: 8px; font-size: 14px; padding: 4px; }}
326
+ QListWidget#rankingList::item {{ padding: 5px 8px; border-radius: 6px; }}
327
+ QListWidget#rankingList::item:selected {{ background: {t.accent_soft}; color: {t.text}; }}
328
+ #examClock {{ color: {t.text_secondary}; font-weight: 600; font-variant-numeric: tabular-nums;
329
+ padding: 2px 10px; border-radius: 9px; background: {qc(t.text, 0.06).name(QColor.HexArgb)}; }}
330
+ #examClock[state="soon"] {{ color: {t.text};
331
+ background: {qc(STATUS["warning"], 0.3).name(QColor.HexArgb)}; }}
332
+ #examClock[state="over"] {{ color: {STATUS["critical"]};
333
+ background: {qc(STATUS["critical"], 0.12).name(QColor.HexArgb)}; }}
334
+ #exerciseRow {{ background: {t.surface}; border: 1px solid {t.border}; border-radius: 10px; }}
335
+ #exerciseRow:hover {{ border-color: {t.accent}; }}
336
+ #exerciseRow QLabel {{ background: transparent; }}
337
+ #rowTitle {{ font-weight: 600; font-size: 14px; }}
338
+ #rowChevron {{ color: {t.text_muted}; font-size: 18px; }}
339
+ #rowMark {{ color: {t.text_muted}; font-size: 15px; }}
340
+ #rowMark[state="done"] {{ color: {STATUS["good"]}; font-weight: 700; }}
341
+ #rowMark[state="started"] {{ color: {STATUS["warning"]}; }}
342
+ QTextBrowser#guideBrowser {{ border: none; border-radius: 0; padding: 18px 26px; }}
343
+ QTextBrowser#plainBrowser {{ background: transparent; border: none; padding: 0; }}
344
+ /* A result an exercise hides: a soft inset with its Reveal button. */
345
+ #revealRow {{ background: {qc(t.text, 0.045).name(QColor.HexArgb)};
346
+ border: 1px dashed {qc(t.text, 0.18).name(QColor.HexArgb)};
347
+ border-radius: 9px; }}
348
+ #revealRow QLabel {{ background: transparent; }}
349
+ #revealTitle {{ font-weight: 600; }}
350
+
351
+ /* ---- segmented control ---------------------------------------------- */
352
+ #segmented {{ background: {qc(t.text, 0.07).name(QColor.HexArgb)}; border-radius: 8px; }}
353
+ #segmented QPushButton {{ background: transparent; border: none; border-radius: 6px;
354
+ padding: 5px 11px; color: {t.text_secondary}; font-weight: 500; }}
355
+ #segmented QPushButton:hover {{ color: {t.text}; }}
356
+ #segmented QPushButton:checked {{ background: {t.surface}; color: {t.text};
357
+ border: 1px solid {t.border}; }}
358
+ #segmented[compact="true"] QPushButton {{ padding: 4px 8px; font-size: 12px; }}
359
+
360
+ /* ---- buttons ------------------------------------------------------ */
361
+ QPushButton {{ background: {t.surface}; border: 1px solid {t.border}; border-radius: 7px;
362
+ padding: 6px 14px; }}
363
+ QPushButton:hover {{ background: {t.surface_alt}; }}
364
+ QPushButton:disabled {{ color: {t.text_muted}; }}
365
+ QPushButton#primary {{ background: {t.accent}; border-color: {t.accent};
366
+ color: {t.accent_text}; font-weight: 600; }}
367
+ QPushButton#primary:hover {{ background: {QColor(t.accent).lighter(110).name()}; }}
368
+ QPushButton#primary:disabled {{ background: {t.border}; border-color: {t.border};
369
+ color: {t.text_muted}; }}
370
+ QPushButton#ghost {{ background: transparent; border: none; color: {t.accent};
371
+ padding: 4px 6px; }}
372
+ QPushButton#ghost:hover {{ text-decoration: underline; }}
373
+ QToolButton#canvasTool {{ background: {t.surface}; border: 1px solid {t.border};
374
+ border-radius: 7px; padding: 4px 9px; }}
375
+ QToolButton#canvasTool:hover {{ background: {t.surface_alt}; }}
376
+ QToolButton#canvasTool::menu-indicator {{ image: url("{arrows['down']}");
377
+ subcontrol-position: right center; width: 9px; height: 9px; right: 6px; }}
378
+ QToolButton#canvasTool[popupMode="2"] {{ padding-right: 20px; }}
379
+ QToolButton#canvasTool:checked {{ background: {t.accent}; color: {t.accent_text};
380
+ border-color: {t.accent}; }}
381
+
382
+ /* ---- inputs ------------------------------------------------------- */
383
+ QTextBrowser, QTextEdit {{ background: {t.surface}; border: 1px solid {t.border};
384
+ border-radius: 10px; padding: 6px; }}
385
+ QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QPlainTextEdit {{
386
+ background: {t.surface}; border: 1px solid {t.border}; border-radius: 7px;
387
+ padding: 4px 8px; selection-background-color: {t.accent}; }}
388
+ QComboBox:focus, QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus,
389
+ QDoubleSpinBox:focus {{ border-color: {t.accent}; }}
390
+ QSpinBox, QDoubleSpinBox {{ padding-right: 20px; }}
391
+ QSpinBox::up-button, QSpinBox::down-button,
392
+ QDoubleSpinBox::up-button, QDoubleSpinBox::down-button {{ subcontrol-origin: border;
393
+ width: 18px; border: none; border-radius: 4px; margin: 2px 3px;
394
+ background: transparent; }}
395
+ QSpinBox::up-button:hover, QSpinBox::down-button:hover,
396
+ QDoubleSpinBox::up-button:hover, QDoubleSpinBox::down-button:hover {{
397
+ background: {qc(t.text, 0.08).name(QColor.HexArgb)}; }}
398
+ QSpinBox::up-button, QDoubleSpinBox::up-button {{ subcontrol-position: top right;
399
+ margin-bottom: 0; }}
400
+ QSpinBox::down-button, QDoubleSpinBox::down-button {{ subcontrol-position: bottom right;
401
+ margin-top: 0; }}
402
+ QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {{ image: url("{arrows['up']}");
403
+ width: 10px; height: 10px; }}
404
+ QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {{ image: url("{arrows['down']}");
405
+ width: 10px; height: 10px; }}
406
+ QSpinBox::up-arrow:disabled, QSpinBox::down-arrow:disabled,
407
+ QDoubleSpinBox::up-arrow:disabled, QDoubleSpinBox::down-arrow:disabled {{ image: none; }}
408
+ /* combobox-popup: 0 -- a list that drops down below the box (as on the
409
+ web), which follows the stylesheet; the menu-like one paints a square
410
+ panel of its own around the rounded list. */
411
+ QComboBox {{ padding-right: 22px; combobox-popup: 0; }}
412
+ QComboBox::drop-down {{ border: none; width: 20px; }}
413
+ QComboBox::down-arrow {{ image: url("{arrows['down']}"); width: 10px; height: 10px; }}
414
+ /* The list that drops down: rounded like the menus (widgets.round_menus
415
+ makes its window see-through, so the corners show). */
416
+ QComboBoxPrivateContainer {{ background: transparent; border: none; }}
417
+ QComboBox QAbstractItemView {{ background: {t.surface}; border: 1px solid {t.border};
418
+ border-radius: 10px; padding: 5px; outline: none;
419
+ selection-background-color: {t.accent}; selection-color: {t.accent_text}; }}
420
+ QComboBox QAbstractItemView::item {{ padding: 6px 12px; border-radius: 6px;
421
+ min-height: 18px; }}
422
+ QComboBox QAbstractItemView::item:selected, QComboBox QAbstractItemView::item:hover {{
423
+ background: {t.accent}; color: {t.accent_text}; }}
424
+ QSlider::groove:horizontal {{ height: 4px; background: {track}; border-radius: 2px; }}
425
+ QSlider::sub-page:horizontal {{ background: {t.accent}; border-radius: 2px; }}
426
+ QSlider::handle:horizontal {{ background: {t.surface}; border: 1px solid {outline};
427
+ width: 16px; height: 16px; margin: -7px 0; border-radius: 8px; }}
428
+ QRadioButton, QCheckBox {{ spacing: 8px; }}
429
+ /* Check boxes (also in lists, e.g. a chart's legend) and radio buttons:
430
+ rounded, filled with the accent colour when on. */
431
+ QCheckBox::indicator, QAbstractItemView::indicator {{ width: 14px; height: 14px;
432
+ border: 1px solid {outline}; border-radius: 4px; background: {t.surface}; }}
433
+ QCheckBox::indicator:hover, QAbstractItemView::indicator:hover {{
434
+ border-color: {t.accent}; }}
435
+ QCheckBox::indicator:checked, QAbstractItemView::indicator:checked {{
436
+ background: {t.accent}; border-color: {t.accent}; image: url("{ticks['check']}"); }}
437
+ QCheckBox::indicator:indeterminate, QAbstractItemView::indicator:indeterminate {{
438
+ background: {t.accent}; border-color: {t.accent}; image: url("{ticks['dash']}"); }}
439
+ QCheckBox::indicator:disabled {{ background: {t.surface_alt}; border-color: {t.border}; }}
440
+ QRadioButton::indicator {{ width: 14px; height: 14px; border: 1px solid {outline};
441
+ border-radius: 8px; background: {t.surface}; }}
442
+ QRadioButton::indicator:hover {{ border-color: {t.accent}; }}
443
+ QRadioButton::indicator:checked {{ width: 6px; height: 6px; border: 5px solid {t.accent};
444
+ background: {t.surface}; }}
445
+
446
+ /* Framed sections (the Filter dialog's): rounded like the cards. */
447
+ QGroupBox {{ border: 1px solid {t.border}; border-radius: 10px; margin-top: 14px;
448
+ padding: 10px 8px 8px 8px; background: {t.surface}; }}
449
+ QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left;
450
+ left: 4px; padding: 0 4px; color: {t.text}; font-weight: 600; }}
451
+ QGroupBox::indicator {{ width: 14px; height: 14px; border: 1px solid {outline};
452
+ border-radius: 4px; background: {t.surface}; }}
453
+ QGroupBox::indicator:checked {{ background: {t.accent}; border-color: {t.accent};
454
+ image: url("{ticks['check']}"); }}
455
+
456
+ /* ---- tables and lists --------------------------------------------- */
457
+ QTableView, QTreeView, QListView {{ background: {t.surface}; border: none;
458
+ alternate-background-color: {t.surface_alt}; gridline-color: {t.border};
459
+ selection-background-color: {t.accent_soft}; selection-color: {t.text}; outline: none; }}
460
+ QHeaderView::section {{ background: {t.surface}; color: {t.text_muted}; border: none;
461
+ border-bottom: 1px solid {t.border}; padding: 6px 8px; font-weight: 600;
462
+ font-size: 11px; }}
463
+ QTableCornerButton::section {{ background: {t.surface}; border: none; }}
464
+ /* The Workflows page's box list: rows like the sidebar's, group names as captions. */
465
+ #boxList {{ background: transparent; }}
466
+ #boxList::item {{ padding: 4px 6px; border-radius: 6px; margin: 0 2px; }}
467
+ #boxList::item:hover {{ background: {qc(t.text, 0.06).name(QColor.HexArgb)}; }}
468
+ #boxList::item:selected {{ background: {t.accent_soft}; color: {t.text}; }}
469
+ #boxList::item:has-children {{ padding-top: 10px; background: transparent; }}
470
+ /* A list on its own in a dialog (Compare logs, the Filter dialog's
471
+ activities) gets a rounded frame, like a text field. */
472
+ QDialog QListWidget, QDialog QListView {{ border: 1px solid {t.border};
473
+ border-radius: 8px; padding: 4px; }}
474
+
475
+ /* ---- scroll bars: thin overlays ------------------------------------- */
476
+ QScrollArea {{ background: transparent; border: none; }}
477
+ QScrollArea > QWidget > QWidget {{ background: transparent; }}
478
+ QScrollBar:vertical, QScrollBar:horizontal {{ background: transparent;
479
+ width: 10px; height: 10px; margin: 2px; }}
480
+ QScrollBar::handle {{ background: {qc(t.text, 0.22).name(QColor.HexArgb)};
481
+ border-radius: 4px; min-height: 30px; min-width: 30px; }}
482
+ QScrollBar::add-line, QScrollBar::sub-line, QScrollBar::add-page, QScrollBar::sub-page {{
483
+ background: none; border: none; height: 0; width: 0; }}
484
+
485
+ QSplitter::handle {{ background: {t.border}; }}
486
+ /* The Workflows page: gaps between cards, lit under the mouse so they read as handles. */
487
+ WorkflowPage QSplitter::handle {{ background: transparent; }}
488
+ WorkflowPage QSplitter::handle:hover {{ background: {qc(t.accent, 0.35).name(QColor.HexArgb)};
489
+ margin: 6px 5px; border-radius: 2px; }}
490
+ QGraphicsView {{ background: {t.canvas}; border: none; }}
491
+ QStatusBar {{ background: {t.page}; color: {t.text_muted}; border-top: 1px solid {t.border}; }}
492
+ /* Pop-up menus: rounded like the cards (see widgets.round_menus, which
493
+ gives them the see-through window the corners need). */
494
+ QMenu {{ background: {t.surface}; border: 1px solid {t.border}; border-radius: 10px;
495
+ padding: 5px; }}
496
+ QMenu::item {{ padding: 6px 24px 6px 12px; border-radius: 6px; margin: 1px 0; }}
497
+ QMenu::item:selected {{ background: {t.accent}; color: {t.accent_text}; }}
498
+ QMenu::item:disabled {{ color: {t.text_muted}; }}
499
+ QMenu::separator {{ height: 1px; background: {t.border}; margin: 5px 8px; }}
500
+ QProgressBar {{ background: {t.border}; border: none; border-radius: 2px; max-height: 4px; }}
501
+ QProgressBar::chunk {{ background: {t.accent}; border-radius: 2px; }}
502
+ QTabWidget::pane {{ border: none; }}
503
+ """
@@ -0,0 +1,134 @@
1
+ """Icons and mouse cursors for the drawing tools.
2
+
3
+ Each tool gets a picture of what it does -- a pointing hand for *Select*, a
4
+ circle for *Place*, a square for *Transition*, an arrow for *Arc* -- on its
5
+ button and as the mouse cursor over the canvas, so the active tool is
6
+ obvious wherever you look. Painted in code: the app ships no image files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from PySide6.QtCore import QPointF, QRectF, Qt
12
+ from PySide6.QtGui import QColor, QCursor, QIcon, QPainter, QPainterPath, QPen, QPixmap
13
+
14
+ from . import style
15
+
16
+
17
+ def _hand(painter: QPainter, size: float, colour: QColor) -> None:
18
+ """A pointing hand (index finger up), drawn in a ``size`` square."""
19
+ s = size / 24.0
20
+ path = QPainterPath()
21
+ # Index finger.
22
+ path.addRoundedRect(QRectF(8.2 * s, 2.0 * s, 3.6 * s, 11.5 * s), 1.8 * s, 1.8 * s)
23
+ # Palm with the folded fingers.
24
+ path.addRoundedRect(QRectF(6.0 * s, 10.0 * s, 12.5 * s, 11.5 * s), 3.2 * s, 3.2 * s)
25
+ # Thumb.
26
+ path.addRoundedRect(QRectF(3.2 * s, 11.6 * s, 4.6 * s, 3.4 * s), 1.6 * s, 1.6 * s)
27
+ painter.setPen(QPen(colour, 1.4 * s))
28
+ painter.setBrush(QColor(255, 255, 255))
29
+ painter.drawPath(path.simplified())
30
+ painter.setPen(QPen(colour, 1.0 * s))
31
+ for x in (11.8, 14.8): # knuckle creases
32
+ painter.drawLine(QPointF(x * s, 10.8 * s), QPointF(x * s, 14.0 * s))
33
+
34
+
35
+ def _shape(painter: QPainter, kind: str, size: float, colour: QColor) -> None:
36
+ s = size / 24.0
37
+ pen = QPen(colour, 2.0 * s)
38
+ pen.setCapStyle(Qt.RoundCap)
39
+ pen.setJoinStyle(Qt.RoundJoin)
40
+ painter.setPen(pen)
41
+ painter.setBrush(Qt.NoBrush)
42
+ if kind == "place":
43
+ painter.drawEllipse(QRectF(4 * s, 4 * s, 16 * s, 16 * s))
44
+ painter.setBrush(colour)
45
+ painter.setPen(Qt.NoPen)
46
+ painter.drawEllipse(QPointF(12 * s, 12 * s), 2.4 * s, 2.4 * s)
47
+ elif kind == "transition":
48
+ painter.drawRect(QRectF(5 * s, 4 * s, 14 * s, 16 * s))
49
+ elif kind == "arc":
50
+ painter.drawLine(QPointF(4 * s, 19 * s), QPointF(18 * s, 6 * s))
51
+ head = QPainterPath(QPointF(20 * s, 4 * s))
52
+ head.lineTo(QPointF(12.5 * s, 6.2 * s))
53
+ head.lineTo(QPointF(17.8 * s, 11.5 * s))
54
+ head.closeSubpath()
55
+ painter.setBrush(colour)
56
+ painter.drawPath(head)
57
+ elif kind == "select":
58
+ _hand(painter, size, colour)
59
+
60
+
61
+ def tool_icon(kind: str) -> QIcon:
62
+ """The button icon for a tool (``select``, ``place``, ``transition``, ``arc``)."""
63
+ pixmap = QPixmap(48, 48)
64
+ pixmap.setDevicePixelRatio(2.0)
65
+ pixmap.fill(Qt.transparent)
66
+ painter = QPainter(pixmap)
67
+ painter.setRenderHint(QPainter.Antialiasing)
68
+ _shape(painter, kind, 24, QColor(style.tokens().text))
69
+ painter.end()
70
+ return QIcon(pixmap)
71
+
72
+
73
+ def tool_cursor(kind: str) -> QCursor:
74
+ """The canvas cursor for a tool: a crosshair with the tool's shape next to
75
+ it (the plain pointer for Select)."""
76
+ if kind == "select":
77
+ return QCursor(Qt.ArrowCursor)
78
+ pixmap = QPixmap(64, 64)
79
+ pixmap.setDevicePixelRatio(2.0)
80
+ pixmap.fill(Qt.transparent)
81
+ painter = QPainter(pixmap)
82
+ painter.setRenderHint(QPainter.Antialiasing)
83
+ black = QColor(20, 20, 20)
84
+ for width, colour in ((3.0, QColor(255, 255, 255)), (1.2, black)): # outlined cross
85
+ painter.setPen(QPen(colour, width))
86
+ painter.drawLine(QPointF(8, 1), QPointF(8, 15))
87
+ painter.drawLine(QPointF(1, 8), QPointF(15, 8))
88
+ painter.translate(13, 13)
89
+ _shape(painter, kind, 16, QColor(style.tokens().accent))
90
+ painter.end()
91
+ return QCursor(pixmap, 8, 8)
92
+
93
+
94
+ def sidebar_icon() -> QIcon:
95
+ """A window with a pane on the left: the button that shows or hides the sidebar."""
96
+ pixmap = QPixmap(40, 40)
97
+ pixmap.setDevicePixelRatio(2.0)
98
+ pixmap.fill(Qt.transparent)
99
+ painter = QPainter(pixmap)
100
+ painter.setRenderHint(QPainter.Antialiasing)
101
+ colour = QColor(style.tokens().text_secondary)
102
+ pen = QPen(colour, 1.5)
103
+ pen.setJoinStyle(Qt.RoundJoin)
104
+ frame = QRectF(2.5, 4.5, 15, 11)
105
+ painter.setPen(Qt.NoPen)
106
+ painter.setBrush(QColor(colour.red(), colour.green(), colour.blue(), 60))
107
+ painter.drawRoundedRect(QRectF(frame.x(), frame.y(), 5.5, frame.height()), 2.5, 2.5)
108
+ painter.setPen(pen)
109
+ painter.setBrush(Qt.NoBrush)
110
+ painter.drawRoundedRect(frame, 2.5, 2.5)
111
+ painter.drawLine(QPointF(8, frame.top()), QPointF(8, frame.bottom()))
112
+ painter.end()
113
+ return QIcon(pixmap)
114
+
115
+
116
+ def question_icon() -> QIcon:
117
+ """A sheet with a question mark: the button that brings back an exercise's question."""
118
+ pixmap = QPixmap(40, 40)
119
+ pixmap.setDevicePixelRatio(2.0)
120
+ pixmap.fill(Qt.transparent)
121
+ painter = QPainter(pixmap)
122
+ painter.setRenderHint(QPainter.Antialiasing)
123
+ colour = QColor(style.tokens().text_secondary)
124
+ pen = QPen(colour, 1.5)
125
+ pen.setJoinStyle(Qt.RoundJoin)
126
+ painter.setPen(pen)
127
+ painter.drawRoundedRect(QRectF(4.5, 2.5, 11, 15), 2.5, 2.5)
128
+ font = painter.font()
129
+ font.setBold(True)
130
+ font.setPixelSize(10)
131
+ painter.setFont(font)
132
+ painter.drawText(QRectF(4.5, 2.5, 11, 15), Qt.AlignCenter, "?")
133
+ painter.end()
134
+ return QIcon(pixmap)