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/trap_lab.py ADDED
@@ -0,0 +1,270 @@
1
+ """Trap Lab — live trap-taxonomy histogram + a running feed of individual traps.
2
+
3
+ Reads gini_trapdump over the serial (the agent's /traps, no gdb halt): per-kind counters drive a
4
+ histogram of traps in the last 60s (syscall / page-fault / timer / device / illegal / other), and
5
+ a ring drives a live feed (pid · kind · epc · faulting-address). The lesson students take away is
6
+ that a system call, a timer preemption, and a demand-paged page are the SAME mechanism with
7
+ different causes. "Step a trap ▸" opens the CPU journey to dissect one frame by frame.
8
+
9
+ Same shape as the Syscall Lab: an injected `traps_source` (the bridge's /traps live, or
10
+ DemoScheduler.traps() offline) polled off the GUI thread, so the whole face is testable against a
11
+ fake feed.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from PySide6.QtCore import Qt, QTimer, Signal
16
+ from PySide6.QtGui import QColor, QPainter
17
+ from PySide6.QtWidgets import (
18
+ QComboBox, QDialog, QFrame, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout,
19
+ QWidget,
20
+ )
21
+
22
+ from ..domain.xv6 import (
23
+ TRAP_KINDS, TrapRate, parse_alarms, parse_trapcounts, parse_traptrace, trap_kind_name,
24
+ )
25
+ from .theme import ThemeManager, icons
26
+
27
+ # trap kinds the "Step a trap" catcher can target (conditioned gdb breakpoint); "any" = next trap
28
+ _CATCH_KINDS = ["any", "pagefault", "syscall", "timer", "illegal", "device"]
29
+
30
+ # one colour per trap kind, so the histogram reads at a glance
31
+ _KIND_ACCENT = {0: "blue", 1: "purple", 2: "amber", 3: "cyan", 4: "red", 5: "slate"}
32
+
33
+
34
+ class TrapBars(QWidget):
35
+ """Horizontal bars for the six trap kinds, one colour per kind."""
36
+
37
+ def __init__(self, theme) -> None:
38
+ super().__init__()
39
+ self.theme = theme
40
+ self._rows: list = [] # [(kind_index, label, value)]
41
+ self.setMinimumHeight(180)
42
+
43
+ def set_data(self, rows) -> None:
44
+ self._rows = list(rows)
45
+ self.update()
46
+
47
+ def paintEvent(self, _e) -> None:
48
+ p = QPainter(self)
49
+ t = self.theme.theme
50
+ p.fillRect(self.rect(), QColor(t.panel))
51
+ if not self._rows or not any(v for _k, _l, v in self._rows):
52
+ p.setPen(QColor(t.muted))
53
+ p.drawText(self.rect(), Qt.AlignCenter, "no traps yet — run the machine (or use Demo)")
54
+ return
55
+ peak = max((v for _k, _l, v in self._rows), default=1) or 1
56
+ rowh = min(26, max(18, (self.height() - 8) // max(len(self._rows), 1)))
57
+ label_w, count_w = 96, 56
58
+ bar_max = max(40, self.width() - label_w - count_w - 12)
59
+ y = 4
60
+ for kind, label, v in self._rows:
61
+ p.setPen(QColor(t.text))
62
+ p.drawText(4, y, label_w - 6, rowh, Qt.AlignVCenter | Qt.AlignLeft, label)
63
+ w = int(bar_max * (v / peak))
64
+ p.fillRect(label_w, y + 3, max(w, 1), rowh - 8,
65
+ QColor(t.accent_for(_KIND_ACCENT.get(kind, "blue"))))
66
+ p.setPen(QColor(t.muted))
67
+ p.drawText(label_w + bar_max + 6, y, count_w, rowh,
68
+ Qt.AlignVCenter | Qt.AlignLeft, str(v))
69
+ y += rowh
70
+
71
+
72
+ class TrapLab(QDialog):
73
+ traps_ready = Signal(str) # raw /traps text pushed from the poll worker
74
+ alarms_ready = Signal(str) # raw gini_dump text (ALARM lines) from the poll worker
75
+ caught = Signal(object) # a TrapFrame from a live catch (or None), off the worker
76
+
77
+ def __init__(self, parent, theme: ThemeManager, device=None, traps_source=None,
78
+ on_step=None, catch_source=None, alarm_source=None, on_play=None) -> None:
79
+ super().__init__(parent)
80
+ self.theme = theme
81
+ self.device = device
82
+ self._src = traps_source or (lambda: "")
83
+ self._on_step = on_step
84
+ self._on_play = on_play # callable() opening the decode-the-trap game; may be None
85
+ self._catch = catch_source # callable(kind) -> TrapFrame (live gdb freeze); may be None
86
+ self._alarm_src = alarm_source # callable() -> gini_dump text with ALARM lines; may be None
87
+ self._rate = TrapRate(window=60.0)
88
+ self._busy = False
89
+ self._closed = False
90
+
91
+ t = theme.theme
92
+ self.setWindowTitle(f"Traps & Interrupts Lab — {getattr(device, 'name', 'xv6')}")
93
+ self.resize(760, 580)
94
+ self.setStyleSheet(f"QDialog{{background:{t.bg};}}")
95
+ root = QVBoxLayout(self)
96
+
97
+ hdr = QHBoxLayout()
98
+ ic = QLabel(); ic.setPixmap(icons.render_pixmap("dashboard", t.accent_for("amber"), 22))
99
+ title = QLabel(f" Traps & Interrupts Lab — {getattr(device, 'name', 'xv6')}")
100
+ title.setStyleSheet(f"color:{t.text};font-size:16px;font-weight:600;")
101
+ hdr.addWidget(ic); hdr.addWidget(title); hdr.addStretch(1)
102
+ root.addLayout(hdr)
103
+
104
+ head = QLabel("Every trap the CPU takes, classified by cause. A system call, a timer "
105
+ "preemption, and a demand-paged page are the same mechanism — watch the mix "
106
+ "as programs run. No gdb; live over serial.")
107
+ head.setWordWrap(True); head.setStyleSheet(f"color:{t.muted};font-size:12px;")
108
+ root.addWidget(head)
109
+
110
+ self._bars = TrapBars(theme)
111
+ root.addWidget(self._panel("Traps · by cause, last 60s", self._bars), 1)
112
+
113
+ self._feed = QPlainTextEdit(); self._feed.setReadOnly(True)
114
+ self._feed.setStyleSheet(
115
+ f"QPlainTextEdit{{background:{t.panel};color:{t.text};border:1px solid {t.line};"
116
+ "border-radius:6px;font-family:monospace;font-size:12px;}")
117
+ root.addWidget(self._panel("Feed · recent traps (pid kind epc addr)", self._feed), 1)
118
+
119
+ # the sigalarm-lab strip — only shown when an alarm source is wired (Phase 3). It's the
120
+ # live proof a student's periodic handler works: the countdown ticks, `on` flips on fire.
121
+ self._alarms = QLabel()
122
+ self._alarms.setWordWrap(True)
123
+ self._alarms.setStyleSheet(
124
+ f"color:{t.text};background:{t.panel2};border:1px solid {t.line};border-radius:8px;"
125
+ "padding:6px 10px;font-family:monospace;font-size:12px;")
126
+ self._alarms.setVisible(self._alarm_src is not None)
127
+ root.addWidget(self._alarms)
128
+
129
+ row = QHBoxLayout()
130
+ if self._on_play is not None: # in-lab game: decode the trap from its scause
131
+ play = QPushButton(" Play: decode the trap")
132
+ play.setStyleSheet(
133
+ f"QPushButton{{color:{t.accent_for('purple')};background:{t.panel2};"
134
+ f"border:1px solid {t.line};border-radius:8px;padding:6px 12px;}}"
135
+ f"QPushButton:hover{{border-color:{t.accent};}}")
136
+ play.clicked.connect(lambda: self._on_play())
137
+ row.addWidget(play)
138
+ row.addStretch(1)
139
+ self._kind = QComboBox(); self._kind.addItems(_CATCH_KINDS)
140
+ self._kind.setToolTip("Which kind of trap to freeze")
141
+ self._kind.setStyleSheet(
142
+ f"QComboBox{{color:{t.text};background:{t.panel2};border:1px solid {t.line};"
143
+ "border-radius:6px;padding:4px 8px;}")
144
+ catch_lbl = QLabel("catch:"); catch_lbl.setStyleSheet(f"color:{t.muted};font-size:12px;")
145
+ row.addWidget(catch_lbl)
146
+ row.addWidget(self._kind)
147
+ self._step_btn = QPushButton(" Step a trap ▸")
148
+ self._step_btn.setToolTip("Freeze a real trap of this kind and dissect it in the CPU journey")
149
+ self._step_btn.setStyleSheet(
150
+ f"QPushButton{{color:{t.text};background:{t.panel2};border:1px solid {t.line};"
151
+ f"border-radius:8px;padding:6px 12px;}}QPushButton:hover{{border-color:{t.accent};}}")
152
+ self._step_btn.clicked.connect(self._step)
153
+ row.addWidget(self._step_btn)
154
+ root.addLayout(row)
155
+
156
+ self.traps_ready.connect(self._apply)
157
+ self.alarms_ready.connect(self._apply_alarms)
158
+ self.caught.connect(self._on_caught)
159
+ self._poll = QTimer(self); self._poll.timeout.connect(self._fetch)
160
+ self._poll.start(1500)
161
+ self._fetch()
162
+
163
+ def _panel(self, title, inner) -> QFrame:
164
+ t = self.theme.theme
165
+ f = QFrame(); f.setStyleSheet(
166
+ f"QFrame{{background:{t.panel2};border:1px solid {t.line};border-radius:10px;}}")
167
+ v = QVBoxLayout(f); v.setContentsMargins(10, 8, 10, 10)
168
+ h = QLabel(title); h.setStyleSheet(
169
+ f"color:{t.muted};font-size:11px;font-weight:600;border:none;")
170
+ v.addWidget(h)
171
+ inner.setStyleSheet((inner.styleSheet() or "") + "border:none;")
172
+ v.addWidget(inner, 1)
173
+ return f
174
+
175
+ def _step(self) -> None:
176
+ """Freeze a live trap (off the GUI thread — the gdb catch can take up to a few seconds),
177
+ then open the journey seeded with it. With no live catch source, open the authored
178
+ journey immediately."""
179
+ if not callable(self._on_step):
180
+ return
181
+ if callable(self._catch):
182
+ kind = self._kind.currentText()
183
+ self._step_btn.setEnabled(False)
184
+ self._step_btn.setText(" freezing a trap…")
185
+ import threading
186
+
187
+ def work():
188
+ try:
189
+ fr = self._catch(kind)
190
+ except Exception:
191
+ fr = None
192
+ if not self._closed:
193
+ self.caught.emit(fr)
194
+ threading.Thread(target=work, daemon=True).start()
195
+ else:
196
+ self._on_step(None)
197
+
198
+ def _on_caught(self, frame) -> None:
199
+ self._step_btn.setEnabled(True)
200
+ self._step_btn.setText(" Step a trap ▸")
201
+ if not self._closed and callable(self._on_step):
202
+ self._on_step(frame)
203
+
204
+ def _fetch(self) -> None:
205
+ if self._busy or self._closed:
206
+ return
207
+ self._busy = True
208
+ import threading
209
+
210
+ def work():
211
+ try:
212
+ txt = self._src()
213
+ except Exception:
214
+ txt = ""
215
+ atxt = ""
216
+ if callable(self._alarm_src):
217
+ try:
218
+ atxt = self._alarm_src() or ""
219
+ except Exception:
220
+ atxt = ""
221
+ if not self._closed: # don't signal a dialog that's being torn down
222
+ self.traps_ready.emit(txt or "")
223
+ if callable(self._alarm_src):
224
+ self.alarms_ready.emit(atxt)
225
+ threading.Thread(target=work, daemon=True).start()
226
+
227
+ def _apply_alarms(self, txt) -> None:
228
+ if self._closed:
229
+ return
230
+ alarms = parse_alarms(txt)
231
+ if not alarms:
232
+ self._alarms.setText("⏰ no alarm set — a process calls sigalarm(interval, handler) to "
233
+ "run a periodic user-level handler (the sigalarm lab).")
234
+ return
235
+ lines = []
236
+ for pid in sorted(alarms):
237
+ a = alarms[pid]
238
+ state = " · handler RUNNING" if a.on else ""
239
+ lines.append(f"⏰ pid {pid} · every {a.interval} ticks · fires in {a.remaining} · "
240
+ f"handler {a.handler}{state}")
241
+ self._alarms.setText("\n".join(lines))
242
+
243
+ def _apply(self, txt) -> None:
244
+ self._busy = False
245
+ if self._closed:
246
+ return
247
+ import time
248
+ counts = parse_trapcounts(txt)
249
+ if counts:
250
+ self._rate.add(time.monotonic(), counts)
251
+ rates = self._rate.rates()
252
+ # all six kinds, biggest first, so the mix is always fully visible (zeros included)
253
+ rows = sorted(((k, trap_kind_name(k), rates.get(k, 0)) for k in TRAP_KINDS),
254
+ key=lambda r: -r[2])
255
+ self._bars.set_data(rows)
256
+ evs = parse_traptrace(txt)
257
+ if evs:
258
+ lines = [f"{e.pid:>3} {trap_kind_name(e.kind):<9} {e.epc}"
259
+ + (f" {e.tval}" if e.tval not in ("0x0", "0x0000000000000000") else "")
260
+ for e in evs]
261
+ sb = self._feed.verticalScrollBar()
262
+ at_bottom = sb.value() >= sb.maximum() - 4
263
+ self._feed.setPlainText("\n".join(lines))
264
+ if at_bottom:
265
+ sb.setValue(sb.maximum())
266
+
267
+ def closeEvent(self, e) -> None: # noqa: N802
268
+ self._closed = True
269
+ self._poll.stop()
270
+ super().closeEvent(e)
gini/ui/worker_host.py ADDED
@@ -0,0 +1,102 @@
1
+ """Running slow hardware work off the GUI thread, safely.
2
+
3
+ Extracted from board_dialog so that every hardware dialog shares ONE copy. The rules
4
+ below are not stylistic — each one is a bug that aborted the whole process when it was
5
+ found by running the setup dialog headless, and re-deriving them per dialog is how they
6
+ come back:
7
+
8
+ 1. **Hold a Python reference to the worker AND the thread.** PySide does not keep a
9
+ worker alive merely because a signal is connected to it, so a worker passed in as a
10
+ temporary is collected the moment the call returns; the thread then sits in its event
11
+ loop forever and nothing is ever emitted.
12
+ 2. **Connect only to BOUND METHODS of the dialog, never to a closure.** A closure has no
13
+ thread affinity, so Qt chooses a direct connection and the "off-thread" handler runs
14
+ ON the worker thread, touching widgets from outside the GUI thread.
15
+ 3. **Never parent the QThread to the dialog.** Serial work is slow — a board reboots when
16
+ its port is opened — so the dialog can easily be closed mid-run; destroying a running
17
+ QThread makes Qt abort. Unparented, the module registry holds it until it finishes.
18
+
19
+ A host must provide `_alive: bool` and a `_worker_failed(str)` slot.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import warnings
24
+
25
+ from PySide6.QtCore import QObject, QThread
26
+
27
+ # Workers and their threads outlive the dialog that started them, so they need an owner
28
+ # that is not the dialog. Entries retire themselves when the thread ends.
29
+ _LIVE_THREADS: set = set() # {(QThread, worker)}
30
+
31
+
32
+ def drain(timeout_ms: int = 3000) -> int:
33
+ """Stop and reap every live worker thread. Returns how many were still running.
34
+
35
+ `_detach` makes a running worker harmless to the DIALOG, but the thread itself is
36
+ still owned by the registry and is only retired when its `finished` signal is
37
+ delivered — which needs an event loop to be turning. At application shutdown (or at
38
+ the end of a test run) there may be none left, and Python then garbage-collects a
39
+ QThread that is still running, which is precisely the "Destroyed while thread is
40
+ still running" abort that this module exists to prevent. So ask them to stop, and
41
+ wait.
42
+ """
43
+ stragglers = 0
44
+ for thread, _worker in list(_LIVE_THREADS):
45
+ try:
46
+ if thread.isRunning():
47
+ stragglers += 1
48
+ thread.quit()
49
+ thread.wait(timeout_ms)
50
+ except RuntimeError:
51
+ pass # already deleted by Qt; nothing to reap
52
+ _LIVE_THREADS.discard((thread, _worker))
53
+ return stragglers
54
+
55
+
56
+ class WorkerHost:
57
+ """Mixin: `self._run(worker, on_done)` and `self._detach()`."""
58
+
59
+ def _run(self, worker: QObject, on_done) -> None:
60
+ thread = QThread()
61
+ worker.moveToThread(thread)
62
+ thread.started.connect(worker.run)
63
+ entry = (thread, worker) # rule 1
64
+ _LIVE_THREADS.add(entry)
65
+ self._worker = worker
66
+
67
+ worker.done.connect(on_done) # rule 2: a bound method of the dialog
68
+ worker.done.connect(thread.quit)
69
+ self._connections = [(worker.done, on_done)]
70
+ if hasattr(worker, "failed"):
71
+ worker.failed.connect(self._worker_failed)
72
+ worker.failed.connect(thread.quit)
73
+ self._connections.append((worker.failed, self._worker_failed))
74
+ if hasattr(worker, "progress") and hasattr(self, "_worker_progress"):
75
+ worker.progress.connect(self._worker_progress)
76
+ self._connections.append((worker.progress, self._worker_progress))
77
+ thread.finished.connect(worker.deleteLater)
78
+ thread.finished.connect(lambda: _LIVE_THREADS.discard(entry))
79
+ thread.finished.connect(thread.deleteLater)
80
+ self._thread = thread # rule 3: unparented
81
+ thread.start()
82
+
83
+ def _detach(self) -> None:
84
+ """Cut every path from a running worker back into this dialog.
85
+
86
+ An `_alive` flag alone is not enough: if the dialog is destroyed while a worker is
87
+ still going, the queued call lands on a freed C++ object and takes the process
88
+ with it. Only OUR handlers are disconnected — `thread.quit` stays connected, or
89
+ the thread would never stop.
90
+ """
91
+ self._alive = False
92
+ for signal, slot in getattr(self, "_connections", []):
93
+ # A worker that already finished has dropped its connections, and PySide
94
+ # reports that as a warning rather than an exception. Closing a dialog after
95
+ # a successful run is the common case, so this must be silent.
96
+ with warnings.catch_warnings():
97
+ warnings.simplefilter("ignore", RuntimeWarning)
98
+ try:
99
+ signal.disconnect(slot)
100
+ except (RuntimeError, TypeError):
101
+ pass
102
+ self._connections = []
gini/ui/zoo_lab.py ADDED
@@ -0,0 +1,112 @@
1
+ """Zoo Lab — a historical OS from the OS Zoo, running under emulation with its screen embedded.
2
+
3
+ Open it by double-clicking an OS Zoo element (FreeDOS, Plan 9, ReactOS, or a bring-your-own
4
+ classic OS) once the topology is running. The container boots the guest under QEMU with a VNC
5
+ framebuffer and serves it as a web page (noVNC); this dialog embeds that page in a
6
+ `QWebEngineView`, so the OS runs right inside gBuilder — mouse, keyboard, boot and all.
7
+
8
+ The embedded page is the full noVNC client (`vnc.html`), which already carries its own toolbar
9
+ for power/keys (Ctrl-Alt-Del), clipboard and fullscreen; this wrapper just frames it and adds
10
+ Reload / Open-in-browser. Importing this module requires QtWebEngine — the caller catches an
11
+ ImportError and falls back to opening the same URL in the system browser, so the feature works
12
+ with no extra dependency.
13
+
14
+ Sibling of the Machine Lab (xv6): there you inspect an OS's internals; here you *use* a whole
15
+ historical OS live.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from PySide6.QtCore import QUrl
20
+ from PySide6.QtGui import QDesktopServices
21
+ from PySide6.QtWebEngineWidgets import QWebEngineView # ships in PySide6-Addons (or full PySide6)
22
+ from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
23
+
24
+ from .theme import ThemeManager, icons
25
+ from .theme.manager import scale_css as _scss
26
+
27
+
28
+ class ZooLab(QDialog):
29
+ """Embedded screen for one OS Zoo guest (noVNC in a QWebEngineView) + a small control strip."""
30
+
31
+ def __init__(self, parent, theme: ThemeManager, device, url: str) -> None:
32
+ super().__init__(parent)
33
+ self.theme = theme
34
+ self.device = device
35
+ self.url = url
36
+
37
+ t = theme.theme
38
+ self.setWindowTitle(f"Zoo Lab — {device.name}")
39
+ self.resize(1024, 820) # 1024x768 guest + room for the header/controls
40
+ self.setStyleSheet(f"QDialog{{background:{t.bg};}}")
41
+
42
+ root = QVBoxLayout(self)
43
+ self._build_header(root)
44
+ self._view = QWebEngineView(self)
45
+ self._view.setUrl(QUrl(url))
46
+ root.addWidget(self._view, 1)
47
+
48
+ # -- header / controls --------------------------------------------------- #
49
+ def _build_header(self, root) -> None:
50
+ t = self.theme.theme
51
+ head = QHBoxLayout()
52
+ ic = QLabel(); ic.setPixmap(icons.render_pixmap("host", t.accent_for("orange"), 24))
53
+ title = QLabel(f" {self.device.name} — {self._label()}")
54
+ title.setStyleSheet(_scss(f"color:{t.text};font-size:16px;font-weight:600;"))
55
+ head.addWidget(ic); head.addWidget(title); head.addStretch(1)
56
+
57
+ tk = self.device.type_key
58
+ try:
59
+ from ..domain import os_zoo
60
+ chip = ("bring your own image" if tk == "oszoo_byo"
61
+ else "boots out of the box" if os_zoo.get(tk) else "over noVNC")
62
+ except Exception:
63
+ chip = "over noVNC"
64
+ tier = QLabel(chip)
65
+ tier.setStyleSheet(
66
+ f"color:{t.muted};background:{t.panel2};border:1px solid {t.line};"
67
+ "border-radius:9px;padding:2px 10px;font-size:11px;")
68
+ head.addWidget(tier)
69
+
70
+ reload_btn = QPushButton(" Reload")
71
+ reload_btn.setIcon(icons.icon("link", t.accent_for("blue"), 14))
72
+ reload_btn.setToolTip("Reconnect the embedded screen")
73
+ reload_btn.clicked.connect(lambda: self._view.setUrl(QUrl(self.url)))
74
+ reload_btn.setStyleSheet(self._btn_css())
75
+ head.addWidget(reload_btn)
76
+
77
+ browser = QPushButton(" Open in browser")
78
+ browser.setIcon(icons.icon("open", t.accent_for("green"), 14))
79
+ browser.setToolTip("Open this OS's screen in your web browser instead")
80
+ browser.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(self.url)))
81
+ browser.setStyleSheet(self._btn_css())
82
+ head.addWidget(browser)
83
+ root.addLayout(head)
84
+
85
+ def _label(self) -> str:
86
+ tk = self.device.type_key
87
+ try:
88
+ from ..domain import os_zoo
89
+ if tk != "oszoo_byo":
90
+ o = os_zoo.get(tk)
91
+ if o:
92
+ return o.label
93
+ elif tk == "oszoo_byo":
94
+ img = (self.device.properties or {}).get("Image", "")
95
+ return f"classic OS ({img})" if img else "classic OS (set an image)"
96
+ except Exception:
97
+ pass
98
+ try: # generic (e.g. a Desktop machine): element label
99
+ from ..domain.devices import REGISTRY
100
+ dt = REGISTRY.get(tk)
101
+ if dt:
102
+ return dt.label
103
+ except Exception:
104
+ pass
105
+ return tk
106
+
107
+ def _btn_css(self) -> str:
108
+ t = self.theme.theme
109
+ return _scss(
110
+ f"QPushButton{{color:{t.text};background:{t.panel2};border:1px solid {t.line};"
111
+ f"border-radius:8px;padding:4px 12px;font-size:12px;}}"
112
+ f"QPushButton:hover{{border-color:{t.accent};}}")
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: gini-toolkit
3
+ Version: 6.0.1.dev0
4
+ Summary: GINI gBuilder — next-generation topology builder for computer networks and cloud computing experimentation
5
+ Author: GINI Toolkit
6
+ License: GPL-3.0-or-later
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: PySide6-Essentials>=6.6
10
+ Requires-Dist: PyYAML>=6.0
11
+ Provides-Extra: agent
12
+ Requires-Dist: mcp>=1.0; extra == "agent"
13
+ Provides-Extra: graph
14
+ Requires-Dist: matplotlib>=3.7; extra == "graph"
15
+ Requires-Dist: numpy>=1.24; extra == "graph"
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7; extra == "dev"
18
+ Requires-Dist: pytest-qt>=4; extra == "dev"
19
+ Requires-Dist: ruff>=0.4; extra == "dev"
20
+ Requires-Dist: mypy>=1.8; extra == "dev"
21
+
22
+ # GINI gBuilder — next generation
23
+
24
+ A ground-up rewrite of the GINI gBuilder frontend on **PySide6 (Qt 6) / Python 3**, expanding
25
+ GINI from a computer-networks lab into an **ultimate experimentation toolkit** spanning both
26
+ **computer networks** and **cloud computing**, with a first-class **AI agent layer** so agents can
27
+ build, inspect, and explain topologies for students.
28
+
29
+ ## What's here
30
+
31
+ ```
32
+ frontend-ng/
33
+ ├── pyproject.toml
34
+ ├── src/gini/
35
+ │ ├── __main__.py launch the app (python -m gini)
36
+ │ ├── domain/ pure-Python model — NO Qt
37
+ │ │ ├── devices.py device/element registry (networking + cloud)
38
+ │ │ └── topology.py Topology, DeviceInstance, Link
39
+ │ ├── app/
40
+ │ │ └── context.py AppContext + typed event bus (replaces global singletons)
41
+ │ ├── ui/
42
+ │ │ ├── main_window.py shell: toolbar, palette, canvas, inspector, console, assistant
43
+ │ │ ├── canvas.py QGraphicsView scene + node/edge items
44
+ │ │ ├── palette.py searchable, categorized device palette
45
+ │ │ ├── inspector.py tabbed property inspector
46
+ │ │ ├── assistant.py in-app "Ask GINI" AI assistant panel
47
+ │ │ └── theme/
48
+ │ │ ├── tokens.py themeable design tokens (dark / light / brand)
49
+ │ │ ├── manager.py token → QSS generator + ThemeManager
50
+ │ │ └── icons.py license-safe flat SVG icon set, recolored per category
51
+ │ └── agent/
52
+ │ ├── api.py programmatic GiniAPI: build / inspect / explain / run
53
+ │ └── mcp_server.py MCP server exposing GiniAPI to external agents
54
+ └── tests/
55
+ ```
56
+
57
+ ## Run it
58
+
59
+ ```bash
60
+ cd frontend-ng
61
+ pip install -e .
62
+ gbuilder # or: python -m gini
63
+ ```
64
+
65
+ Headless smoke test (no display needed):
66
+
67
+ ```bash
68
+ QT_QPA_PLATFORM=offscreen python -m gini --selftest
69
+ ```
70
+
71
+ ## Status
72
+
73
+ Early scaffold (Phase 2 of the evolution plan). The UI shell, themeable design system, icon
74
+ system, the expanded device taxonomy, and the agent API/MCP scaffold are in place and runnable.
75
+ The legacy `.gsav`/compiler/protocol services are being ported behind `services/` next.
76
+
77
+ See `../GINI Project/GINI_Frontend_Evolution_Plan.md` for the full roadmap.