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,227 @@
1
+ """Four toolbar pills for GINI: the current MODE, the AI MODEL, the ACTIVITY, and YOU.
2
+
3
+ * Mode pill — which interaction mode is active (Chat / Explain / Wizard / Coach),
4
+ coloured per mode.
5
+ * Model pill — the connected local model's name + status (green = reachable, amber =
6
+ set but not responding, grey = none). Click it to open Settings → LLM.
7
+ * Activity pill — a rotating spinner while the assistant is thinking, else a steady Idle.
8
+ * User pill — your Teaching Center enrolment: who you're signed in as, whether the course
9
+ server is reachable, and **how many assigned missions are still due** (0 is a
10
+ perfectly good answer, and worth showing — "nothing due" is information).
11
+ gBuilder is fully usable signed OUT, so the signed-out state is calm and grey,
12
+ not an error. Click it to sign in (Settings) or to jump to your assigned work.
13
+
14
+ One widget paints them all so the toolbar stays tidy; the Model and User pills are click targets.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from PySide6.QtCore import QRectF, QSize, Qt, QTimer, Signal
19
+ from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
20
+ from PySide6.QtWidgets import QWidget
21
+
22
+ _GAP = 8
23
+
24
+
25
+ class ModeIndicator(QWidget):
26
+ model_clicked = Signal() # the Model pill was clicked (open Settings → LLM)
27
+ user_clicked = Signal() # the User pill was clicked (sign in, or go to my missions)
28
+
29
+ def __init__(self, theme, parent=None) -> None:
30
+ super().__init__(parent)
31
+ self.theme = theme
32
+ self._mode = "Chat"
33
+ self._busy = False
34
+ self._angle = 0
35
+ self._model_name = "" # "" = no model connected
36
+ self._model_ok = False # reachable?
37
+ self._student = "" # "" = not enrolled in any course
38
+ self._tc_ok = False # course server reachable?
39
+ self._is_teacher = False # signed in with a teacher role?
40
+ self._due = 0 # assigned missions not yet completed
41
+ self.setFixedHeight(26)
42
+ self.setMouseTracking(True)
43
+ self._timer = QTimer(self)
44
+ self._timer.setInterval(55)
45
+ self._timer.timeout.connect(self._spin)
46
+
47
+ # -- state from the assistant / main window ----------------------------- #
48
+ def set_status(self, label: str, busy: bool) -> None:
49
+ self._mode = (label or "Chat").replace(" mode", "") or "Chat"
50
+ self._busy = busy
51
+ if busy and not self._timer.isActive():
52
+ self._timer.start()
53
+ elif not busy and self._timer.isActive():
54
+ self._timer.stop(); self._angle = 0
55
+ self.updateGeometry(); self.update()
56
+
57
+ def set_model(self, name: str, connected: bool) -> None:
58
+ """name='' → 'no model'; name + connected → green; name + not connected → amber."""
59
+ self._model_name = name or ""
60
+ self._model_ok = bool(connected)
61
+ self.setToolTip("Local model connected — Wizard, Coach and open-ended Q&A are on."
62
+ if (self._model_name and self._model_ok) else
63
+ f"'{self._model_name}' is set but not responding — click to check Settings."
64
+ if self._model_name else
65
+ "No local model — click to connect one (Settings → LLM). Explain and "
66
+ "build commands still work without it.")
67
+ self.updateGeometry(); self.update()
68
+
69
+ def set_enrolment(self, student: str, online: bool, due: int = 0) -> None:
70
+ """student='' → signed out (grey, and that is FINE); signed in + reachable → green with the
71
+ count of missions still due; signed in + unreachable → amber. `due < 0` = a TEACHER: no
72
+ assignments-due notion applies, so the pill shows the role instead of a count."""
73
+ self._student = student or ""
74
+ self._tc_ok = bool(online)
75
+ self._is_teacher = int(due) < 0
76
+ self._due = max(0, int(due))
77
+ if not self._student:
78
+ tip = ("Not signed in to a course. gBuilder works fully without one — click to enrol "
79
+ "in a Teaching Center (Settings → Teaching Center).")
80
+ elif self._is_teacher:
81
+ tip = f"Signed in as {self._student} (teacher). Click for teacher tools."
82
+ elif not self._tc_ok:
83
+ tip = (f"Signed in as {self._student}, but the course server isn't reachable. "
84
+ f"Showing your cached assignments; results will sync when it's back.")
85
+ elif self._due:
86
+ tip = (f"Signed in as {self._student} — {self._due} assigned mission"
87
+ f"{'s' if self._due != 1 else ''} still to do. Click to open them.")
88
+ else:
89
+ tip = f"Signed in as {self._student} — nothing due. Click to browse missions."
90
+ self.setToolTip(tip)
91
+ self.updateGeometry(); self.update()
92
+
93
+ def _user_text(self) -> str:
94
+ if not self._student:
95
+ return "sign in"
96
+ if getattr(self, "_is_teacher", False):
97
+ return f"{self._student} · teacher"
98
+ if not self._tc_ok:
99
+ return f"{self._student} · offline"
100
+ return f"{self._student} · {self._due} due" if self._due else f"{self._student} · clear"
101
+
102
+ def _user_color(self) -> QColor:
103
+ t = self.theme.theme
104
+ if not self._student:
105
+ return QColor(t.muted) # signed out is not an error state
106
+ if not self._tc_ok:
107
+ return QColor(t.warning)
108
+ return QColor(t.accent) if self._due else QColor(t.success)
109
+
110
+ def _spin(self) -> None:
111
+ self._angle = (self._angle + 24) % 360
112
+ self.update()
113
+
114
+ # -- geometry ----------------------------------------------------------- #
115
+ def _font(self) -> QFont:
116
+ from .theme.manager import sp
117
+ f = QFont(); f.setPointSize(sp(9)); f.setBold(True)
118
+ return f
119
+
120
+ def _mode_color(self) -> QColor:
121
+ t = self.theme.theme
122
+ return {"Explain": QColor(t.accent), "Wizard": QColor(t.accent2),
123
+ "Coach": QColor(t.accent2), "Tutor": QColor(t.success)}.get(
124
+ self._mode, QColor(t.muted))
125
+
126
+ def _model_color(self) -> QColor:
127
+ t = self.theme.theme
128
+ if not self._model_name:
129
+ return QColor(t.muted)
130
+ return QColor(t.success) if self._model_ok else QColor(t.warning)
131
+
132
+ def _pills(self):
133
+ """(kind, text, colour, width) for each pill, left to right."""
134
+ fm = QFontMetrics(self._font())
135
+ t = self.theme.theme
136
+
137
+ def w(text):
138
+ return 30 + fm.horizontalAdvance(text)
139
+
140
+ model_txt = self._model_name or "no model"
141
+ act_txt = "Thinking…" if self._busy else "Idle"
142
+ act_col = QColor(t.warning) if self._busy else QColor(t.muted)
143
+ user_txt = self._user_text()
144
+ return [
145
+ ("mode", self._mode, self._mode_color(), w(self._mode)),
146
+ ("model", model_txt, self._model_color(), w(model_txt)),
147
+ ("activity", act_txt, act_col, w(act_txt)),
148
+ ("user", user_txt, self._user_color(), w(user_txt)),
149
+ ]
150
+
151
+ def _ranges(self) -> dict[str, tuple[float, float]]:
152
+ """Each pill's x-span. Derived from the layout, NOT recorded during paint — otherwise the
153
+ click targets don't exist until the widget has been painted once."""
154
+ out: dict[str, tuple[float, float]] = {}
155
+ x = 1.5
156
+ for kind, _t, _c, w in self._pills():
157
+ out[kind] = (x, x + w)
158
+ x += w + _GAP
159
+ return out
160
+
161
+ def sizeHint(self) -> QSize:
162
+ total = sum(p[3] for p in self._pills()) + _GAP * (len(self._pills()) - 1) + 3
163
+ return QSize(int(total), 26)
164
+
165
+ def minimumSizeHint(self) -> QSize:
166
+ return self.sizeHint()
167
+
168
+ # -- paint -------------------------------------------------------------- #
169
+ def _pill_bg(self, p: QPainter, x: float, w: float, col: QColor) -> None:
170
+ bg = QColor(col); bg.setAlpha(38)
171
+ p.setBrush(bg); p.setPen(QPen(col, 1.4))
172
+ p.drawRoundedRect(QRectF(x, 1.5, w, self.height() - 3), 12, 12)
173
+
174
+ def paintEvent(self, _e) -> None:
175
+ p = QPainter(self)
176
+ p.setRenderHint(QPainter.Antialiasing, True)
177
+ p.setFont(self._font())
178
+ h = self.height()
179
+ x = 1.5
180
+ for kind, text, col, w in self._pills():
181
+ self._pill_bg(p, x, w, col)
182
+ if kind == "activity" and self._busy:
183
+ p.save(); p.translate(x + 13, h / 2.0); p.rotate(self._angle)
184
+ pen = QPen(col, 2.4); pen.setCapStyle(Qt.RoundCap); p.setPen(pen)
185
+ p.drawArc(QRectF(-6, -6, 12, 12), 0, 290 * 16); p.restore()
186
+ elif kind == "user":
187
+ self._person(p, x + 13, h / 2.0, col) # a person, not a status dot
188
+ else:
189
+ p.setBrush(col); p.setPen(Qt.NoPen)
190
+ p.drawEllipse(QRectF(x + 9, h / 2.0 - 4, 8, 8))
191
+ p.setPen(col)
192
+ p.drawText(QRectF(x + 22, 0, w - 20, h), Qt.AlignVCenter | Qt.AlignLeft, text)
193
+ x += w + _GAP
194
+ p.end()
195
+
196
+ def _person(self, p: QPainter, cx: float, cy: float, col: QColor) -> None:
197
+ """A little head-and-shoulders — the pill reads as YOU at a glance, not as another status
198
+ light. Hollow when signed out; filled once you're enrolled."""
199
+ p.save()
200
+ signed_in = bool(self._student)
201
+ p.setPen(QPen(col, 1.4))
202
+ p.setBrush(col if signed_in else Qt.NoBrush)
203
+ p.drawEllipse(QRectF(cx - 3.2, cy - 6.0, 6.4, 6.4)) # head
204
+ p.drawChord(QRectF(cx - 5.4, cy - 1.4, 10.8, 9.6), 0, 180 * 16) # shoulders
205
+ p.restore()
206
+
207
+ # -- the Model and User pills are click targets -------------------------- #
208
+ def _hit(self, x: float) -> str:
209
+ for kind in ("model", "user"):
210
+ x0, x1 = self._ranges().get(kind, (0.0, 0.0))
211
+ if x0 <= x <= x1:
212
+ return kind
213
+ return ""
214
+
215
+ def mousePressEvent(self, e) -> None:
216
+ hit = self._hit(e.position().x())
217
+ if hit == "model":
218
+ self.model_clicked.emit()
219
+ elif hit == "user":
220
+ self.user_clicked.emit()
221
+ else:
222
+ super().mousePressEvent(e)
223
+
224
+ def mouseMoveEvent(self, e) -> None:
225
+ self.setCursor(Qt.PointingHandCursor if self._hit(e.position().x())
226
+ else Qt.ArrowCursor)
227
+ super().mouseMoveEvent(e)
gini/ui/palette.py ADDED
@@ -0,0 +1,112 @@
1
+ """Searchable, categorized device palette. Items drag onto the canvas."""
2
+ from __future__ import annotations
3
+
4
+ from PySide6.QtCore import QMimeData, QSize, Qt, Signal
5
+ from PySide6.QtGui import QDrag
6
+ from PySide6.QtWidgets import (
7
+ QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
8
+ )
9
+
10
+ from ..domain import by_category
11
+ from .canvas import MIME
12
+ from .theme import ThemeManager, icons
13
+
14
+ KEY_ROLE = Qt.UserRole + 1
15
+
16
+
17
+ class _Tree(QTreeWidget):
18
+ def __init__(self) -> None:
19
+ super().__init__()
20
+ self.setHeaderHidden(True)
21
+ self.setIndentation(10)
22
+ self.setRootIsDecorated(True)
23
+ self.setDragEnabled(True)
24
+ self.setIconSize(QSize(22, 22))
25
+ self.setExpandsOnDoubleClick(False)
26
+
27
+ def startDrag(self, actions) -> None:
28
+ item = self.currentItem()
29
+ if item is None:
30
+ return
31
+ key = item.data(0, KEY_ROLE)
32
+ if not key:
33
+ return
34
+ mime = QMimeData()
35
+ mime.setData(MIME, key.encode())
36
+ drag = QDrag(self)
37
+ drag.setMimeData(mime)
38
+ ic = item.icon(0)
39
+ if not ic.isNull():
40
+ drag.setPixmap(ic.pixmap(28, 28))
41
+ drag.exec(Qt.CopyAction)
42
+
43
+
44
+ class Palette(QWidget):
45
+ element_selected = Signal(str) # type_key clicked (for explain mode)
46
+
47
+ def __init__(self, theme: ThemeManager) -> None:
48
+ super().__init__()
49
+ self.setObjectName("Sidebar")
50
+ self.theme = theme
51
+ lay = QVBoxLayout(self)
52
+ lay.setContentsMargins(10, 12, 10, 10)
53
+ lay.setSpacing(8)
54
+
55
+ self.search = QLineEdit()
56
+ self.search.setPlaceholderText("Search devices…")
57
+ self.search.addAction(
58
+ icons.icon("search", theme.theme.faint, 16),
59
+ QLineEdit.LeadingPosition,
60
+ )
61
+ self.search.textChanged.connect(self._filter)
62
+ lay.addWidget(self.search)
63
+
64
+ self.tree = _Tree()
65
+ self.tree.itemClicked.connect(self._on_item_clicked)
66
+ lay.addWidget(self.tree, 1)
67
+ self._populate()
68
+ theme.themeChanged.connect(self._restyle)
69
+
70
+ def _on_item_clicked(self, item, _col) -> None:
71
+ key = item.data(0, KEY_ROLE)
72
+ if key:
73
+ self.element_selected.emit(key)
74
+
75
+ def _populate(self) -> None:
76
+ self.tree.clear()
77
+ t = self.theme.theme
78
+ for cat, items in by_category().items():
79
+ top = QTreeWidgetItem([cat.value])
80
+ top.setFlags(Qt.ItemIsEnabled)
81
+ from .theme.manager import sp
82
+ f = top.font(0); f.setPointSize(sp(10)); f.setBold(True); top.setFont(0, f)
83
+ self.tree.addTopLevelItem(top)
84
+ top.setExpanded(True)
85
+ for d in items:
86
+ child = QTreeWidgetItem([d.label])
87
+ child.setData(0, KEY_ROLE, d.key)
88
+ child.setIcon(0, icons.icon(d.icon, t.accent_for(d.accent.value), 22))
89
+ child.setToolTip(0, d.description)
90
+ top.addChild(child)
91
+
92
+ def _filter(self, text: str) -> None:
93
+ text = text.strip().lower()
94
+ for i in range(self.tree.topLevelItemCount()):
95
+ top = self.tree.topLevelItem(i)
96
+ visible_children = 0
97
+ for j in range(top.childCount()):
98
+ ch = top.child(j)
99
+ show = text in ch.text(0).lower() or text in (ch.toolTip(0) or "").lower()
100
+ ch.setHidden(bool(text) and not show)
101
+ visible_children += 0 if ch.isHidden() else 1
102
+ top.setHidden(bool(text) and visible_children == 0)
103
+ if text:
104
+ top.setExpanded(True)
105
+
106
+ def _restyle(self, _name: str) -> None:
107
+ self._populate()
108
+ self.search.actions() and self.search.removeAction(self.search.actions()[0])
109
+ self.search.addAction(
110
+ icons.icon("search", self.theme.theme.faint, 16),
111
+ QLineEdit.LeadingPosition,
112
+ )
gini/ui/peripherals.py ADDED
@@ -0,0 +1,218 @@
1
+ """xv6 Terminal — the console as a single software peripheral you wire on the canvas.
2
+
3
+ xv6 has no networking, so instead of switches/routers you attach peripherals to the Machine.
4
+ The **Terminal** is the console: one shell view (a screen and keyboard in one, like a real tty),
5
+ because xv6's console is a single bidirectional UART. Double-clicking a Terminal on the canvas
6
+ opens this view, bound to its linked xv6 Machine's live provider.
7
+
8
+ It talks to the same provider the Machine Lab uses. Output STREAMS in append-only via
9
+ `console_since(cursor)` (so kernel output interleaves with your echoed input exactly like a real
10
+ terminal); input goes out via `send_input()`. A few conveniences are handled terminal-side — the
11
+ kind of thing a real terminal + readline do for you, not the shell: up/down-arrow command history,
12
+ and the built-ins `help`, `clear`, `history`, and `ps` (xv6 has no `ps` program — it's Ctrl-P,
13
+ which we surface under a friendly name). Everything else is passed straight to xv6's real `sh`.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from PySide6.QtCore import Qt, QTimer, Signal
18
+ from PySide6.QtWidgets import (
19
+ QDialog, QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton, QVBoxLayout,
20
+ )
21
+
22
+ from .theme import ThemeManager
23
+ from .theme.manager import scale_css as _scss
24
+
25
+ # terminal-side built-ins (handled here, not sent to xv6 sh) and the common real xv6 programs the
26
+ # `help` text advertises — so students know what they can actually run.
27
+ _BUILTINS = ("help", "clear", "history", "ps")
28
+ _XV6_PROGRAMS = ("ls", "cat", "echo", "grep", "wc", "mkdir", "rm", "ln", "kill",
29
+ "spin [secs]", "busy [secs]", "alloc", "writer", "forktest", "grind", "usertests")
30
+
31
+
32
+ class TerminalView(QDialog):
33
+ """A bash-like console for an xv6 Machine: streaming output + a prompt line with history."""
34
+
35
+ delta_ready = Signal(str, int) # (new console text, new cursor)
36
+
37
+ def __init__(self, parent, theme: ThemeManager, provider, device=None) -> None:
38
+ super().__init__(parent)
39
+ self.theme = theme
40
+ self.provider = provider
41
+ self._cursor = 0 # console byte offset we've displayed up to
42
+ self._fetching = False # one console read in flight — never overlap them
43
+ self._history: list[str] = []
44
+ self._hist_idx = 0 # points one past the last entry (i.e. "new line")
45
+ t = theme.theme
46
+ self.setWindowTitle(f"Terminal — {getattr(device, 'name', 'xv6')}")
47
+ self.resize(760, 480)
48
+ self.setStyleSheet(f"QDialog{{background:{t.bg};}}")
49
+ v = QVBoxLayout(self)
50
+ v.setSpacing(6)
51
+ self.view = QPlainTextEdit()
52
+ self.view.setReadOnly(True)
53
+ self.view.setStyleSheet(
54
+ f"QPlainTextEdit{{background:{t.panel};color:{t.text};border:1px solid {t.line};"
55
+ "border-radius:6px;font-family:monospace;font-size:12px;}")
56
+ v.addWidget(self.view, 1)
57
+ # prompt row: a "$" marker + the input line, styled as one terminal strip
58
+ row = QHBoxLayout()
59
+ row.setSpacing(6)
60
+ prompt = QLabel("$")
61
+ prompt.setStyleSheet(_scss(f"color:{t.accent};font-family:monospace;font-size:13px;"))
62
+ row.addWidget(prompt)
63
+ self.input = QLineEdit()
64
+ self.input.setPlaceholderText("type a command — e.g. spin 10 & · ls · help")
65
+ self.input.setStyleSheet(
66
+ f"QLineEdit{{background:{t.panel};color:{t.text};border:1px solid {t.line};"
67
+ "border-radius:6px;padding:8px;font-family:monospace;font-size:13px;}")
68
+ self.input.returnPressed.connect(self._submit)
69
+ self.input.installEventFilter(self) # up/down = history
70
+ row.addWidget(self.input, 1)
71
+ # xv6 has no Ctrl-C / SIGINT, so a foreground program (e.g. `spin` with no &) blocks the
72
+ # shell. Break kills the most-recent user process via the kernel's console handler, so the
73
+ # prompt comes back even though sh isn't reading input.
74
+ brk = QPushButton("Break ⌃C")
75
+ brk.setToolTip("Interrupt a hung foreground command (xv6 has no Ctrl-C — this kills the "
76
+ "most-recently started program so the shell prompt returns)")
77
+ # CRITICAL: a dialog push button is auto-default, so Enter in the input line would ALSO
78
+ # click it — every submitted command would fire a Break and kill a process. Opt out so
79
+ # Enter only submits.
80
+ brk.setAutoDefault(False)
81
+ brk.setDefault(False)
82
+ brk.setStyleSheet(
83
+ f"QPushButton{{color:{t.text};background:{t.panel2};border:1px solid {t.line};"
84
+ f"border-radius:6px;padding:6px 12px;font-family:monospace;}}"
85
+ f"QPushButton:hover{{border-color:{t.accent};}}")
86
+ brk.clicked.connect(self._break)
87
+ self._break_btn = brk
88
+ row.addWidget(brk)
89
+ v.addLayout(row)
90
+ hint = QLabel("Programs take arguments (spin 10 & runs 10s in the background); ↑ recalls "
91
+ "history; type help to see what you can run.")
92
+ hint.setStyleSheet(_scss(f"color:{t.faint};font-size:11px;"))
93
+ v.addWidget(hint)
94
+ self._streaming = hasattr(provider, "console_since")
95
+ self.delta_ready.connect(self._append_stream)
96
+ self._poll = QTimer(self)
97
+ self._poll.timeout.connect(self._refresh)
98
+ self._poll.start(350)
99
+ self.input.setFocus()
100
+ self._append("Connected. Type help to get started.\n")
101
+ self._refresh()
102
+
103
+ # -- output --------------------------------------------------------------- #
104
+ def _bg(self, fn):
105
+ import threading
106
+ threading.Thread(target=fn, daemon=True).start()
107
+
108
+ def _refresh(self):
109
+ # Never overlap reads: a slow agent + a fixed timer would stack background reads that all
110
+ # fetch from the SAME cursor, duplicating output (and echoed commands). One in flight only.
111
+ if self._fetching:
112
+ return
113
+ self._fetching = True
114
+ cur = self._cursor
115
+ def work():
116
+ try:
117
+ if self._streaming:
118
+ text, nxt = self.provider.console_since(cur)
119
+ else: # provider without streaming -> full tail
120
+ text, nxt = "\x00" + (self.provider.console() or ""), cur
121
+ except Exception:
122
+ text, nxt = "", cur # a failed read: skip, keep what we have
123
+ self.delta_ready.emit(text, nxt)
124
+ self._bg(work)
125
+
126
+ def _append_stream(self, text: str, nxt: int):
127
+ self._fetching = False # read done — the next tick may fetch again
128
+ self._cursor = nxt # advance the cursor on the GUI thread (safe)
129
+ if text.startswith("\x00"): # non-streaming fallback: replace the pane
130
+ body = text[1:]
131
+ if body and body != self.view.toPlainText():
132
+ self._set_text(body)
133
+ return
134
+ if text:
135
+ self._append(text)
136
+
137
+ def _append(self, text: str):
138
+ sb = self.view.verticalScrollBar()
139
+ at_bottom = sb.value() >= sb.maximum() - 4
140
+ cur = self.view.textCursor()
141
+ cur.movePosition(cur.MoveOperation.End)
142
+ cur.insertText(text)
143
+ if at_bottom:
144
+ sb.setValue(sb.maximum())
145
+
146
+ def _set_text(self, text: str):
147
+ sb = self.view.verticalScrollBar()
148
+ at_bottom = sb.value() >= sb.maximum() - 4
149
+ self.view.setPlainText(text)
150
+ if at_bottom:
151
+ sb.setValue(sb.maximum())
152
+
153
+ # -- input ---------------------------------------------------------------- #
154
+ def _submit(self):
155
+ cmd = self.input.text().strip()
156
+ self.input.clear()
157
+ if cmd:
158
+ self._history.append(cmd)
159
+ self._hist_idx = len(self._history)
160
+ if cmd in _BUILTINS:
161
+ self._builtin(cmd) # handled terminal-side, echoed locally
162
+ return
163
+ # a real xv6 command: xv6's console echoes it back, so it appears in the stream itself
164
+ self._bg(lambda: self.provider.send_input(cmd + "\n"))
165
+
166
+ def _builtin(self, cmd: str):
167
+ self._append(f"$ {cmd}\n") # local echo (xv6 never sees these)
168
+ if cmd == "help":
169
+ self._append(self._help_text())
170
+ elif cmd == "clear":
171
+ if hasattr(self.provider, "clear_console"):
172
+ self._bg(self.provider.clear_console)
173
+ self.view.clear()
174
+ elif cmd == "history":
175
+ for i, h in enumerate(self._history, 1):
176
+ self._append(f"{i:>4} {h}\n")
177
+ elif cmd == "ps":
178
+ # xv6 has no `ps` program — Ctrl-P triggers the kernel's native procdump, which prints
179
+ # to the console and streams back in like any other output.
180
+ self._append("(xv6 process table — Ctrl-P)\n")
181
+ self._bg(lambda: self.provider.send_input("\x10"))
182
+
183
+ def _break(self):
184
+ # send the kernel's break control-char (Ctrl-C). The console driver handles it directly —
185
+ # NOT sh — so it works even while a foreground program has the shell blocked in wait().
186
+ self._append("^C\n")
187
+ if hasattr(self.provider, "interrupt"):
188
+ self._bg(self.provider.interrupt)
189
+ else: # older provider: fall back to raw Ctrl-C
190
+ self._bg(lambda: self.provider.send_input("\x03"))
191
+
192
+ def _help_text(self) -> str:
193
+ return (
194
+ "terminal built-ins: " + ", ".join(_BUILTINS) + "\n"
195
+ "xv6 programs: " + ", ".join(_XV6_PROGRAMS) + "\n"
196
+ "tips: add & to run in the background (spin 10 &); ↑/↓ recall history; "
197
+ "programs take arguments.\n")
198
+
199
+ def eventFilter(self, obj, ev): # noqa: N802
200
+ if obj is self.input and ev.type() == ev.Type.KeyPress:
201
+ if ev.key() == Qt.Key_Up:
202
+ self._recall(-1)
203
+ return True
204
+ if ev.key() == Qt.Key_Down:
205
+ self._recall(+1)
206
+ return True
207
+ return super().eventFilter(obj, ev)
208
+
209
+ def _recall(self, step: int):
210
+ if not self._history:
211
+ return
212
+ self._hist_idx = max(0, min(len(self._history), self._hist_idx + step))
213
+ self.input.setText(self._history[self._hist_idx] if self._hist_idx < len(self._history)
214
+ else "")
215
+
216
+ def closeEvent(self, e): # noqa: N802
217
+ self._poll.stop()
218
+ super().closeEvent(e)