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/run_button.py ADDED
@@ -0,0 +1,183 @@
1
+ """One morphing circular power button that replaces the separate Run / Stop
2
+ toolbar actions.
3
+
4
+ States (driven by MainWindow from the real run lifecycle):
5
+
6
+ * ready — green disc, white ▶ glyph. Click launches the topology.
7
+ * booting — a progress ring fills clockwise to (containers up / total) as the
8
+ fabric comes online; a soft core pulses. Click cancels (stops).
9
+ * running — accent disc, white ■ stop glyph, a gentle green "alive" breathing
10
+ ring. Click stops.
11
+ * stopping — muted disc with an indeterminate rotating arc while it winds down.
12
+ * error — red disc, white ▶ (retry), a pulsing red halo.
13
+
14
+ The widget only *paints* state; MainWindow decides what a click means and calls
15
+ `set_state` / `set_progress` at the right lifecycle points.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import math
20
+
21
+ from PySide6.QtCore import QRectF, QSize, Qt, QTimer, Signal
22
+ from PySide6.QtGui import QColor, QPainter, QPen, QPolygonF, QRadialGradient
23
+ from PySide6.QtCore import QPointF
24
+ from PySide6.QtWidgets import QWidget
25
+
26
+ _ANIM_STATES = {"booting", "running", "stopping", "error"}
27
+
28
+
29
+ class RunButton(QWidget):
30
+ clicked = Signal() # user pressed the button (MainWindow decides run vs stop)
31
+
32
+ def __init__(self, theme, parent=None) -> None:
33
+ super().__init__(parent)
34
+ self.theme = theme
35
+ self._state = "ready"
36
+ self._frac = 0.0 # eased boot-progress fraction actually drawn
37
+ self._target = 0.0 # up / total
38
+ self._up = self._total = 0
39
+ self._phase = 0.0 # animation phase (pulse / spin)
40
+ self._hover = False
41
+ self.setFixedSize(40, 34)
42
+ self.setCursor(Qt.PointingHandCursor)
43
+ self._refresh_tip()
44
+ self._timer = QTimer(self)
45
+ self._timer.setInterval(33)
46
+ self._timer.timeout.connect(self._tick)
47
+
48
+ # -- state in --------------------------------------------------------------
49
+ def set_state(self, state: str) -> None:
50
+ if state not in ("ready", "booting", "running", "stopping", "error"):
51
+ return
52
+ self._state = state
53
+ if state == "ready":
54
+ self._target = self._frac = 0.0
55
+ self._up = self._total = 0
56
+ elif state == "running":
57
+ self._target = 1.0
58
+ if state in _ANIM_STATES and not self._timer.isActive():
59
+ self._timer.start()
60
+ elif state not in _ANIM_STATES and self._timer.isActive():
61
+ self._timer.stop(); self._phase = 0.0
62
+ self._refresh_tip()
63
+ self.update()
64
+
65
+ def set_progress(self, up: int, total: int) -> None:
66
+ self._up, self._total = int(up), int(total)
67
+ self._target = (up / total) if total else 0.0
68
+ self._refresh_tip()
69
+ self.update()
70
+
71
+ def state(self) -> str:
72
+ return self._state
73
+
74
+ def _refresh_tip(self) -> None:
75
+ tip = {
76
+ "ready": "Run — launch this topology on Docker",
77
+ "booting": (f"Starting… {self._up}/{self._total} containers up"
78
+ if self._total else "Starting the topology…"),
79
+ "running": "Running — click to stop",
80
+ "stopping": "Stopping…",
81
+ "error": "Run failed — click to try again",
82
+ }[self._state]
83
+ self.setToolTip(tip)
84
+
85
+ # -- animation -------------------------------------------------------------
86
+ def _tick(self) -> None:
87
+ self._phase = (self._phase + 0.12) % (math.tau)
88
+ # ease the drawn fraction toward the real target so the ring glides
89
+ self._frac += (self._target - self._frac) * 0.25
90
+ self.update()
91
+
92
+ # -- theme hook (called by MainWindow on theme change) ---------------------
93
+ def refresh_theme(self) -> None:
94
+ self.update()
95
+
96
+ # -- input -----------------------------------------------------------------
97
+ def enterEvent(self, _e) -> None:
98
+ self._hover = True; self.update()
99
+
100
+ def leaveEvent(self, _e) -> None:
101
+ self._hover = False; self.update()
102
+
103
+ def mouseReleaseEvent(self, e) -> None:
104
+ if e.button() == Qt.LeftButton and self.rect().contains(e.position().toPoint()):
105
+ self.clicked.emit()
106
+ super().mouseReleaseEvent(e)
107
+
108
+ def sizeHint(self) -> QSize:
109
+ return QSize(40, 34)
110
+
111
+ # -- paint -----------------------------------------------------------------
112
+ def _disc_color(self) -> QColor:
113
+ t = self.theme.theme
114
+ return {
115
+ "ready": QColor(t.success),
116
+ "booting": QColor(t.success).darker(155), # dim so the progress ring pops
117
+ "running": QColor(t.accent),
118
+ "stopping": QColor(t.muted),
119
+ "error": QColor(t.danger),
120
+ }[self._state]
121
+
122
+ def paintEvent(self, _e) -> None:
123
+ t = self.theme.theme
124
+ p = QPainter(self)
125
+ p.setRenderHint(QPainter.Antialiasing, True)
126
+ w, h = self.width(), self.height()
127
+ cx, cy = w / 2.0, h / 2.0
128
+ r = min(w, h) / 2.0 - 4.0
129
+ disc = self._disc_color()
130
+
131
+ # ambient halo: hover on any state, plus the pulsing error / alive glow
132
+ halo = 0.0
133
+ if self._state == "error":
134
+ halo = 0.55 + 0.45 * math.sin(self._phase * 2)
135
+ elif self._state == "running":
136
+ halo = 0.25 + 0.20 * math.sin(self._phase)
137
+ elif self._hover:
138
+ halo = 0.5
139
+ if halo > 0.01:
140
+ glow_col = QColor(t.success) if self._state == "running" else disc
141
+ grad = QRadialGradient(cx, cy, r + 6)
142
+ gc = QColor(glow_col); gc.setAlpha(int(120 * halo))
143
+ grad.setColorAt(0.55, gc)
144
+ grad.setColorAt(1.0, QColor(gc.red(), gc.green(), gc.blue(), 0))
145
+ p.setPen(Qt.NoPen); p.setBrush(grad)
146
+ p.drawEllipse(QRectF(cx - r - 6, cy - r - 6, 2 * (r + 6), 2 * (r + 6)))
147
+
148
+ # the disc
149
+ core = QColor(disc)
150
+ if self._state == "booting": # subtle breathing core
151
+ core = core.lighter(int(108 + 8 * math.sin(self._phase * 2)))
152
+ p.setPen(QPen(disc.darker(135), 1.2))
153
+ p.setBrush(core)
154
+ p.drawEllipse(QRectF(cx - r, cy - r, 2 * r, 2 * r))
155
+
156
+ # rings
157
+ ring_rect = QRectF(cx - r + 3, cy - r + 3, 2 * (r - 3), 2 * (r - 3))
158
+ if self._state == "booting":
159
+ track = QPen(QColor(255, 255, 255, 85), 3.6); track.setCapStyle(Qt.RoundCap)
160
+ p.setPen(track); p.setBrush(Qt.NoBrush)
161
+ p.drawArc(ring_rect, 0, 360 * 16)
162
+ arc = QPen(QColor(255, 255, 255, 255), 3.6); arc.setCapStyle(Qt.RoundCap)
163
+ p.setPen(arc)
164
+ p.drawArc(ring_rect, 90 * 16, -int(self._frac * 360) * 16)
165
+ elif self._state == "stopping": # indeterminate spinner
166
+ arc = QPen(QColor(255, 255, 255, 210), 3); arc.setCapStyle(Qt.RoundCap)
167
+ p.setPen(arc); p.setBrush(Qt.NoBrush)
168
+ start = int(-math.degrees(self._phase * 2.4)) * 16
169
+ p.drawArc(ring_rect, start, 110 * 16)
170
+
171
+ # glyph
172
+ p.setPen(Qt.NoPen); p.setBrush(QColor("#ffffff"))
173
+ if self._state in ("running", "stopping"):
174
+ s = r * 0.62
175
+ p.drawRoundedRect(QRectF(cx - s / 2, cy - s / 2, s, s), 2, 2)
176
+ elif self._state in ("ready", "error"):
177
+ tri = QPolygonF([
178
+ QPointF(cx - r * 0.26, cy - r * 0.42),
179
+ QPointF(cx - r * 0.26, cy + r * 0.42),
180
+ QPointF(cx + r * 0.46, cy),
181
+ ])
182
+ p.drawPolygon(tri)
183
+ p.end()
@@ -0,0 +1,234 @@
1
+ """Settings dialog — organized into tabs (Appearance, Networking, GINI AI, Naming,
2
+ Pricing, Help). Edits the live Settings; the caller applies + persists the result."""
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtGui import QDoubleValidator
6
+ from PySide6.QtWidgets import (
7
+ QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QLabel, QLineEdit,
8
+ QTabWidget, QVBoxLayout, QWidget,
9
+ )
10
+
11
+ _THEMES = ("Dark", "Light", "GINI Brand", "High Contrast", "Sand", "Blue", "Green")
12
+ _TEXT_SIZES = ("Normal", "Large", "Extra Large")
13
+
14
+
15
+ def _page(tabs: QTabWidget, title: str) -> QFormLayout:
16
+ """Add a tab whose body is a QFormLayout, and return that form."""
17
+ w = QWidget()
18
+ lay = QVBoxLayout(w)
19
+ form = QFormLayout()
20
+ lay.addLayout(form)
21
+ lay.addStretch(1)
22
+ tabs.addTab(w, title)
23
+ return form
24
+
25
+
26
+ def _note(text: str) -> QLabel:
27
+ lbl = QLabel(text)
28
+ lbl.setObjectName("Faint")
29
+ lbl.setWordWrap(True)
30
+ return lbl
31
+
32
+
33
+ class SettingsDialog(QDialog):
34
+ """Edits the live Settings; the caller applies + persists the returned values."""
35
+
36
+ def __init__(self, parent, settings) -> None:
37
+ super().__init__(parent)
38
+ self.setWindowTitle("GINI Settings")
39
+ self.setMinimumWidth(480)
40
+ root = QVBoxLayout(self)
41
+ tabs = QTabWidget()
42
+ root.addWidget(tabs)
43
+
44
+ # --- Appearance --------------------------------------------------- #
45
+ appf = _page(tabs, "Appearance")
46
+ self.theme = QComboBox(); self.theme.addItems(_THEMES)
47
+ # Select the CURRENT theme by matching the list itself. This used to use a hand-written
48
+ # {name: index} map that only covered the first four themes — so Sand/Blue/Green fell through
49
+ # to index 0 and the dialog silently reported "Dark". Saving Settings for ANY reason (e.g.
50
+ # editing your Teaching Center credentials) then wrote Dark back and stole your theme.
51
+ # Deriving the index from _THEMES means the two can never drift apart again.
52
+ cur = (settings.theme or "Dark").strip().lower()
53
+ cur = {"brand": "gini brand"}.get(cur, cur) # tolerate the old short alias
54
+ names = [t.lower() for t in _THEMES]
55
+ self.theme.setCurrentIndex(names.index(cur) if cur in names else 0)
56
+ appf.addRow("Theme", self.theme)
57
+ self.text_size = QComboBox(); self.text_size.addItems(_TEXT_SIZES)
58
+ cur_sz = (getattr(settings, "text_size", "Normal") or "Normal").strip().lower()
59
+ sizes = [s.lower() for s in _TEXT_SIZES]
60
+ self.text_size.setCurrentIndex(sizes.index(cur_sz) if cur_sz in sizes else 0)
61
+ appf.addRow("Text size", self.text_size)
62
+ appf.addRow("", _note("Scales the whole interface — handy on large or high-resolution "
63
+ "displays. Applies instantly."))
64
+ self.reduced = QCheckBox("Reduce motion / animations")
65
+ self.reduced.setChecked(settings.reduced_motion)
66
+ appf.addRow("", self.reduced)
67
+
68
+ # --- Networking --------------------------------------------------- #
69
+ netf = _page(tabs, "Networking")
70
+ self.auto_internet = QCheckBox("Containers get internet automatically (default eth)")
71
+ self.auto_internet.setChecked(settings.auto_internet)
72
+ netf.addRow("", self.auto_internet)
73
+ netf.addRow("", _note("Off = faithful mode: a machine reaches the internet only via "
74
+ "a drawn Internet element, routed through your network."))
75
+ self.autobuild = QCheckBox("Build missing lab images automatically")
76
+ self.autobuild.setChecked(bool(getattr(settings, "autobuild_images", False)))
77
+ netf.addRow("", self.autobuild)
78
+ netf.addRow("", _note("The gRouter and SDN controller run from images built on this machine. "
79
+ "With this on, GINI builds one the first time you Run without it, "
80
+ "instead of printing a docker build command and stopping. The first "
81
+ "build takes a couple of minutes; after that it's instant."))
82
+
83
+ # --- GINI AI ------------------------------------------------------ #
84
+ aif = _page(tabs, "GINI AI")
85
+ self.llm_enabled = QCheckBox("Use a local LLM for open-ended questions")
86
+ self.llm_enabled.setChecked(settings.llm_enabled)
87
+ aif.addRow("", self.llm_enabled)
88
+ self.llm_url = QLineEdit(settings.llm_url)
89
+ self.llm_url.setPlaceholderText("http://localhost:11434")
90
+ aif.addRow("Server (Ollama)", self.llm_url)
91
+ self.llm_model = QLineEdit(settings.llm_model)
92
+ self.llm_model.setPlaceholderText("llama3.1 / gemma / qwen …")
93
+ aif.addRow("Model", self.llm_model)
94
+ # editing the server/model is a clear intent to use the LLM — auto-tick the box
95
+ self.llm_url.textEdited.connect(lambda *_: self.llm_enabled.setChecked(True))
96
+ self.llm_model.textEdited.connect(lambda *_: self.llm_enabled.setChecked(True))
97
+ self.llm_think = QCheckBox("Reasoning ('thinking') model")
98
+ self.llm_think.setChecked(settings.llm_think)
99
+ aif.addRow("", self.llm_think)
100
+ aif.addRow("", _note("Without an LLM, GINI still builds, explains, traces paths, "
101
+ "and runs Wizard recipes."))
102
+
103
+ # --- Naming ------------------------------------------------------- #
104
+ nf = _page(tabs, "Naming")
105
+ from ..domain.devices import default_prefix
106
+ self._prefix_edits = {}
107
+ for key, label in (("host", "Machine"), ("router", "Router"), ("switch", "Switch"),
108
+ ("hub", "Hub"), ("instance", "Instance"), ("container", "Container")):
109
+ cur = settings.name_prefixes.get(key, default_prefix(key))
110
+ edit = QLineEdit(cur); edit.setPlaceholderText(default_prefix(key))
111
+ self._prefix_edits[key] = edit
112
+ nf.addRow(label, edit)
113
+ nf.addRow("", _note("e.g. set Machine to “Mach_” for Mach_1, Mach_2, …"))
114
+
115
+ # --- Pricing ------------------------------------------------------ #
116
+ from ..domain.pricing import rate_of
117
+ prf = _page(tabs, "Pricing")
118
+ self._price_edits = {}
119
+ for key, label in (("instance", "Instance (VM)"), ("container", "Container"),
120
+ ("host", "Machine"), ("router", "Router"), ("switch", "Switch"),
121
+ ("database", "Database"), ("object_store", "Object store"),
122
+ ("dashboard", "Dashboards (Grafana)")):
123
+ edit = QLineEdit(f"{rate_of(key, settings.prices):g}")
124
+ edit.setValidator(QDoubleValidator(0.0, 100000.0, 2, edit))
125
+ edit.setPlaceholderText(f"{rate_of(key):g}")
126
+ self._price_edits[key] = edit
127
+ prf.addRow(label, edit)
128
+ prf.addRow("", _note("Toy “cloud bill” shown live in the dashboard while a lab runs. "
129
+ "Blank = use the default rate."))
130
+
131
+ # --- Teaching Center ---------------------------------------------- #
132
+ tcf = _page(tabs, "Teaching Center")
133
+ self.tc_url = QLineEdit(settings.tc_url)
134
+ self.tc_url.setPlaceholderText("http://localhost:8080")
135
+ tcf.addRow("Course server", self.tc_url)
136
+ self.tc_course = QLineEdit(settings.tc_course)
137
+ self.tc_course.setPlaceholderText("cs4480-fall26")
138
+ tcf.addRow("Course", self.tc_course)
139
+ self.tc_student = QLineEdit(settings.tc_student)
140
+ self.tc_student.setPlaceholderText("your username — e.g. ravi")
141
+ tcf.addRow("Username", self.tc_student)
142
+ self.tc_token = QLineEdit(settings.tc_token)
143
+ self.tc_token.setPlaceholderText("one-time token from your instructor")
144
+ self.tc_token.setEchoMode(QLineEdit.Password)
145
+ tcf.addRow("Enrolment token", self.tc_token)
146
+ self.tc_insecure = QCheckBox("Allow insecure (plain HTTP) connection")
147
+ self.tc_insecure.setChecked(bool(getattr(settings, "tc_allow_insecure", False)))
148
+ tcf.addRow("", self.tc_insecure)
149
+ tcf.addRow("", _note("Your password is never stored — signing in exchanges it for a session. "
150
+ "The enrolment token is used ONCE, to claim your account.\n\n"
151
+ "GINI refuses to send a password over plain HTTP to a remote server: on "
152
+ "shared wifi anyone could read it. Tick the box above only for a demo on "
153
+ "a network you trust (localhost is always allowed).\n\n"
154
+ "Leave the server blank to work offline — Missions then offers the local "
155
+ "practice catalog."))
156
+
157
+ # --- Hardware (GINI32 boards) ------------------------------------- #
158
+ # The one thing a board cannot discover for itself. It is the same network for
159
+ # every board in a class, so it is entered here once and then written to each
160
+ # board over USB by Hardware → Set Up a Board.
161
+ hwf = _page(tabs, "Hardware")
162
+ self.board_ssid = QLineEdit(getattr(settings, "board_wifi_ssid", "") or "")
163
+ self.board_ssid.setPlaceholderText("the Wi-Fi this laptop is on — 2.4 GHz")
164
+ hwf.addRow("Lab Wi-Fi name", self.board_ssid)
165
+ self.board_pw = QLineEdit(getattr(settings, "board_wifi_password", "") or "")
166
+ self.board_pw.setEchoMode(QLineEdit.Password)
167
+ hwf.addRow("Lab Wi-Fi password", self.board_pw)
168
+ hwf.addRow("", _note(
169
+ "A GINI32 board joins this network to reach gBuilder, so it must be the SAME "
170
+ "network this laptop is on. ESP32 radios are 2.4 GHz only — a 5 GHz-only "
171
+ "network will never be found.\n\n"
172
+ "Set boards up from Hardware → Set Up a Board with the board plugged in over "
173
+ "USB. That is the only step that needs a cable; everything afterwards is "
174
+ "wireless.\n\n"
175
+ "This password is stored in the config file so boards can be set up without "
176
+ "retyping it. Use the shared lab network here, not a personal account."))
177
+
178
+ # --- Help & Tour -------------------------------------------------- #
179
+ hf = _page(tabs, "Help")
180
+ self.show_help = QCheckBox("Show the feature tour (Cue Cards) at launch")
181
+ self.show_help.setChecked(settings.show_help_on_launch)
182
+ hf.addRow("", self.show_help)
183
+ hf.addRow("", _note("Reopen the tour any time from Help → Feature Tour."))
184
+
185
+ root.addWidget(_note("Saved to ~/.gini/config.json."))
186
+ bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
187
+ bb.accepted.connect(self.accept)
188
+ bb.rejected.connect(self.reject)
189
+ root.addWidget(bb)
190
+
191
+ def values(self) -> dict:
192
+ from ..domain.devices import default_prefix
193
+ from ..domain.pricing import DEFAULT_RATES
194
+ prefixes = {}
195
+ for key, edit in self._prefix_edits.items():
196
+ p = edit.text().strip()
197
+ if p and p != default_prefix(key): # only store real overrides
198
+ prefixes[key] = p
199
+ prices = {}
200
+ for key, edit in self._price_edits.items():
201
+ txt = edit.text().strip()
202
+ if not txt:
203
+ continue
204
+ try:
205
+ val = round(float(txt), 2)
206
+ except ValueError:
207
+ continue
208
+ if val != DEFAULT_RATES.get(key): # only store real overrides
209
+ prices[key] = val
210
+ return {
211
+ "theme": self.theme.currentText(),
212
+ "text_size": self.text_size.currentText(),
213
+ "reduced_motion": self.reduced.isChecked(),
214
+ "auto_internet": self.auto_internet.isChecked(),
215
+ "autobuild_images": self.autobuild.isChecked(),
216
+ "llm_enabled": self.llm_enabled.isChecked(),
217
+ "llm_url": self.llm_url.text().strip() or "http://localhost:11434",
218
+ "llm_model": self.llm_model.text().strip() or "llama3.1",
219
+ "llm_think": self.llm_think.isChecked(),
220
+ "name_prefixes": prefixes,
221
+ "prices": prices, # was computed but dropped -> KeyError
222
+ "show_help_on_launch": self.show_help.isChecked(),
223
+ "tc_url": self.tc_url.text().strip(),
224
+ "tc_course": self.tc_course.text().strip(),
225
+ "tc_student": self.tc_student.text().strip(),
226
+ "tc_token": self.tc_token.text().strip(),
227
+ "tc_allow_insecure": self.tc_insecure.isChecked(),
228
+ # GINI32: the lab Wi-Fi written to boards over USB. Not stripped of case —
229
+ # SSIDs are case-sensitive — but surrounding whitespace is, because a name
230
+ # pasted from a slide routinely carries a trailing space and the board would
231
+ # then hunt forever for a network that does not exist.
232
+ "board_wifi_ssid": self.board_ssid.text().strip(),
233
+ "board_wifi_password": self.board_pw.text(),
234
+ }
@@ -0,0 +1,111 @@
1
+ """Sign in to the Teaching Center.
2
+
3
+ Two shapes, because a student's first sign-in is a different act from their tenth:
4
+
5
+ * **First time (claim).** The teacher enrolled you and handed you an enrolment token. You supply it
6
+ once, choose a password, and the token is spent. This is what stops a classmate from claiming
7
+ your identity: student ids are guessable, so "first password wins" would be an open door.
8
+ * **Every time after.** Student id + password.
9
+
10
+ The password is used once, to get a session token, and is never stored. If the course server isn't
11
+ HTTPS and isn't localhost, the client REFUSES to send it (see `teaching_center.refuse_plaintext_
12
+ password`) — and we surface that refusal as an explanation, not a stack trace.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from PySide6.QtCore import Qt
17
+ from PySide6.QtWidgets import (
18
+ QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QLabel, QLineEdit, QVBoxLayout,
19
+ )
20
+
21
+
22
+ class SignInDialog(QDialog):
23
+ def __init__(self, parent, settings, *, first_time: bool = False) -> None:
24
+ super().__init__(parent)
25
+ self.setWindowTitle("Sign in to your course")
26
+ self.setMinimumWidth(420)
27
+ self._settings = settings
28
+ root = QVBoxLayout(self)
29
+
30
+ head = QLabel(f"<b>{settings.tc_course or 'your course'}</b> · {settings.tc_url}")
31
+ head.setTextFormat(Qt.RichText)
32
+ root.addWidget(head)
33
+
34
+ form = QFormLayout()
35
+ self.student = QLineEdit(settings.tc_student)
36
+ self.student.setPlaceholderText("your username — e.g. ravi")
37
+ self.student.setToolTip("The username your instructor gave you (not your school ID).")
38
+ form.addRow("Username", self.student)
39
+
40
+ self.first = QCheckBox("First time — I have an enrolment token")
41
+ self.first.setChecked(bool(first_time))
42
+ form.addRow("", self.first)
43
+
44
+ self.enrol = QLineEdit(settings.tc_token)
45
+ self.enrol.setPlaceholderText("the token your instructor gave you")
46
+ form.addRow("Enrolment token", self.enrol)
47
+
48
+ self.password = QLineEdit()
49
+ self.password.setEchoMode(QLineEdit.Password)
50
+ self.password.setPlaceholderText("at least 8 characters")
51
+ form.addRow("Password", self.password)
52
+
53
+ self.confirm = QLineEdit()
54
+ self.confirm.setEchoMode(QLineEdit.Password)
55
+ form.addRow("Confirm password", self.confirm)
56
+ root.addLayout(form)
57
+
58
+ self.note = QLabel("")
59
+ self.note.setWordWrap(True)
60
+ self.note.setObjectName("Faint")
61
+ root.addWidget(self.note)
62
+
63
+ self.bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
64
+ self.bb.button(QDialogButtonBox.Ok).setText("Sign in")
65
+ self.bb.accepted.connect(self._accept)
66
+ self.bb.rejected.connect(self.reject)
67
+ root.addWidget(self.bb)
68
+
69
+ self.first.toggled.connect(self._sync)
70
+ self._sync()
71
+
72
+ def _sync(self) -> None:
73
+ """The claim fields only exist for a claim. A returning student shouldn't be asked to confirm
74
+ a password they already have."""
75
+ claiming = self.first.isChecked()
76
+ for w in (self.enrol, self.confirm):
77
+ w.setVisible(claiming)
78
+ lbl = self.layout().itemAt(1).layout().labelForField(w)
79
+ if lbl is not None:
80
+ lbl.setVisible(claiming)
81
+ self.bb.button(QDialogButtonBox.Ok).setText("Set password & sign in" if claiming
82
+ else "Sign in")
83
+ self.note.setText(
84
+ "Your instructor gave you a one-time enrolment token. You'll choose your own password "
85
+ "now; the token is then spent." if claiming else
86
+ "Signing in stores a session on this machine — never your password.")
87
+ self.adjustSize()
88
+
89
+ def _error(self, msg: str) -> None:
90
+ self.note.setText(msg)
91
+ self.note.setObjectName("")
92
+ self.note.setStyleSheet("color:#d9534f; font-weight:600;")
93
+
94
+ def _accept(self) -> None:
95
+ if not self.student.text().strip():
96
+ return self._error("Enter your student id.")
97
+ pw = self.password.text()
98
+ if len(pw) < 8:
99
+ return self._error("Your password must be at least 8 characters.")
100
+ if self.first.isChecked():
101
+ if not self.enrol.text().strip():
102
+ return self._error("Enter the enrolment token your instructor gave you.")
103
+ if pw != self.confirm.text():
104
+ return self._error("The two passwords don't match.")
105
+ self.accept()
106
+
107
+ def values(self) -> dict:
108
+ return {"student": self.student.text().strip(),
109
+ "password": self.password.text(),
110
+ "claim": self.first.isChecked(),
111
+ "enrolment_token": self.enrol.text().strip()}