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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- 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)
|
gini/ui/mission_panel.py
ADDED
|
@@ -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)
|