gini-toolkit 6.0.1.dev0__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 (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/ui/memory_lab.py ADDED
@@ -0,0 +1,371 @@
1
+ """Memory Lab — the visual virtual-memory workbench for an xv6 Machine.
2
+
3
+ Shows a process's address space three ways: the region MAP (text | data | heap | guard | stack …
4
+ trapframe | trampoline), the leaf PAGE-TABLE mappings (VA → PA with R/W/X/U permissions), and the
5
+ PHYSICAL page allocator (free vs used), plus a page-FAULT log. Press “Simulate page fault” to see
6
+ lazy/demand allocation grow the stack: a fault is recorded, a physical page is allocated, and a
7
+ new mapping appears. Renders from an injected provider (offline DemoVm here; GDB-backed on Mac).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from PySide6.QtCore import Qt
12
+ from PySide6.QtGui import QColor, QPainter
13
+ from PySide6.QtWidgets import (
14
+ QDialog, QFrame, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QPushButton, QTableWidget,
15
+ QTableWidgetItem, QVBoxLayout, QWidget,
16
+ )
17
+
18
+ from ..domain.xv6_vm import PGSIZE, DemoVm, classify_faults, region_for, shared_frames
19
+ from .theme import ThemeManager, icons
20
+ from .theme.manager import scale_css as _scss
21
+
22
+ _REGION_ACCENT = {"text": "blue", "data": "green", "heap": "cyan", "guard": "slate",
23
+ "stack": "amber", "trapframe": "purple", "trampoline": "red"}
24
+ _FAULT_ACCENT = {"cow-write": "purple", "lazy-alloc": "green", "stack-growth": "amber",
25
+ "illegal": "red"}
26
+
27
+
28
+ class ResidentMeter(QWidget):
29
+ """A two-tone bar: RESIDENT (physically-backed) pages filled solid inside the VIRTUAL extent,
30
+ so lazy allocation reads as a gap that fills in — virtual jumps on sbrk, resident catches up
31
+ one page per fault."""
32
+
33
+ def __init__(self, theme) -> None:
34
+ super().__init__()
35
+ self.theme = theme
36
+ self._res = 0
37
+ self._virt = 0
38
+ self.setMinimumHeight(22)
39
+
40
+ def set_values(self, resident, virtual) -> None:
41
+ self._res, self._virt = int(resident), int(virtual)
42
+ self.update()
43
+
44
+ def paintEvent(self, _e) -> None:
45
+ p = QPainter(self)
46
+ t = self.theme.theme
47
+ p.fillRect(self.rect(), QColor(t.panel))
48
+ if self._virt <= 0:
49
+ return
50
+ w = self.width()
51
+ # the virtual extent (outline) then the resident fill inside it
52
+ p.fillRect(0, 0, w, self.height(), QColor(t.panel2))
53
+ frac = max(0.0, min(1.0, self._res / self._virt))
54
+ p.fillRect(0, 0, int(w * frac), self.height(), QColor(t.accent_for("green")))
55
+
56
+
57
+ class RegionStrip(QWidget):
58
+ """The address-space map — one labelled segment per region (width weighted by page count,
59
+ with a floor so single-page regions like the trapframe stay visible)."""
60
+
61
+ def __init__(self, theme) -> None:
62
+ super().__init__()
63
+ self.theme = theme
64
+ self._regions = []
65
+ self.setMinimumHeight(60)
66
+
67
+ def set_regions(self, regions) -> None:
68
+ self._regions = list(regions)
69
+ self.update()
70
+
71
+ def paintEvent(self, _e) -> None:
72
+ p = QPainter(self)
73
+ t = self.theme.theme
74
+ p.fillRect(self.rect(), QColor(t.panel2))
75
+ if not self._regions:
76
+ return
77
+ floor = 0.07
78
+ raw = [min(max(r.pages, 1), 64) for r in self._regions] # clamp huge stacks
79
+ tot = sum(raw)
80
+ weights = [max(v / tot, floor) for v in raw]
81
+ wsum = sum(weights)
82
+ x = 0.0
83
+ W = self.width()
84
+ for r, w in zip(self._regions, weights):
85
+ seg = W * (w / wsum)
86
+ p.fillRect(int(x), 6, int(seg) - 2, self.height() - 30,
87
+ QColor(t.accent_for(_REGION_ACCENT.get(r.name, "slate"))))
88
+ p.setPen(QColor(t.text))
89
+ p.drawText(int(x) + 4, 22, r.name)
90
+ p.setPen(QColor(t.muted))
91
+ p.drawText(int(x) + 4, self.height() - 8, r.perms or "")
92
+ x += seg
93
+
94
+
95
+ class PhysBar(QWidget):
96
+ """A used/free bar for the physical page allocator."""
97
+
98
+ def __init__(self, theme) -> None:
99
+ super().__init__()
100
+ self.theme = theme
101
+ self._frac = 0.0
102
+ self.setMinimumHeight(26)
103
+
104
+ def set_frac(self, frac) -> None:
105
+ self._frac = max(0.0, min(1.0, frac))
106
+ self.update()
107
+
108
+ def paintEvent(self, _e) -> None:
109
+ p = QPainter(self)
110
+ t = self.theme.theme
111
+ p.fillRect(self.rect(), QColor(t.panel))
112
+ p.fillRect(0, 0, int(self.width() * self._frac), self.height(),
113
+ QColor(t.accent_for("amber")))
114
+
115
+
116
+ class MemoryLab(QDialog):
117
+ def __init__(self, parent, theme: ThemeManager, device=None, provider=None,
118
+ on_play=None, play_games=None) -> None:
119
+ super().__init__(parent)
120
+ self.theme = theme
121
+ self.device = device
122
+ self.provider = provider or DemoVm()
123
+ self._on_play = on_play # callable(game_id) opening a game; may be None
124
+ self._play_games = play_games or [] # [(label, game_id)]
125
+
126
+ t = theme.theme
127
+ self.setWindowTitle(f"Virtual Memory Lab — {getattr(device, 'name', 'xv6')}")
128
+ self.resize(920, 700)
129
+ self.setStyleSheet(f"QDialog{{background:{t.bg};}}")
130
+ root = QVBoxLayout(self)
131
+ self._build_header(root)
132
+
133
+ self._strip = RegionStrip(theme)
134
+ root.addWidget(self._panel("Address space · regions (low → high VA)", self._strip))
135
+
136
+ grid = QGridLayout(); grid.setSpacing(10); root.addLayout(grid, 1)
137
+ self._pt_tbl = self._table(["VA", "PA", "perms", "region"])
138
+ grid.addWidget(self._panel("Page table · leaf mappings", self._pt_tbl, fill=True), 0, 0, 2, 1)
139
+ grid.addWidget(self._build_phys_panel(), 0, 1)
140
+ grid.addWidget(self._build_faults_panel(), 1, 1)
141
+ grid.setColumnStretch(0, 3); grid.setColumnStretch(1, 2)
142
+
143
+ # the COW / sharing view + per-process resident-vs-virtual meters (uses all_procs())
144
+ root.addWidget(self._build_sharing_panel())
145
+
146
+ self._render(self.provider.snapshot())
147
+ self._render_sharing()
148
+
149
+ # -- header/panels ---------------------------------------------------- #
150
+ def _build_header(self, root) -> None:
151
+ t = self.theme.theme
152
+ head = QHBoxLayout()
153
+ ic = QLabel(); ic.setPixmap(icons.render_pixmap("layout", t.accent_for("purple"), 22))
154
+ title = QLabel(f" Virtual Memory Lab — {getattr(self.device, 'name', 'xv6')}")
155
+ title.setStyleSheet(_scss(f"color:{t.text};font-size:16px;font-weight:600;"))
156
+ head.addWidget(ic); head.addWidget(title); head.addStretch(1)
157
+ if self._on_play is not None: # in-lab games (thrashing, translate)
158
+ for label, gid in self._play_games:
159
+ play = QPushButton(f" Play: {label}")
160
+ play.setStyleSheet(
161
+ f"QPushButton{{color:{t.accent_for('purple')};background:{t.panel2};"
162
+ f"border:1px solid {t.line};border-radius:8px;padding:5px 11px;}}"
163
+ f"QPushButton:hover{{border-color:{t.accent};}}")
164
+ play.clicked.connect(lambda _c=False, g=gid: self._on_play(g))
165
+ head.addWidget(play)
166
+ self._satp = QLabel(); self._satp.setStyleSheet(
167
+ _scss(f"color:{t.muted};font-family:monospace;font-size:11px;"))
168
+ head.addWidget(self._satp)
169
+ root.addLayout(head)
170
+ hint = QLabel("The process address space, its leaf page-table mappings (VA→PA with "
171
+ "R/W/X/U), and the physical allocator. Simulate a fault to watch demand "
172
+ "paging grow the stack.")
173
+ hint.setWordWrap(True); hint.setStyleSheet(_scss(f"color:{t.muted};font-size:11px;"))
174
+ root.addWidget(hint)
175
+
176
+ def _panel(self, title, inner, fill=False) -> QFrame:
177
+ t = self.theme.theme
178
+ f = QFrame(); f.setStyleSheet(
179
+ _scss(f"QFrame{{background:{t.panel2};border:1px solid {t.line};border-radius:10px;}}"))
180
+ v = QVBoxLayout(f); v.setContentsMargins(10, 8, 10, 10)
181
+ h = QLabel(title); h.setStyleSheet(
182
+ _scss(f"color:{t.muted};font-size:11px;font-weight:600;border:none;"))
183
+ v.addWidget(h)
184
+ inner.setStyleSheet((inner.styleSheet() or "") + "border:none;")
185
+ v.addWidget(inner, 1 if fill else 0)
186
+ return f
187
+
188
+ def _table(self, cols) -> QTableWidget:
189
+ t = self.theme.theme
190
+ tbl = QTableWidget(0, len(cols))
191
+ tbl.setHorizontalHeaderLabels(cols)
192
+ tbl.verticalHeader().setVisible(False)
193
+ tbl.setEditTriggers(QTableWidget.NoEditTriggers)
194
+ tbl.setSelectionMode(QTableWidget.NoSelection)
195
+ tbl.horizontalHeader().setSectionResizeMode(len(cols) - 1, QHeaderView.Stretch)
196
+ tbl.setStyleSheet(
197
+ f"QTableWidget{{background:{t.panel};color:{t.text};border:none;"
198
+ f"gridline-color:{t.line};font-size:12px;font-family:monospace;}}"
199
+ f"QHeaderView::section{{background:{t.panel2};color:{t.muted};border:none;"
200
+ "padding:4px;font-family:sans-serif;}")
201
+ return tbl
202
+
203
+ def _build_phys_panel(self) -> QFrame:
204
+ t = self.theme.theme
205
+ f, v = self._framed("Physical memory · page allocator")
206
+ self._phys_bar = PhysBar(self.theme)
207
+ v.addWidget(self._phys_bar)
208
+ self._phys_lbl = QLabel(); self._phys_lbl.setStyleSheet(
209
+ _scss(f"color:{t.text};font-size:12px;border:none;"))
210
+ v.addWidget(self._phys_lbl)
211
+ v.addStretch(1)
212
+ return f
213
+
214
+ def _build_faults_panel(self) -> QFrame:
215
+ t = self.theme.theme
216
+ f, v = self._framed("Page faults · live ring (classified)")
217
+ self._fault_tbl = self._table(["pid", "VA", "cause", "kind"])
218
+ v.addWidget(self._fault_tbl, 1)
219
+ self._live = not hasattr(self.provider, "simulate_fault") # live reader can't fake a fault
220
+ btn = QPushButton(" Refresh" if self._live else " Simulate page fault")
221
+ btn.setToolTip("Re-read the live fault ring + page tables (launch `alloc` from the "
222
+ "scheduler window to make real faults)" if self._live else
223
+ "Grow the stack by one page via a simulated demand fault")
224
+ btn.setIcon(icons.icon("send", t.accent_for("amber"), 14))
225
+ btn.clicked.connect(self._on_fault)
226
+ btn.setStyleSheet(self._btn_css())
227
+ v.addWidget(btn)
228
+ return f
229
+
230
+ def _btn_css(self) -> str:
231
+ t = self.theme.theme
232
+ return (f"QPushButton{{color:{t.text};background:{t.panel};border:1px solid {t.line};"
233
+ f"border-radius:8px;padding:6px 12px;}}QPushButton:hover{{border-color:{t.accent};}}")
234
+
235
+ def _build_sharing_panel(self) -> QFrame:
236
+ """The COW / physical-sharing view: each process's resident-vs-virtual meter, plus the
237
+ physical pages mapped by more than one process (shared until one writes)."""
238
+ t = self.theme.theme
239
+ f, v = self._framed("Copy-on-write · processes & physically-shared pages")
240
+ row = QHBoxLayout(); v.addLayout(row, 1)
241
+ # left: per-process resident/virtual meters
242
+ left = QWidget(); self._proc_box = QVBoxLayout(left); self._proc_box.setSpacing(4)
243
+ self._proc_meters: dict = {}
244
+ lw = QWidget(); lv = QVBoxLayout(lw); lv.setContentsMargins(0, 0, 0, 0)
245
+ cap = QLabel("resident (green) within virtual (sz)")
246
+ cap.setStyleSheet(_scss(f"color:{t.faint};font-size:10px;border:none;"))
247
+ lv.addWidget(cap); lv.addWidget(left); lv.addStretch(1)
248
+ row.addWidget(lw, 2)
249
+ # right: shared physical frames
250
+ self._share_tbl = self._table(["phys page", "shared by", "cow"])
251
+ row.addWidget(self._panel("Shared physical pages", self._share_tbl, fill=True), 3)
252
+ # a COW-write button in demo mode (make the child copy its shared page)
253
+ if hasattr(self.provider, "simulate_cow_write"):
254
+ b = QPushButton(" Simulate COW write (child writes shared page)")
255
+ b.setIcon(icons.icon("send", t.accent_for("purple"), 14))
256
+ b.setStyleSheet(self._btn_css())
257
+ b.clicked.connect(self._on_cow)
258
+ v.addWidget(b)
259
+ return f
260
+
261
+ def _framed(self, title) -> tuple[QFrame, QVBoxLayout]:
262
+ t = self.theme.theme
263
+ f = QFrame(); f.setStyleSheet(
264
+ _scss(f"QFrame{{background:{t.panel2};border:1px solid {t.line};border-radius:10px;}}"))
265
+ v = QVBoxLayout(f); v.setContentsMargins(10, 8, 10, 10)
266
+ h = QLabel(title); h.setStyleSheet(
267
+ _scss(f"color:{t.muted};font-size:11px;font-weight:600;border:none;"))
268
+ v.addWidget(h)
269
+ return f, v
270
+
271
+ # -- actions ---------------------------------------------------------- #
272
+ def _on_fault(self) -> None:
273
+ fn = getattr(self.provider, "simulate_fault", None)
274
+ self._render(fn() if callable(fn) else self.provider.snapshot())
275
+ self._render_sharing()
276
+
277
+ def _on_cow(self) -> None:
278
+ fn = getattr(self.provider, "simulate_cow_write", None)
279
+ if callable(fn):
280
+ fn()
281
+ self._render(self.provider.snapshot())
282
+ self._render_sharing()
283
+
284
+ def _all_procs(self) -> dict:
285
+ fn = getattr(self.provider, "all_procs", None)
286
+ try:
287
+ return fn() if callable(fn) else {}
288
+ except Exception:
289
+ return {}
290
+
291
+ def _fault_rows(self, snap):
292
+ """(pid, va, cause, kind) rows. Live: the classified fault RING; demo: the simulated log."""
293
+ if self._live:
294
+ faults = classify_faults(self._all_procs_faults(), self._all_procs())
295
+ return [(f.pid, f.va, f.cause, f.kind) for f in faults]
296
+ return [(getattr(f, "pid", None), f.va, f.cause, "") for f in snap.faults]
297
+
298
+ def _all_procs_faults(self):
299
+ fn = getattr(self.provider, "faults", None)
300
+ try:
301
+ return fn() if callable(fn) else []
302
+ except Exception:
303
+ return []
304
+
305
+ def _render_sharing(self) -> None:
306
+ t = self.theme.theme
307
+ procs = self._all_procs()
308
+ # per-process resident-vs-virtual meters (rebuild)
309
+ while self._proc_box.count():
310
+ w = self._proc_box.takeAt(0).widget()
311
+ if w:
312
+ w.deleteLater()
313
+ self._proc_meters = {}
314
+ for pid in sorted(procs):
315
+ pv = procs[pid]
316
+ roww = QWidget(); rl = QHBoxLayout(roww); rl.setContentsMargins(0, 0, 0, 0)
317
+ lbl = QLabel(f"pid {pid} {pv.name}")
318
+ lbl.setStyleSheet(_scss(f"color:{t.text};font-size:11px;border:none;min-width:110px;"))
319
+ meter = ResidentMeter(self.theme)
320
+ meter.set_values(pv.resident_pages, max(pv.virtual_pages, pv.resident_pages))
321
+ num = QLabel(f"{pv.resident_pages}/{max(pv.virtual_pages, pv.resident_pages)} pg")
322
+ num.setStyleSheet(_scss(f"color:{t.muted};font-size:11px;border:none;min-width:56px;"))
323
+ rl.addWidget(lbl); rl.addWidget(meter, 1); rl.addWidget(num)
324
+ self._proc_box.addWidget(roww)
325
+ self._proc_meters[pid] = meter
326
+ # shared physical pages (mapped by >1 proc) — the COW sharing signal
327
+ shared = shared_frames(procs)
328
+ # is any owner's leaf for this pa a COW page? (user, read-only, RSW-marked)
329
+ self._share_tbl.setRowCount(len(shared))
330
+ for r, pa in enumerate(sorted(shared)):
331
+ pids = shared[pa]
332
+ cow = any(pte.pa == pa and pte.cow for pv in procs.values() for pte in pv.leaves)
333
+ vals = [hex(pa), ", ".join(f"pid {x}" for x in pids), "COW" if cow else "—"]
334
+ for c, val in enumerate(vals):
335
+ it = QTableWidgetItem(val)
336
+ if c == 2 and cow:
337
+ it.setForeground(QColor(t.accent_for("purple")))
338
+ self._share_tbl.setItem(r, c, it)
339
+
340
+ def _render(self, snap) -> None:
341
+ t = self.theme.theme
342
+ self._satp.setText(f"satp = {hex(snap.satp)}")
343
+ self._strip.set_regions(snap.regions)
344
+ # page table
345
+ leaves = sorted(snap.leaves, key=lambda p: p.va)
346
+ self._pt_tbl.setRowCount(len(leaves))
347
+ for r, pte in enumerate(leaves):
348
+ reg = region_for(pte.va, snap.regions)
349
+ for c, val in enumerate([hex(pte.va), hex(pte.pa), pte.perms, reg]):
350
+ it = QTableWidgetItem(val)
351
+ if c == 2 and "u" in pte.perms:
352
+ it.setForeground(QColor(t.accent_for("green")))
353
+ elif c == 2 and pte.perms == "----":
354
+ it.setForeground(QColor(t.faint))
355
+ self._pt_tbl.setItem(r, c, it)
356
+ # physical memory
357
+ ph = snap.phys
358
+ self._phys_bar.set_frac(ph.used_frac)
359
+ self._phys_lbl.setText(
360
+ f"{ph.used_pages:,} used / {ph.free_pages:,} free of {ph.total_pages:,} pages "
361
+ f"({ph.used_frac * 100:.1f}% used · {ph.free_pages * 4 // 1024} MB free)")
362
+ # faults — the live classified ring (or the simulated demo log)
363
+ rows = self._fault_rows(snap)
364
+ self._fault_tbl.setRowCount(len(rows))
365
+ for r, (pid, va, cause, kind) in enumerate(rows):
366
+ cells = ["" if pid is None else str(pid), hex(va), cause, kind]
367
+ for c, val in enumerate(cells):
368
+ it = QTableWidgetItem(val)
369
+ if c == 3 and kind in _FAULT_ACCENT:
370
+ it.setForeground(QColor(t.accent_for(_FAULT_ACCENT[kind])))
371
+ self._fault_tbl.setItem(r, c, it)
@@ -0,0 +1,302 @@
1
+ """The Missions panel — the student-facing arena HUD.
2
+
3
+ Shows the brief, a live **objective tracker** (met ✓ / unmet ✗ / awaiting-run ⏳), the game
4
+ clock, and lives (attempts). It renders a Mission + the current world; the controller calls
5
+ `refresh(world)` whenever the canvas changes (structural objectives update instantly) and on
6
+ Run/Check. When the attempt is witnessed, a band badge appears.
7
+
8
+ Kept decoupled from the app bus for testability — a thin controller wires `bus.topology_changed`
9
+ to `refresh`. Theme is optional (sensible fallback colors) so the widget renders standalone.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from PySide6.QtCore import Qt, Signal
14
+ from PySide6.QtWidgets import (
15
+ QFrame, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QVBoxLayout, QWidget,
16
+ )
17
+
18
+ from ..domain import objectives as _obj
19
+ from .theme.manager import scale_css as _scss
20
+
21
+
22
+ # Hard width cap for the HUD. A QLabel with wordWrap reports its full one-line text as its
23
+ # *minimum* width on real Qt (a long-standing quirk), which would force the whole Ask GINI dock —
24
+ # and the window — wider than the screen. `setMaximumWidth` is the one constraint Qt always
25
+ # honors (a widget can never exceed it, and its minimum is clamped to it), so we cap the panel.
26
+ _PANEL_MAX_W = 500
27
+
28
+
29
+ def _fluid(label: QLabel) -> QLabel:
30
+ """A word-wrapped label that is allowed to shrink (small minimum width) so it wraps into the
31
+ panel instead of demanding its full one-line width."""
32
+ label.setWordWrap(True)
33
+ label.setMinimumWidth(40)
34
+ return label
35
+
36
+ # status → glyph (kept to widely-available symbols so they render everywhere; ⏳ is not)
37
+ _GLYPH = {_obj.MET: "✓", _obj.UNMET: "✗", _obj.PENDING: "○"}
38
+ _FALLBACK = {"text": "#e8e8ea", "muted": "#8a8a90", "met": "#3fb950", "unmet": "#8a8a90",
39
+ "pending": "#d0932a", "accent": "#4c8dff", "warn": "#d9534f"}
40
+ _BAND_COLOR = {"gold": "#e0b53f", "pass": "#3fb950", "partial": "#d0932a", "incomplete": "#8a8a90"}
41
+
42
+
43
+ def _fmt_clock(remaining) -> str:
44
+ if remaining is None:
45
+ return "—:—"
46
+ remaining = max(0, int(remaining))
47
+ return f"{remaining // 60:d}:{remaining % 60:02d}"
48
+
49
+
50
+ class MissionPanel(QWidget):
51
+ run_requested = Signal() # the student pressed Run/Check → run behavioral probes
52
+
53
+ def __init__(self, theme=None, parent=None) -> None:
54
+ super().__init__(parent)
55
+ self.theme = theme
56
+ self._mission = None
57
+ self._shown: int | None = None # the level the student is actually ON
58
+ self._peek: int | None = None # a level they clicked to look ahead at (read-only)
59
+ # hard-cap the width so the HUD can never inflate the Ask GINI dock / the window
60
+ self.setMaximumWidth(_PANEL_MAX_W)
61
+ self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
62
+ lay = QVBoxLayout(self)
63
+ lay.setContentsMargins(12, 12, 12, 12)
64
+ lay.setSpacing(8)
65
+
66
+ header = QHBoxLayout()
67
+ self._title = _fluid(QLabel("Mission"))
68
+ self._title.setStyleSheet(_scss("font-size:16px; font-weight:700;"))
69
+ header.addWidget(self._title, 1)
70
+ self._clock = QLabel("—:—")
71
+ self._clock.setStyleSheet(_scss("font-size:15px; font-weight:600;"))
72
+ self._lives = QLabel("")
73
+ header.addWidget(self._lives)
74
+ header.addSpacing(10)
75
+ header.addWidget(self._clock)
76
+ lay.addLayout(header)
77
+
78
+ self._brief = _fluid(QLabel(""))
79
+ self._brief.setStyleSheet(_scss("font-size:12px;"))
80
+ lay.addWidget(self._brief)
81
+
82
+ # the LEVEL RIBBON — the shape of the whole journey, always visible
83
+ self._ribbon = QHBoxLayout()
84
+ self._ribbon.setSpacing(4)
85
+ lay.addLayout(self._ribbon)
86
+
87
+ # current beat (guided missions): "Step 2 of 5" + the instruction, shown prominently
88
+ self._step_num = QLabel("")
89
+ self._step_num.setObjectName("Muted")
90
+ self._step_num.setVisible(False)
91
+ lay.addWidget(self._step_num)
92
+ self._step = _fluid(QLabel(""))
93
+ self._step.setVisible(False)
94
+ lay.addWidget(self._step)
95
+
96
+ line = QFrame(); line.setFrameShape(QFrame.HLine)
97
+ lay.addWidget(line)
98
+
99
+ self._obj_box = QVBoxLayout()
100
+ self._obj_box.setSpacing(4)
101
+ lay.addLayout(self._obj_box)
102
+
103
+ # Run/Check — resolves behavioral objectives against the live runtime. Shown only when the
104
+ # lesson HAS behavioral objectives (nothing to run otherwise).
105
+ self._run_btn = QPushButton("▶ Run / Check")
106
+ self._run_btn.setObjectName("Accent")
107
+ self._run_btn.setCursor(Qt.PointingHandCursor)
108
+ self._run_btn.setVisible(False)
109
+ self._run_btn.clicked.connect(self.run_requested.emit)
110
+ lay.addWidget(self._run_btn)
111
+
112
+ self._band = QLabel("")
113
+ self._band.setStyleSheet(_scss("font-size:14px; font-weight:700;"))
114
+ self._band.setVisible(False)
115
+ lay.addWidget(self._band)
116
+ lay.addStretch(1)
117
+
118
+ # -- colors ------------------------------------------------------------- #
119
+ def _c(self, role: str) -> str:
120
+ t = getattr(self.theme, "theme", None)
121
+ if t is not None:
122
+ return {"text": getattr(t, "text", _FALLBACK["text"]),
123
+ "muted": getattr(t, "text_muted", _FALLBACK["muted"]),
124
+ "accent": getattr(t, "accent", _FALLBACK["accent"]),
125
+ "met": getattr(t, "accent_for", lambda k: _FALLBACK["met"])("green"),
126
+ "pending": getattr(t, "accent_for", lambda k: _FALLBACK["pending"])("amber"),
127
+ "unmet": getattr(t, "text_muted", _FALLBACK["unmet"]),
128
+ "warn": _FALLBACK["warn"]}.get(role, _FALLBACK.get(role, "#888"))
129
+ return _FALLBACK.get(role, "#888")
130
+
131
+ # -- API ---------------------------------------------------------------- #
132
+ def set_mission(self, mission) -> None:
133
+ self._mission = mission
134
+ self._shown = self._peek = None # a new mission starts back at Level 1
135
+ les = mission.lesson
136
+ self._title.setText(les.title or les.id)
137
+ self._brief.setText(les.brief)
138
+ self._brief.setStyleSheet(f"color:{self._c('muted')};")
139
+ self._render_objectives([_obj.ObjectiveResult(o.id, o.say, o.kind, _obj.UNMET)
140
+ for o in les.objectives])
141
+ self._run_btn.setVisible(any(o.is_behavioral() for o in les.objectives))
142
+ self._sync_header()
143
+
144
+ def set_step(self, text: str, index: int, total: int) -> None:
145
+ """Show the current guided beat prominently (index/total = 'Step 2 of 5')."""
146
+ self._step_num.setText(f"Step {index} of {total}")
147
+ self._step.setText("→ " + text)
148
+ self._step.setStyleSheet(f"color:{self._c('accent')}; font-weight:600;")
149
+ self._step_num.setVisible(True)
150
+ self._step.setVisible(True)
151
+
152
+ def clear_step(self) -> None:
153
+ self._step_num.setVisible(False)
154
+ self._step.setVisible(False)
155
+
156
+ def refresh(self, world, runner=None) -> None:
157
+ """Re-evaluate against the world and repaint. Structural objectives resolve live (no
158
+ runner); pass a `runner` (a Run/Check) to also resolve behavioral objectives."""
159
+ if self._mission is None:
160
+ return
161
+ self._mission.evaluate(world, runner)
162
+ self.render_current()
163
+
164
+ def render_current(self) -> None:
165
+ """Repaint from the mission's LAST evaluated results (e.g. after a Run/Check) without
166
+ re-evaluating — so behavioral verdicts from the run aren't lost to a runner-less refresh."""
167
+ if self._mission is None:
168
+ return
169
+ self._render_objectives(self._mission.last_results)
170
+ self._sync_header()
171
+
172
+ def tick(self) -> None:
173
+ """Repaint just the clock (call on a 1s timer while playing)."""
174
+ self._sync_header()
175
+
176
+ # -- rendering ---------------------------------------------------------- #
177
+ def _sync_header(self) -> None:
178
+ m = self._mission
179
+ if m is None:
180
+ return
181
+ rem = m.remaining()
182
+ self._clock.setText(_fmt_clock(rem))
183
+ warn = rem is not None and rem <= 60
184
+ self._clock.setStyleSheet("font-size:15px; font-weight:600; color:"
185
+ + (self._c("warn") if warn else self._c("text")) + ";")
186
+ self._lives.setText("♥ " * m.lives_left() or "—")
187
+ self._lives.setStyleSheet(f"color:{self._c('accent')};")
188
+ # Show the LIVE band, computed from the objectives on screen — never a frozen snapshot, or the
189
+ # label can contradict the count ("PARTIAL — 9/9"). Appears once there's progress to report.
190
+ sc = m.score()
191
+ if sc.met > 0 or m.state in ("witnessed", "done"):
192
+ col = _BAND_COLOR.get(sc.band, self._c("text"))
193
+ label = {"gold": "★ GOLD", "pass": "✓ PASS", "partial": "◐ PARTIAL",
194
+ "incomplete": "✗ INCOMPLETE"}.get(sc.band, sc.band.upper())
195
+ self._band.setText(f"{label} — {sc.summary}")
196
+ self._band.setStyleSheet(_scss(f"font-size:14px; font-weight:700; color:{col};"))
197
+ self._band.setVisible(True)
198
+ else:
199
+ self._band.setVisible(False)
200
+
201
+ def _clear_objectives(self) -> None:
202
+ while self._obj_box.count():
203
+ item = self._obj_box.takeAt(0)
204
+ w = item.widget()
205
+ if w is not None:
206
+ w.setParent(None) # detach immediately (deleteLater is async → stale paint)
207
+ w.deleteLater()
208
+
209
+ def _peek_level(self, level: int) -> None:
210
+ """Click a ribbon tile to look at another level. Read-only: it changes nothing, and the
211
+ moment the level you're actually on advances, the view snaps back to it."""
212
+ self._peek = None if level == self._shown else level
213
+ self.render_current()
214
+
215
+ def _clear_ribbon(self) -> None:
216
+ while self._ribbon.count():
217
+ item = self._ribbon.takeAt(0)
218
+ w = item.widget()
219
+ if w is not None:
220
+ w.setParent(None)
221
+ w.deleteLater()
222
+
223
+ def _tile(self, lv: int, state: str, done: int, total: int, selected: bool) -> QPushButton:
224
+ """One ribbon tile. `state` ∈ done | active | locked."""
225
+ face = {"done": self._c("met"), "active": self._c("accent"), "locked": self._c("muted")}[state]
226
+ mark = "✓" if state == "done" else str(lv)
227
+ short = _obj.LEVEL_NAME.get(lv, "").split("(")[0].strip() # tiles stay narrow
228
+ b = QPushButton(f"{mark} {short}")
229
+ b.setCursor(Qt.PointingHandCursor)
230
+ b.setToolTip({"done": f"Level {lv} — finished ({done}/{total})",
231
+ "active": f"Level {lv} — you are here ({done}/{total})",
232
+ "locked": f"Level {lv} — {total} task(s). Click to look ahead."}[state])
233
+ # the active tile is ILLUMINATED (filled); done tiles are solid-but-quiet; locked are outlines
234
+ if state == "active":
235
+ css = f"background:{face}; color:#0d0d10; border:1px solid {face};"
236
+ elif state == "done":
237
+ css = f"background:transparent; color:{face}; border:1px solid {face};"
238
+ else:
239
+ css = f"background:transparent; color:{face}; border:1px dashed {face};"
240
+ ring = f"outline:2px solid {self._c('text')};" if selected else ""
241
+ b.setStyleSheet(
242
+ f"QPushButton{{{css}{ring} border-radius:4px; padding:4px 8px; font-size:11px; "
243
+ f"font-weight:700;}}")
244
+ b.clicked.connect(lambda _=False, l=lv: self._peek_level(l))
245
+ return b
246
+
247
+ def _render_objectives(self, results) -> None:
248
+ """A RIBBON of levels + ONLY the level you're on.
249
+
250
+ Ten-plus rows of tasks would bury the chat and bury the *next move* among things you've
251
+ already done. So the panel shows one level at a time, in a readable size, and the ribbon
252
+ carries the shape of the whole journey: finished levels tick over, the level you're on is
253
+ lit, later ones sit dashed and unlit. Finish a level and the next simply comes to life.
254
+ Locked tiles can be clicked to look ahead (read-only) — knowing what's coming is motivating;
255
+ the view snaps back the moment you actually advance."""
256
+ self._clear_objectives()
257
+ self._clear_ribbon()
258
+ if not results:
259
+ self._shown = self._peek = None
260
+ return
261
+ # group into rungs, preserving the ladder order
262
+ rungs: list[tuple[int, list]] = []
263
+ for r in results:
264
+ lv = getattr(r, "level", 1)
265
+ if not rungs or rungs[-1][0] != lv:
266
+ rungs.append((lv, []))
267
+ rungs[-1][1].append(r)
268
+
269
+ # the ACTIVE rung is the first with anything still open — that's where the student is
270
+ active = next((lv for lv, rs in rungs if not all(x.met for x in rs)), None)
271
+ if active != self._shown:
272
+ self._peek = None # you advanced (or the mission just started) → follow
273
+ self._shown = active
274
+ view = self._peek if self._peek is not None else active
275
+ if view is None: # everything done
276
+ view = rungs[-1][0]
277
+
278
+ for lv, rs in rungs:
279
+ done = sum(1 for x in rs if x.met)
280
+ state = ("done" if done == len(rs) else
281
+ "active" if lv == active else "locked")
282
+ self._ribbon.addWidget(self._tile(lv, state, done, len(rs), selected=(lv == view)))
283
+ self._ribbon.addStretch(1)
284
+
285
+ rows = next((rs for lv, rs in rungs if lv == view), [])
286
+ if self._peek is not None and self._peek != active:
287
+ way = "back at" if active is not None and view < active else "ahead at"
288
+ look = QLabel(f"Looking {way} Level {view} — you're on Level {active}.")
289
+ look.setStyleSheet(_scss(f"color:{self._c('pending')}; font-size:11px; font-weight:600;"))
290
+ self._obj_box.addWidget(look)
291
+
292
+ head = QLabel(f"LEVEL {view} · {_obj.LEVEL_NAME.get(view, '').upper()}")
293
+ head.setStyleSheet(f"color:{self._c('muted')}; font-size:11px; font-weight:700; "
294
+ f"letter-spacing:1px;")
295
+ self._obj_box.addWidget(head)
296
+ for r in rows:
297
+ row = _fluid(QLabel(f"{_GLYPH.get(r.status, '•')} {r.say}"))
298
+ role = {"met": "met", "unmet": "unmet", "pending": "pending"}.get(r.status, "text")
299
+ weight = "600" if r.status == _obj.MET else "400"
300
+ # only ONE level is on screen, so the tasks can finally be a readable size
301
+ row.setStyleSheet(_scss(f"color:{self._c(role)}; font-weight:{weight}; font-size:13px;"))
302
+ self._obj_box.addWidget(row)