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
@@ -0,0 +1,184 @@
1
+ """Author a fragment — teacher mode.
2
+
3
+ The composer, built on what already exists: you build the winning arrangement on the real canvas,
4
+ this reads it into candidate objectives (by demonstration), you confirm/prune, optionally add a
5
+ difficulty **fork**, and finalize — which validates and writes the blessed YAML to the user content
6
+ layer (`~/.gini/content/fragments`). "Test" is the same Run/Check students use, so a fragment is
7
+ proven gradable by the real engine before it ships.
8
+
9
+ Deliberately a dialog over the live canvas rather than a new editor: the canvas IS the board, and the
10
+ mission engine IS the grader. We only add the authoring controls around them.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from PySide6.QtCore import Qt
15
+ from PySide6.QtWidgets import (
16
+ QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel, QLineEdit,
17
+ QListWidget, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QWidget,
18
+ )
19
+
20
+ from ..domain import authoring as _au
21
+
22
+
23
+ class AuthorDialog(QDialog):
24
+ def __init__(self, parent, ctx, *, author: str = "") -> None:
25
+ super().__init__(parent)
26
+ self.ctx = ctx
27
+ self._author = author
28
+ self._objs: list[dict] = [] # core objectives (dicts)
29
+ self._fork_objs: list[dict] = [] # objectives on the harder fork (optional)
30
+ self.setWindowTitle("Author a fragment")
31
+ self.setMinimumWidth(560)
32
+
33
+ root = QVBoxLayout(self)
34
+ root.addWidget(QLabel("<b>Author a fragment from what you've built on the canvas.</b> "
35
+ "Build the winning arrangement, read off the objectives, then finalize.",
36
+ wordWrap=True))
37
+
38
+ form = QFormLayout()
39
+ self.fid = QLineEdit(); self.fid.setPlaceholderText("a short id, e.g. private-db-vpc")
40
+ form.addRow("Fragment id", self.fid)
41
+ self.teaches = QLineEdit(); self.teaches.setPlaceholderText("concept key, e.g. vpc-networking")
42
+ form.addRow("Teaches", self.teaches)
43
+ self.summary = QLineEdit(); self.summary.setPlaceholderText("one line the student sees")
44
+ form.addRow("Summary", self.summary)
45
+ self.spirit = QLineEdit()
46
+ self.spirit.setPlaceholderText("mechanism-free goal (what 'done' means, not how)")
47
+ form.addRow("Spirit", self.spirit)
48
+ root.addLayout(form)
49
+
50
+ # --- core objectives (derived from the canvas) -----------------------
51
+ head = QHBoxLayout()
52
+ head.addWidget(QLabel("<b>Objectives</b> — derived from your canvas:"))
53
+ head.addStretch(1)
54
+ derive = QPushButton("⟲ Read from canvas")
55
+ derive.clicked.connect(self._derive)
56
+ head.addWidget(derive)
57
+ root.addLayout(head)
58
+ self.obj_list = QListWidget()
59
+ self.obj_list.setMaximumHeight(150)
60
+ root.addWidget(self.obj_list)
61
+ rm = QPushButton("Remove selected objective"); rm.clicked.connect(self._remove_obj)
62
+ root.addWidget(rm)
63
+
64
+ # --- fork (optional difficulty branch) -------------------------------
65
+ self.fork_on = QCheckBox("Add a harder fork (the difficulty knob — earns gold)")
66
+ self.fork_on.toggled.connect(self._toggle_fork)
67
+ root.addWidget(self.fork_on)
68
+ self._fork_box = QWidget(); fb = QVBoxLayout(self._fork_box); fb.setContentsMargins(16, 0, 0, 0)
69
+ frow = QHBoxLayout()
70
+ self.fork_label = QLineEdit(); self.fork_label.setPlaceholderText("what the harder way is")
71
+ frow.addWidget(self.fork_label, 1)
72
+ self.fork_kind = QComboBox(); self.fork_kind.addItems(["converge", "diverge"])
73
+ self.fork_kind.setToolTip("converge = a harder way to the SAME goal · diverge = a different path")
74
+ frow.addWidget(self.fork_kind)
75
+ fb.addLayout(frow)
76
+ fhead = QHBoxLayout()
77
+ fhead.addWidget(QLabel("Fork objectives — build the harder solution, then:"))
78
+ fhead.addStretch(1)
79
+ fderive = QPushButton("⟲ Read fork from canvas"); fderive.clicked.connect(self._derive_fork)
80
+ fhead.addWidget(fderive)
81
+ fb.addLayout(fhead)
82
+ self.fork_list = QListWidget(); self.fork_list.setMaximumHeight(100)
83
+ fb.addWidget(self.fork_list)
84
+ self._fork_box.setVisible(False)
85
+ root.addWidget(self._fork_box)
86
+
87
+ self.note = QLabel(""); self.note.setWordWrap(True)
88
+ root.addWidget(self.note)
89
+
90
+ bb = QDialogButtonBox()
91
+ self.test_btn = bb.addButton("Test on canvas", QDialogButtonBox.ActionRole)
92
+ self.save_btn = bb.addButton("Finalize & save", QDialogButtonBox.AcceptRole)
93
+ bb.addButton("Cancel", QDialogButtonBox.RejectRole)
94
+ self.test_btn.clicked.connect(self._test)
95
+ self.save_btn.clicked.connect(self._finalize)
96
+ bb.rejected.connect(self.reject)
97
+ root.addWidget(bb)
98
+
99
+ # -- objective derivation ------------------------------------------------ #
100
+ def _derive(self) -> None:
101
+ self._objs = _au.derive_objectives(self.ctx.topology)
102
+ self._render(self.obj_list, self._objs)
103
+ if not self._objs:
104
+ self._warn("The canvas is empty — place the winning arrangement first, then read it.")
105
+ else:
106
+ self._info(f"Read {len(self._objs)} objective(s). Prune any you don't want to grade.")
107
+
108
+ def _derive_fork(self) -> None:
109
+ # the fork's objectives are whatever is on the canvas NOW that isn't already a core objective
110
+ core_checks = {o["check"] for o in self._objs}
111
+ cand = [o for o in _au.derive_objectives(self.ctx.topology) if o["check"] not in core_checks]
112
+ for o in cand: # keep fork objective ids distinct from core
113
+ o["id"] = "fork-" + o["id"]
114
+ self._fork_objs = cand
115
+ self._render(self.fork_list, self._fork_objs)
116
+ self._info(f"Fork: {len(cand)} objective(s) beyond the core.")
117
+
118
+ def _render(self, widget: QListWidget, objs: list[dict]) -> None:
119
+ widget.clear()
120
+ for o in objs:
121
+ it = QListWidgetItem(f"L{o['level']} {o['say']} · {o['check']}")
122
+ it.setData(Qt.UserRole, o["id"])
123
+ widget.addItem(it)
124
+
125
+ def _remove_obj(self) -> None:
126
+ row = self.obj_list.currentRow()
127
+ if 0 <= row < len(self._objs):
128
+ del self._objs[row]
129
+ self._render(self.obj_list, self._objs)
130
+
131
+ def _toggle_fork(self, on: bool) -> None:
132
+ self._fork_box.setVisible(on)
133
+ self.adjustSize()
134
+
135
+ # -- build the dict ------------------------------------------------------ #
136
+ def _dict(self) -> dict | None:
137
+ fid = self.fid.text().strip()
138
+ if not fid:
139
+ self._warn("Give the fragment an id."); return None
140
+ if not self._objs:
141
+ self._warn("Read at least one objective from the canvas."); return None
142
+ forks = None
143
+ if self.fork_on.isChecked() and self._fork_objs:
144
+ forks = [{"id": "hard", "label": self.fork_label.text().strip(),
145
+ "difficulty": 2, "kind": self.fork_kind.currentText(),
146
+ "objectives": self._fork_objs}]
147
+ return _au.build_fragment_dict(
148
+ frag_id=fid, teaches=self.teaches.text().strip(), summary=self.summary.text().strip(),
149
+ spirit=self.spirit.text().strip(), objectives=self._objs, forks=forks,
150
+ author=self._author)
151
+
152
+ def _test(self) -> None:
153
+ """Validate structurally, then hand the teacher a real playtest: build the fragment, drop into
154
+ it as a mission, and use Run/Check. Here we just validate + confirm gradability; the caller
155
+ launches the live mission."""
156
+ d = self._dict()
157
+ if d is None:
158
+ return
159
+ problems = _au.validate_dict(d)
160
+ if problems:
161
+ self._warn("Not gradable yet: " + "; ".join(problems))
162
+ return
163
+ self._info("Valid + gradable. Finalize to save, or keep refining. "
164
+ "(Play it as a mission to prove it winnable against real Docker.)")
165
+
166
+ def _finalize(self) -> None:
167
+ d = self._dict()
168
+ if d is None:
169
+ return
170
+ try:
171
+ path = _au.save_fragment(d)
172
+ except ValueError as e:
173
+ self._warn(f"Can't save — not gradable: {e}")
174
+ return
175
+ from ..domain import fragments as _frag
176
+ _frag.reload() # pick the new fragment up immediately
177
+ QMessageBox.information(self, "Saved",
178
+ f"Fragment '{d['id']}' saved and loaded.\n\n{path}\n\n"
179
+ f"Upload it to the Teaching Center to compose experiments from it.")
180
+ self.accept()
181
+
182
+ # -- notes --------------------------------------------------------------- #
183
+ def _warn(self, msg): self.note.setText(msg); self.note.setStyleSheet("color:#d9534f;font-weight:600;")
184
+ def _info(self, msg): self.note.setText(msg); self.note.setStyleSheet("color:#3fb950;")
@@ -0,0 +1,247 @@
1
+ """Hardware → Set Up a Board: give a GINI32 board the lab Wi-Fi, over USB.
2
+
3
+ This is the only step in the whole GINI32 story that needs a cable, and a student
4
+ does it once per board. Everything the dialog writes is the minimum a board
5
+ cannot work out for itself; its address, gateway, hotspot and subnet all still
6
+ come from the canvas when it checks in.
7
+
8
+ The serial work happens on a worker thread. Talking to a board takes a couple of
9
+ seconds — it reboots when the port is opened — and freezing the window for that
10
+ long makes a working setup look like a hang.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from PySide6.QtCore import QObject, Qt, Signal
15
+ from PySide6.QtWidgets import (
16
+ QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel,
17
+ QLineEdit, QMessageBox, QPushButton, QVBoxLayout, QWidget,
18
+ )
19
+
20
+ from ..services import boardsetup as bs
21
+ from .worker_host import WorkerHost, _LIVE_THREADS # noqa: F401 (tests inspect it)
22
+
23
+
24
+ def _note(text: str) -> QLabel:
25
+ lab = QLabel(text)
26
+ lab.setWordWrap(True)
27
+ lab.setStyleSheet("color: palette(mid);")
28
+ return lab
29
+
30
+
31
+ class _Scan(QObject):
32
+ """Finds boards on a worker thread (opening a port resets and waits for it)."""
33
+ done = Signal(list, list) # [BoardInfo], [PortInfo]
34
+ failed = Signal(str)
35
+
36
+ def run(self) -> None:
37
+ try:
38
+ boards, others = bs.detect_boards()
39
+ self.done.emit(boards, others)
40
+ except Exception as e: # never let a USB oddity kill the UI
41
+ self.failed.emit(str(e))
42
+
43
+
44
+ class _Apply(QObject):
45
+ """Writes settings to one board, off the GUI thread."""
46
+ done = Signal(bool, str)
47
+
48
+ def __init__(self, port: str, ssid: str, password: str, board_id: str) -> None:
49
+ super().__init__()
50
+ self._args = (port, ssid, password, board_id)
51
+
52
+ def run(self) -> None:
53
+ port, ssid, password, board_id = self._args
54
+ try:
55
+ with bs.BoardConsole(port) as con:
56
+ if not con.wait_for_prompt():
57
+ self.done.emit(False, "the board never reached its prompt — unplug "
58
+ "it, plug it back in, and try again")
59
+ return
60
+ ok, msg = con.apply(ssid, password, board_id)
61
+ self.done.emit(ok, msg)
62
+ except Exception as e:
63
+ self.done.emit(False, str(e))
64
+
65
+
66
+ class BoardSetupDialog(WorkerHost, QDialog):
67
+ """Pick a plugged-in board, confirm the details, write them."""
68
+
69
+ def __init__(self, parent, settings, known_ids: list[str] | None = None) -> None:
70
+ super().__init__(parent)
71
+ self.setWindowTitle("Set Up a GINI32 Board")
72
+ self.setMinimumWidth(560)
73
+ self._settings = settings
74
+ self._known_ids = list(known_ids or [])
75
+ self._boards: list[bs.BoardInfo] = []
76
+ self._thread: QThread | None = None
77
+ self._worker: QObject | None = None # kept alive; see _run()
78
+ self._connections: list = [] # (signal, slot) pairs cut on close
79
+ self._alive = True # cleared on close: workers must stop touching us
80
+ self.applied_id = ""
81
+
82
+ root = QVBoxLayout(self)
83
+ root.addWidget(_note(
84
+ "Plug a board into this computer with a USB cable. This is the only step "
85
+ "that needs a cable — after it, the board works over Wi-Fi."))
86
+
87
+ form = QFormLayout()
88
+ row = QHBoxLayout()
89
+ self.board = QComboBox()
90
+ self.board.setMinimumWidth(320)
91
+ row.addWidget(self.board, 1)
92
+ self.rescan = QPushButton("Rescan")
93
+ self.rescan.clicked.connect(self.scan)
94
+ row.addWidget(self.rescan)
95
+ holder = QWidget()
96
+ holder.setLayout(row)
97
+ form.addRow("Board", holder)
98
+
99
+ self.ssid = QLineEdit(getattr(settings, "board_wifi_ssid", "") or "")
100
+ self.ssid.setPlaceholderText("the Wi-Fi this laptop is on — 2.4 GHz")
101
+ form.addRow("Lab Wi-Fi name", self.ssid)
102
+ self.password = QLineEdit(getattr(settings, "board_wifi_password", "") or "")
103
+ self.password.setEchoMode(QLineEdit.Password)
104
+ form.addRow("Lab Wi-Fi password", self.password)
105
+ self.board_id = QLineEdit()
106
+ self.board_id.setPlaceholderText("gini-1")
107
+ form.addRow("Name this board", self.board_id)
108
+ root.addLayout(form)
109
+
110
+ self.status = _note("")
111
+ root.addWidget(self.status)
112
+ root.addWidget(_note(
113
+ "The name you give the board is what you type into the GINI32 element's "
114
+ "BoardID on the canvas — pick something short you can read off a sticker."))
115
+
116
+ self.buttons = QDialogButtonBox(QDialogButtonBox.Cancel)
117
+ self.apply_btn = self.buttons.addButton("Set Up Board",
118
+ QDialogButtonBox.AcceptRole)
119
+ self.apply_btn.clicked.connect(self._apply)
120
+ self.buttons.rejected.connect(self.reject)
121
+ root.addWidget(self.buttons)
122
+
123
+ self.board.currentIndexChanged.connect(self._board_changed)
124
+ self.scan()
125
+
126
+ # ---------------------------------------------------------------- scanning
127
+
128
+ def scan(self) -> None:
129
+ self._busy(True, "Looking for boards…")
130
+ self.board.clear()
131
+ self._run(_Scan(), self._scanned)
132
+
133
+ def _worker_failed(self, message: str) -> None:
134
+ if self._alive:
135
+ self._busy(False, f"Could not look for boards: {message}")
136
+
137
+ def _scanned(self, boards: list, others: list) -> None:
138
+ if not self._alive: # the dialog was closed while we were scanning
139
+ return
140
+ self._boards = boards
141
+ self.board.clear()
142
+ for b in boards:
143
+ label = b.board_id or "(unnamed board)"
144
+ if b.configured:
145
+ label += f" — set up for '{b.ssid}'"
146
+ else:
147
+ label += " — not set up yet"
148
+ self.board.addItem(f"{label} [{b.port}]", b.port)
149
+
150
+ if boards:
151
+ self._busy(False, f"Found {len(boards)} board"
152
+ f"{'s' if len(boards) != 1 else ''}.")
153
+ elif others:
154
+ names = ", ".join(o.device for o in others[:3])
155
+ self._busy(False,
156
+ f"Serial devices found ({names}), but none answered as a GINI32 "
157
+ f"board. Is the gBridge firmware flashed on it?")
158
+ else:
159
+ self._busy(False,
160
+ "No board found. Check the cable is plugged in at both ends — "
161
+ "and that it is a DATA cable: many USB cables carry power only, "
162
+ "which looks exactly like a dead board.")
163
+ self._board_changed()
164
+
165
+ def _board_changed(self) -> None:
166
+ """Suggest a name that does not collide with one already in use."""
167
+ b = self._selected()
168
+ if b is None:
169
+ return
170
+ if b.board_id and b.board_id not in ("gini32-1",): # keep a real, chosen name
171
+ self.board_id.setText(b.board_id)
172
+ else:
173
+ taken = self._known_ids + [x.board_id for x in self._boards
174
+ if x is not b and x.board_id]
175
+ self.board_id.setText(bs.suggest_board_id(taken))
176
+ if b.ssid and not self.ssid.text().strip():
177
+ self.ssid.setText(b.ssid)
178
+
179
+ def _selected(self) -> bs.BoardInfo | None:
180
+ port = self.board.currentData()
181
+ return next((b for b in self._boards if b.port == port), None)
182
+
183
+ # ---------------------------------------------------------------- applying
184
+
185
+ def _apply(self) -> None:
186
+ b = self._selected()
187
+ if b is None:
188
+ QMessageBox.information(self, "No board selected",
189
+ "Plug a board in and press Rescan.")
190
+ return
191
+ ssid = self.ssid.text().strip()
192
+ pw = self.password.text()
193
+ bid = self.board_id.text().strip()
194
+ if not ssid:
195
+ QMessageBox.warning(self, "Wi-Fi name needed",
196
+ "Enter the name of the Wi-Fi network this laptop is on. "
197
+ "The board joins it to reach gBuilder.")
198
+ return
199
+ if not bid:
200
+ QMessageBox.warning(self, "Name needed",
201
+ "Give the board a short name — it is how the canvas "
202
+ "refers to it.")
203
+ return
204
+ self._busy(True, f"Setting up '{bid}'…")
205
+ self._run(_Apply(b.port, ssid, pw, bid), self._applied)
206
+
207
+ def _applied(self, ok: bool, msg: str) -> None:
208
+ if not self._alive:
209
+ return
210
+ self._busy(False, "")
211
+ if not ok:
212
+ QMessageBox.warning(self, "Setup failed", msg)
213
+ return
214
+ # Remember the lab Wi-Fi so the next board is a single click.
215
+ try:
216
+ self._settings.board_wifi_ssid = self.ssid.text().strip()
217
+ self._settings.board_wifi_password = self.password.text()
218
+ except Exception:
219
+ pass
220
+ self.applied_id = self.board_id.text().strip()
221
+ QMessageBox.information(
222
+ self, "Board is set up",
223
+ f"{msg}.\n\nThe board is restarting and will join the Wi-Fi on its own. "
224
+ f"You can unplug it now — it only needs power from here on.\n\n"
225
+ f"Use '{self.applied_id}' as the BoardID on the canvas.")
226
+ self.accept()
227
+
228
+ # ------------------------------------------------------------------- utils
229
+
230
+ # _run() and _detach() now come from WorkerHost — one copy of the threading
231
+ # rules, shared with the flash dialog. See ui/worker_host.py.
232
+
233
+ def closeEvent(self, event) -> None:
234
+ self._detach()
235
+ super().closeEvent(event)
236
+
237
+ def reject(self) -> None:
238
+ self._detach()
239
+ super().reject()
240
+
241
+ def _busy(self, busy: bool, message: str) -> None:
242
+ if not self._alive:
243
+ return
244
+ self.status.setText(message)
245
+ self.rescan.setEnabled(not busy)
246
+ self.apply_btn.setEnabled(not busy)
247
+ self.setCursor(Qt.WaitCursor if busy else Qt.ArrowCursor)
gini/ui/branding.py ADDED
@@ -0,0 +1,21 @@
1
+ """App branding — the GINI mascot used as the window / taskbar / dock icon."""
2
+ from __future__ import annotations
3
+
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+
7
+ from PySide6.QtGui import QIcon
8
+
9
+ ASSETS = Path(__file__).parent / "assets"
10
+ APP_ICON_PNG = ASSETS / "app_icon.png"
11
+ APP_ICON_ICNS = ASSETS / "app_icon.icns"
12
+
13
+
14
+ def icon_path() -> str:
15
+ return str(APP_ICON_PNG)
16
+
17
+
18
+ @lru_cache(maxsize=1)
19
+ def app_icon() -> QIcon:
20
+ """The GINI mascot as a QIcon (empty if the asset is missing — never crashes)."""
21
+ return QIcon(str(APP_ICON_PNG)) if APP_ICON_PNG.exists() else QIcon()