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/canvas.py ADDED
@@ -0,0 +1,2007 @@
1
+ """The topology canvas: a themed QGraphicsView scene with node + edge items.
2
+
3
+ Drives off the AppContext: dropping a palette item or an agent call adds a device to
4
+ the topology, which emits `device_added`, which the scene turns into a NodeItem. The
5
+ model stays the single source of truth; the scene is a view of it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ from PySide6.QtCore import (
12
+ QEasingCurve, QPointF, QRect, QRectF, QSizeF, Qt, QTimer, QVariantAnimation,
13
+ )
14
+ from PySide6.QtGui import (
15
+ QBrush, QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen, QPolygonF,
16
+ QRadialGradient,
17
+ )
18
+ from PySide6.QtWidgets import (
19
+ QGraphicsDropShadowEffect, QGraphicsItem, QGraphicsObject, QGraphicsScene,
20
+ QGraphicsView, QLabel,
21
+ )
22
+
23
+ from ..app import AppContext
24
+ from ..domain import grouping, pricing
25
+ from ..domain.topology import DeviceInstance, Link
26
+ from .theme import icons
27
+ from .theme.manager import sp as _sp, ui_scale as _uiscale # scale text + node cards by the UI setting
28
+ from .theme.tokens import Theme
29
+
30
+ MIME = "application/x-gini-device"
31
+ GRID = 22
32
+ # how many pixels of "near enough" the connect gesture allows around a node. Aiming at a 40px icon
33
+ # with a mouse is a fine-motor task; being 3px off should not silently cancel the mode.
34
+ _HIT_SLACK = 10
35
+ NODE_W, NODE_H = 138, 84
36
+ SIZE_STEP = 30 # px the node grows taller per size tier above S (vertical scaling)
37
+ CORNER_R = 13 # rounded-corner radius for bent connectors
38
+
39
+
40
+ def _qcolor(spec: str) -> QColor:
41
+ if spec.startswith("rgba("):
42
+ nums = [int(float(x)) for x in spec[5:-1].split(",")]
43
+ return QColor(nums[0], nums[1], nums[2], nums[3] if len(nums) > 3 else 255)
44
+ return QColor(spec)
45
+
46
+
47
+ def _blend(c1: QColor, c2: QColor, t: float) -> QColor:
48
+ t = max(0.0, min(1.0, t))
49
+ return QColor(
50
+ int(c1.red() + (c2.red() - c1.red()) * t),
51
+ int(c1.green() + (c2.green() - c1.green()) * t),
52
+ int(c1.blue() + (c2.blue() - c1.blue()) * t),
53
+ )
54
+
55
+
56
+ def _ortho_waypoints(a: "NodeItem", b: "NodeItem") -> list[QPointF]:
57
+ """Manhattan (horizontal/vertical) waypoints from A's border to B's border.
58
+
59
+ Exits perpendicular to the nearer face and turns at the midline, giving a tidy
60
+ two-bend 'Z' that the rounded-path builder softens into smooth elbows.
61
+ """
62
+ s = _uiscale() # cards are drawn at this scale — use scaled extents
63
+ nw = NODE_W * s
64
+ ax, ay = a.pos().x(), a.pos().y()
65
+ bx, by = b.pos().x(), b.pos().y()
66
+ ah, bh = a.node_h() * s, b.node_h() * s # per-node heights (size tiers differ) × card scale
67
+ ca = QPointF(ax + nw / 2, ay + ah / 2)
68
+ cb = QPointF(bx + nw / 2, by + bh / 2)
69
+ dx, dy = cb.x() - ca.x(), cb.y() - ca.y()
70
+ if abs(dy) >= abs(dx): # stacked-ish -> exit top/bottom
71
+ if dy >= 0:
72
+ ex, en = QPointF(ca.x(), ay + ah), QPointF(cb.x(), by)
73
+ else:
74
+ ex, en = QPointF(ca.x(), ay), QPointF(cb.x(), by + bh)
75
+ mid = (ex.y() + en.y()) / 2
76
+ return [ex, QPointF(ex.x(), mid), QPointF(en.x(), mid), en]
77
+ else: # side-by-side -> exit left/right
78
+ if dx >= 0:
79
+ ex, en = QPointF(ax + nw, ca.y()), QPointF(bx, cb.y())
80
+ else:
81
+ ex, en = QPointF(ax, ca.y()), QPointF(bx + nw, cb.y())
82
+ mid = (ex.x() + en.x()) / 2
83
+ return [ex, QPointF(mid, ex.y()), QPointF(mid, en.y()), en]
84
+
85
+
86
+ def _rounded_path(pts: list[QPointF], r: float) -> QPainterPath:
87
+ """A polyline through `pts` with each interior corner rounded by radius `r`."""
88
+ path = QPainterPath()
89
+ # drop near-duplicate / collinear-collapsed points so corners are well defined
90
+ clean: list[QPointF] = []
91
+ for p in pts:
92
+ if not clean or (p - clean[-1]).manhattanLength() > 0.5:
93
+ clean.append(p)
94
+ if not clean:
95
+ return path
96
+ path.moveTo(clean[0])
97
+ if len(clean) == 2:
98
+ path.lineTo(clean[1])
99
+ return path
100
+ for i in range(1, len(clean) - 1):
101
+ prev, cur, nxt = clean[i - 1], clean[i], clean[i + 1]
102
+ vin, vout = cur - prev, nxt - cur
103
+ lin = math.hypot(vin.x(), vin.y())
104
+ lout = math.hypot(vout.x(), vout.y())
105
+ if lin < 1e-6 or lout < 1e-6:
106
+ continue
107
+ ri = min(r, lin / 2, lout / 2)
108
+ path.lineTo(cur - vin * (ri / lin)) # straight up to the corner
109
+ path.quadTo(cur, cur + vout * (ri / lout)) # round through it
110
+ path.lineTo(clean[-1])
111
+ return path
112
+
113
+
114
+ # default box sizes (px) per container type, and the minimum a box can be resized to.
115
+ _GROUP_DEFAULTS = {"vpc": (380.0, 260.0), "cloud_subnet": (300.0, 170.0),
116
+ "region": (480.0, 330.0)}
117
+ GROUP_MIN_W, GROUP_MIN_H = 170.0, 120.0
118
+ _GRIP = 16.0
119
+
120
+
121
+ class GroupItem(QGraphicsObject):
122
+ """A resizable container box for grouping elements (VPC / Cloud Subnet / Region).
123
+
124
+ Drawn behind the nodes as a translucent labelled rectangle. Whatever sits inside it
125
+ (decided by geometry — see domain/grouping.py) becomes its child via parent_id, which
126
+ the compiler turns into a real isolated Docker network for a VPC. Move the box and its
127
+ contents travel with it; drag the bottom-right grip to resize."""
128
+
129
+ def __init__(self, scene: "CanvasScene", inst: DeviceInstance) -> None:
130
+ super().__init__()
131
+ self._scene = scene
132
+ self.inst = inst
133
+ dw, dh = _GROUP_DEFAULTS.get(inst.type_key, (320.0, 200.0))
134
+ if not inst.w or not inst.h:
135
+ inst.w, inst.h = dw, dh
136
+ self._resizing = False
137
+ self._last = QPointF(inst.x, inst.y) # must exist before setPos fires itemChange
138
+ self.setFlags(
139
+ QGraphicsItem.ItemIsMovable
140
+ | QGraphicsItem.ItemIsSelectable
141
+ | QGraphicsItem.ItemSendsGeometryChanges
142
+ )
143
+ self.setPos(inst.x, inst.y)
144
+ self.setZValue(-5) # behind edges (z=1) and nodes (z=10)
145
+ self.setAcceptHoverEvents(True)
146
+
147
+ # geometry --------------------------------------------------------------- #
148
+ def box_rect(self) -> QRectF:
149
+ return QRectF(0, 0, float(self.inst.w), float(self.inst.h))
150
+
151
+ def boundingRect(self) -> QRectF:
152
+ return QRectF(-2, -2, float(self.inst.w) + 4, float(self.inst.h) + 4)
153
+
154
+ def _grip_rect(self) -> QRectF:
155
+ return QRectF(self.inst.w - _GRIP, self.inst.h - _GRIP, _GRIP, _GRIP)
156
+
157
+ # painting --------------------------------------------------------------- #
158
+ def paint(self, p: QPainter, *_):
159
+ t = self._scene.theme
160
+ dt = self.inst.type
161
+ accent = _qcolor(t.accent_for(dt.accent.value))
162
+ sel = self.isSelected()
163
+ p.setRenderHint(QPainter.Antialiasing, True)
164
+ fill = QColor(accent); fill.setAlpha(40 if sel else 26)
165
+ p.setBrush(QBrush(fill))
166
+ pen = QPen(accent, 2.0 if sel else 1.4)
167
+ pen.setStyle(Qt.SolidLine if sel else Qt.DashLine)
168
+ p.setPen(pen)
169
+ p.drawRoundedRect(self.box_rect(), 14, 14)
170
+ # title + subtitle (the VPC's CIDR / subnet's tier)
171
+ p.setPen(accent)
172
+ f = QFont(); f.setBold(True); f.setPointSize(_sp(10)); p.setFont(f)
173
+ p.drawText(QRectF(14, 7, self.inst.w - 28, 16), Qt.AlignVCenter,
174
+ f"{dt.label}: {self.inst.name or dt.label}")
175
+ sub = self._subtitle()
176
+ if sub:
177
+ p.setPen(_qcolor(t.muted))
178
+ f2 = QFont(); f2.setPointSize(_sp(8)); p.setFont(f2)
179
+ p.drawText(QRectF(14, 23, self.inst.w - 28, 13), Qt.AlignVCenter, sub)
180
+ # resize grip (three corner ticks)
181
+ g = self._grip_rect()
182
+ p.setPen(QPen(accent, 1.5))
183
+ for o in (3.0, 7.0, 11.0):
184
+ p.drawLine(QPointF(g.right() - o, g.bottom() - 2),
185
+ QPointF(g.right() - 2, g.bottom() - o))
186
+
187
+ def _subtitle(self) -> str:
188
+ pr = self.inst.properties or {}
189
+ if self.inst.type_key == "vpc":
190
+ return pr.get("CIDR", "")
191
+ if self.inst.type_key == "cloud_subnet":
192
+ return " · ".join(x for x in (pr.get("CIDR", ""), pr.get("Tier", "")) if x)
193
+ if self.inst.type_key == "region":
194
+ return pr.get("Region", "") or pr.get("Zone", "")
195
+ return ""
196
+
197
+ # interaction ------------------------------------------------------------ #
198
+ def mousePressEvent(self, e):
199
+ if (e.button() == Qt.LeftButton
200
+ and self._grip_rect().adjusted(-5, -5, 4, 4).contains(e.pos())):
201
+ self._resizing = True
202
+ self.setSelected(True)
203
+ e.accept()
204
+ return
205
+ super().mousePressEvent(e)
206
+
207
+ def mouseMoveEvent(self, e):
208
+ if self._resizing:
209
+ self.prepareGeometryChange()
210
+ self.inst.w = max(GROUP_MIN_W, e.pos().x())
211
+ self.inst.h = max(GROUP_MIN_H, e.pos().y())
212
+ self.update()
213
+ e.accept()
214
+ return
215
+ super().mouseMoveEvent(e)
216
+
217
+ def mouseReleaseEvent(self, e):
218
+ was_resizing = self._resizing
219
+ self._resizing = False
220
+ super().mouseReleaseEvent(e)
221
+ self._scene.recompute_membership() # box moved/resized -> reassign contents
222
+ if was_resizing:
223
+ self._scene.ctx.bus.topology_changed.emit()
224
+
225
+ def itemChange(self, change, value):
226
+ if change == QGraphicsItem.ItemPositionHasChanged:
227
+ new = self.pos()
228
+ dx, dy = new.x() - self._last.x(), new.y() - self._last.y()
229
+ self._last = QPointF(new)
230
+ self.inst.x, self.inst.y = new.x(), new.y()
231
+ if dx or dy: # drag the box -> its contents follow
232
+ self._scene._move_children(self.inst.id, dx, dy)
233
+ return super().itemChange(change, value)
234
+
235
+
236
+ class NodeItem(QGraphicsObject):
237
+ """Visual card for one DeviceInstance."""
238
+
239
+ def __init__(self, scene: "CanvasScene", inst: DeviceInstance) -> None:
240
+ super().__init__()
241
+ self._scene = scene
242
+ self.inst = inst
243
+ self.status = "idle"
244
+ # GINI32 only: the hotspot address of the real board currently checked in, or ""
245
+ # when none is. Observed hardware state, so it is never saved with the topology.
246
+ self.board_addr = ""
247
+ self.setFlags(
248
+ QGraphicsItem.ItemIsMovable
249
+ | QGraphicsItem.ItemIsSelectable
250
+ | QGraphicsItem.ItemSendsGeometryChanges
251
+ )
252
+ self.setPos(inst.x, inst.y)
253
+ self.setScale(_uiscale()) # the whole card (art + text) grows with the text-size setting
254
+ self.setZValue(10)
255
+ self.setAcceptHoverEvents(True)
256
+ self._hover = 0.0
257
+ self._spot = False # tutor spotlight
258
+ self._ring = False # tutor highlight
259
+ self._xray = None # X-ray: "target" (this node is being inspected) or None
260
+ self._anim: QVariantAnimation | None = None
261
+ self._shadow = QGraphicsDropShadowEffect()
262
+ self.refresh_theme()
263
+ self.setGraphicsEffect(self._shadow)
264
+
265
+ def refresh_theme(self) -> None:
266
+ t = self._scene.theme
267
+ self._shadow.setColor(_qcolor(t.shadow))
268
+ self._shadow.setBlurRadius(t.elevation + 10 * self._hover)
269
+ self._shadow.setOffset(0, 4 + 2 * self._hover)
270
+
271
+ # size tier (resizable elements grow taller; others stay at the base height) ----- #
272
+ def _resizable(self) -> bool:
273
+ return pricing.resizable(self.inst.type_key)
274
+
275
+ # xv6 caps at L (2 vCPU): QEMU harts are capped at 2 for a legible 1-2 core scheduler demo, so
276
+ # the Size must never advertise more cores than the machine actually runs.
277
+ _XV6_MAX_LEVEL = 3 # L
278
+
279
+ def _size(self) -> int:
280
+ if not self._resizable():
281
+ return 1
282
+ lvl = pricing.size_level(getattr(self.inst, "size", 1))
283
+ return min(lvl, self._XV6_MAX_LEVEL) if self.inst.type_key == "xv6" else lvl
284
+
285
+ def node_h(self) -> float:
286
+ return NODE_H + (self._size() - 1) * SIZE_STEP
287
+
288
+ def _stepper_rects(self) -> tuple[QRectF, QRectF]:
289
+ """(minus, plus) hit rectangles for the on-node size stepper, at bottom-right."""
290
+ y = self.node_h() - 25
291
+ return (QRectF(NODE_W - 46, y, 19, 19), QRectF(NODE_W - 24, y, 19, 19))
292
+
293
+ def _bump_size(self, delta: int) -> None:
294
+ # xv6's CPU count is fixed at boot (-smp), so changing vCPUs requires a stop + rerun —
295
+ # block the stepper while the machine is running (other elements resize live).
296
+ if self.inst.type_key == "xv6" and getattr(self._scene, "running", False):
297
+ self._scene.ctx.log("Stop the topology to change the xv6 Machine's vCPUs, "
298
+ "then Run again.", "info")
299
+ return
300
+ new = pricing.size_level(self._size() + delta)
301
+ if self.inst.type_key == "xv6":
302
+ new = min(new, self._XV6_MAX_LEVEL) # + can't push xv6 past L (2 vCPU)
303
+ if new == self._size():
304
+ return
305
+ self.prepareGeometryChange()
306
+ self.inst.size = new
307
+ self.update()
308
+ self._scene.update_edges_for(self.inst.id)
309
+ # resized -> live CPU update if running; rebill the dashboard; persist in .gini
310
+ self._scene.ctx.bus.device_resized.emit(self.inst.id)
311
+ self._scene.ctx.bus.topology_changed.emit()
312
+
313
+ def set_xray(self, state) -> None:
314
+ """Ring this element while it's being X-rayed ('target'), or None to clear."""
315
+ if state == self._xray:
316
+ return
317
+ self._xray = state
318
+ self.update()
319
+
320
+ def boundingRect(self) -> QRectF:
321
+ return QRectF(-3, -3, NODE_W + 6, self.node_h() + 6)
322
+
323
+ # hover lift -------------------------------------------------------------- #
324
+ def hoverEnterEvent(self, e):
325
+ self._animate_hover(1.0)
326
+ super().hoverEnterEvent(e)
327
+
328
+ def hoverLeaveEvent(self, e):
329
+ self._animate_hover(0.0)
330
+ super().hoverLeaveEvent(e)
331
+
332
+ def _animate_hover(self, target: float) -> None:
333
+ if self._scene.ctx.settings.reduced_motion:
334
+ self._set_hover(target)
335
+ return
336
+ if self._anim is not None:
337
+ self._anim.stop()
338
+ anim = QVariantAnimation(self)
339
+ anim.setStartValue(self._hover)
340
+ anim.setEndValue(target)
341
+ anim.setDuration(self._scene.theme.dur_fast)
342
+ anim.setEasingCurve(QEasingCurve.OutCubic)
343
+ anim.valueChanged.connect(lambda v: self._set_hover(float(v)))
344
+ anim.start()
345
+ self._anim = anim
346
+
347
+ def _set_hover(self, v: float) -> None:
348
+ self._hover = v
349
+ self._shadow.setBlurRadius(self._scene.theme.elevation + 10 * v)
350
+ self._shadow.setOffset(0, 4 + 2 * v)
351
+ self.update()
352
+
353
+ def paint(self, p: QPainter, opt, widget=None) -> None:
354
+ t = self._scene.theme
355
+ dt = self.inst.type
356
+ accent = _qcolor(t.accent_for(dt.accent.value))
357
+ p.setRenderHint(QPainter.Antialiasing, True)
358
+ # A SLOT-TAGGED element is scaffolding — a stand-in for a composition parameter, not part of
359
+ # the fragment being authored. Render it recessive so the canvas tells the truth: the bright,
360
+ # un-hulled elements are YOUR delta; the faded ones get replaced at compose time.
361
+ if getattr(self.inst, "slot", ""):
362
+ p.setOpacity(0.55)
363
+
364
+ H = self.node_h()
365
+ rect = QRectF(0.5, 0.5, NODE_W - 1, H - 1)
366
+ selected = self.isSelected()
367
+ hover = self._hover
368
+
369
+ # selection / focus / tutor ring
370
+ ring_strength = 1.0 if (selected or self._spot or self._ring) else hover
371
+ if ring_strength > 0.01:
372
+ ring = QColor(accent)
373
+ ring.setAlpha(int(80 * ring_strength))
374
+ p.setBrush(Qt.NoBrush)
375
+ p.setPen(QPen(ring, 3))
376
+ p.drawRoundedRect(rect.adjusted(-2, -2, 2, 2), 12, 12)
377
+ if self._spot:
378
+ glow = QColor(accent)
379
+ glow.setAlpha(45)
380
+ p.setPen(QPen(glow, 6))
381
+ p.drawRoundedRect(rect.adjusted(-5, -5, 5, 5), 14, 14)
382
+
383
+ # Mission: a reddish glow when the game master has flagged this element as off-task or
384
+ # wrongly wired (non-destructive — clears the instant the student fixes it)
385
+ if self._mission_flagged():
386
+ dgl = _qcolor(t.danger); dgl.setAlpha(70)
387
+ p.setBrush(Qt.NoBrush); p.setPen(QPen(dgl, 6))
388
+ p.drawRoundedRect(rect.adjusted(-5, -5, 5, 5), 14, 14)
389
+
390
+ # X-ray: ring the element currently being inspected (its ghosts float around it)
391
+ if self._xray == "target":
392
+ halo = QColor(accent); halo.setAlpha(60)
393
+ p.setBrush(Qt.NoBrush); p.setPen(QPen(halo, 6))
394
+ p.drawRoundedRect(rect.adjusted(-4, -4, 4, 4), 13, 13)
395
+ edge = QColor(accent); edge.setAlpha(220)
396
+ p.setPen(QPen(edge, 2.4))
397
+ p.drawRoundedRect(rect.adjusted(-1.5, -1.5, 1.5, 1.5), 11, 11)
398
+
399
+ p.setBrush(QBrush(_qcolor(t.node_fill())))
400
+ border = accent if selected else _blend(_qcolor(t.line2), accent, hover)
401
+ p.setPen(QPen(border, 2 if selected else 1.4))
402
+ p.drawRoundedRect(rect, 10, 10)
403
+
404
+ # icon swatch
405
+ soft = QColor(accent)
406
+ soft.setAlpha(40)
407
+ p.setBrush(soft)
408
+ p.setPen(Qt.NoPen)
409
+ p.drawRoundedRect(QRectF(10, 10, 30, 30), 8, 8)
410
+ px = icons.render_pixmap(dt.icon, t.accent_for(dt.accent.value), size=20)
411
+ p.drawPixmap(15, 15, px)
412
+
413
+ # name + type
414
+ p.setPen(_qcolor(t.text))
415
+ f = QFont(); f.setPointSize(_sp(11)); f.setWeight(QFont.DemiBold); p.setFont(f)
416
+ p.drawText(QRectF(48, 10, NODE_W - 56, 18), Qt.AlignVCenter, self.inst.name)
417
+ p.setPen(_qcolor(t.faint))
418
+ f2 = QFont(); f2.setPointSize(_sp(8)); p.setFont(f2)
419
+ p.drawText(QRectF(48, 27, NODE_W - 56, 14), Qt.AlignVCenter, dt.label)
420
+
421
+ # primary IP (once compiled) — at-a-glance addressing on the node
422
+ addr = self._scene.ctx.addressing.get(self.inst.name)
423
+ ip = ""
424
+ if addr and addr.get("interfaces"):
425
+ ifaces = addr["interfaces"]
426
+ ip = ifaces[0]["ip"].split("/")[0]
427
+ if len(ifaces) > 1:
428
+ ip += f" +{len(ifaces) - 1}"
429
+ elif getattr(self, "board_addr", ""):
430
+ # A GINI32 board is not in the compiled addressing table — it has no
431
+ # container and the canvas never assigns it a machine address. Its meaningful
432
+ # address is the hotspot gateway it raises for real devices, which only
433
+ # exists while the hardware is actually checked in. Set (and cleared) by
434
+ # _poll_boards, so an unplugged board stops advertising an address it no
435
+ # longer answers on.
436
+ ip = self.board_addr
437
+ if ip:
438
+ p.setPen(_qcolor(t.accent))
439
+ fip = QFont(); fip.setStyleHint(QFont.Monospace); fip.setPointSize(_sp(8))
440
+ p.setFont(fip)
441
+ p.drawText(QRectF(48, 41, NODE_W - 56, 13), Qt.AlignVCenter, ip)
442
+
443
+ # status chip
444
+ chip_col, label = {
445
+ "running": (_qcolor(t.success), "running"),
446
+ "booting": (_qcolor(t.warning), "booting"),
447
+ "stopping": (_qcolor(t.warning), "stopping"),
448
+ "error": (_qcolor(t.danger), "error"),
449
+ "ready": (_qcolor(t.accent), "ready"), # a rider whose donor is up — runnable
450
+ }.get(self.status, (_qcolor(t.muted), "idle"))
451
+ # Real hardware does not "run" — it is either there or it is not. Saying
452
+ # "running" about a board on someone's desk invites the wrong mental model,
453
+ # and "idle" reads as "fine, just quiet" when it actually means "absent".
454
+ if self.inst.type_key == "gini32":
455
+ chip_col, label = {
456
+ "running": (_qcolor(t.success), "connected"),
457
+ "searching": (_qcolor(t.warning), "searching"),
458
+ "error": (_qcolor(t.danger), "no board"),
459
+ }.get(self.status, (_qcolor(t.muted), "offline"))
460
+ chip_bg = QColor(chip_col); chip_bg.setAlpha(38)
461
+ cr = QRectF(12, H - 24, 62, 16)
462
+ p.setBrush(chip_bg); p.setPen(Qt.NoPen)
463
+ p.drawRoundedRect(cr, 8, 8)
464
+ p.setBrush(chip_col)
465
+ p.drawEllipse(QRectF(cr.left() + 7, cr.center().y() - 3, 6, 6))
466
+ p.setPen(chip_col)
467
+ f3 = QFont(); f3.setPointSize(_sp(8)); p.setFont(f3)
468
+ p.drawText(cr.adjusted(20, 0, -2, 0), Qt.AlignVCenter, label)
469
+
470
+ # size tier: capacity gauge + label in the grown body, and a + / - stepper
471
+ if self._resizable():
472
+ self._paint_size(p, t, accent, H)
473
+
474
+ # Mission: red error badge (top-right) when flagged off-task / wrongly wired
475
+ if self._mission_flagged():
476
+ bx, by = NODE_W - 24, 6
477
+ p.setBrush(_qcolor(t.danger)); p.setPen(Qt.NoPen)
478
+ p.drawEllipse(QRectF(bx, by, 18, 18))
479
+ p.setPen(QColor("#ffffff"))
480
+ fb = QFont(); fb.setPointSize(_sp(11)); fb.setBold(True); p.setFont(fb)
481
+ p.drawText(QRectF(bx, by - 1, 18, 18), Qt.AlignCenter, "!")
482
+
483
+ # advisory-lint warning badge (top-right) — clickable to ask GINI about it
484
+ if (self.inst.name in self._scene.ctx.warnings and not self._off_goal()
485
+ and not self._mission_flagged()):
486
+ warn = _qcolor(t.warning)
487
+ bx, by = NODE_W - 22, 8
488
+ p.setBrush(warn); p.setPen(Qt.NoPen)
489
+ p.drawEllipse(QRectF(bx, by, 14, 14))
490
+ p.setPen(QColor("#1a1205"))
491
+ fb = QFont(); fb.setPointSize(_sp(9)); fb.setBold(True); p.setFont(fb)
492
+ p.drawText(QRectF(bx, by, 14, 14), Qt.AlignCenter, "!")
493
+
494
+ # Wizard: flag an element that isn't part of the active goal (click ✕ to remove)
495
+ if self._off_goal():
496
+ bx, by = NODE_W - 24, 6
497
+ p.setBrush(_qcolor(t.danger)); p.setPen(Qt.NoPen)
498
+ p.drawEllipse(QRectF(bx, by, 18, 18))
499
+ p.setPen(QColor("#ffffff"))
500
+ fb = QFont(); fb.setPointSize(_sp(10)); fb.setBold(True); p.setFont(fb)
501
+ p.drawText(QRectF(bx, by - 1, 18, 18), Qt.AlignCenter, "✕")
502
+
503
+ def _paint_size(self, p: QPainter, t, accent: QColor, H: float) -> None:
504
+ level = self._size()
505
+ label, vcpu, _mem, _mult = pricing.size_tier(level)
506
+
507
+ # + / - stepper (bottom-right) — dim the end-stops
508
+ minus, plus = self._stepper_rects()
509
+ for r, glyph, on in ((minus, "−", level > pricing.SIZE_MIN),
510
+ (plus, "+", level < pricing.SIZE_MAX)):
511
+ bg = QColor(accent); bg.setAlpha(30 if on else 10)
512
+ p.setBrush(bg)
513
+ p.setPen(QPen(accent if on else _qcolor(t.line2), 1.2))
514
+ p.drawRoundedRect(r.adjusted(1, 1, -1, -1), 5, 5)
515
+ p.setPen(accent if on else _qcolor(t.faint))
516
+ fs = QFont(); fs.setPointSize(_sp(12)); fs.setBold(True); p.setFont(fs)
517
+ p.drawText(r, Qt.AlignCenter, glyph)
518
+
519
+ # capacity caption + vertical gauge in the body the taller node opens up
520
+ body_top, body_bot = 58.0, H - 30
521
+ if body_bot - body_top >= 16:
522
+ p.setPen(_qcolor(t.muted))
523
+ fc = QFont(); fc.setPointSize(_sp(8)); fc.setBold(True); p.setFont(fc)
524
+ p.drawText(QRectF(14, body_top, NODE_W - 30, 14),
525
+ Qt.AlignVCenter | Qt.AlignLeft, f"{label} · {vcpu:g} vCPU")
526
+ gx, gw, gtop, gbot = NODE_W - 16.0, 6.0, body_top + 16, body_bot
527
+ p.setBrush(_qcolor(t.bg3)); p.setPen(QPen(_qcolor(t.line2), 1))
528
+ p.drawRoundedRect(QRectF(gx, gtop, gw, gbot - gtop), 3, 3)
529
+ fh = (gbot - gtop) * (level / pricing.SIZE_MAX)
530
+ p.setBrush(accent); p.setPen(Qt.NoPen)
531
+ p.drawRoundedRect(QRectF(gx, gbot - fh, gw, fh), 3, 3)
532
+
533
+ def itemChange(self, change, value):
534
+ if change == QGraphicsItem.ItemPositionHasChanged:
535
+ self.inst.x = self.pos().x()
536
+ self.inst.y = self.pos().y()
537
+ self._scene.update_edges_for(self.inst.id)
538
+ return super().itemChange(change, value)
539
+
540
+ def _on_warning_badge(self, pos) -> bool:
541
+ """True if `pos` (item coords) is on the lint warning badge."""
542
+ if self.inst.name not in self._scene.ctx.warnings or self._off_goal():
543
+ return False
544
+ badge = QRectF(NODE_W - 24, 6, 18, 18) # a touch larger than drawn, easier to hit
545
+ return badge.contains(pos)
546
+
547
+ def _off_goal(self) -> bool:
548
+ """True if a Wizard objective is active and this element isn't part of it."""
549
+ m = getattr(self._scene.ctx, "mission", None)
550
+ return m is not None and not m.allows(self.inst.type_key)
551
+
552
+ def _mission_flagged(self) -> bool:
553
+ """True if a Mission has flagged this element (off-task or wrongly wired)."""
554
+ return self.inst.id in getattr(self._scene.ctx, "mission_flags", {})
555
+
556
+ def _on_offgoal_badge(self, pos) -> bool:
557
+ return self._off_goal() and QRectF(NODE_W - 26, 4, 22, 22).contains(pos)
558
+
559
+ def mousePressEvent(self, e):
560
+ # clicking the amber "!" badge asks GINI why it's flagged (and how to fix it),
561
+ # rather than selecting/moving the node
562
+ if e.button() == Qt.LeftButton and self._on_offgoal_badge(e.pos()):
563
+ self._scene.ctx.bus.device_delete_requested.emit(self.inst.id) # ✕ -> remove
564
+ e.accept()
565
+ return
566
+ if e.button() == Qt.LeftButton and self._on_warning_badge(e.pos()):
567
+ self._scene.ctx.bus.warning_explain_requested.emit(self.inst.id)
568
+ e.accept()
569
+ return
570
+ # on-node size stepper (+ / -) — resize without selecting/moving the node
571
+ if e.button() == Qt.LeftButton and self._resizable():
572
+ minus, plus = self._stepper_rects()
573
+ if minus.contains(e.pos()):
574
+ self._bump_size(-1); e.accept(); return
575
+ if plus.contains(e.pos()):
576
+ self._bump_size(+1); e.accept(); return
577
+ super().mousePressEvent(e)
578
+
579
+ def mouseReleaseEvent(self, e):
580
+ super().mouseReleaseEvent(e)
581
+ # dragging a node into / out of a VPC box reassigns its membership
582
+ self._scene.recompute_membership()
583
+
584
+ def mouseDoubleClickEvent(self, e):
585
+ # double-click to "log in" — open a terminal/console for this device. LEFT button only:
586
+ # a fast second RIGHT click arrives here too (Qt turns it into a DblClick), and opening a
587
+ # console because the student wired two elements in quick succession is nobody's intent.
588
+ if e.button() != Qt.LeftButton:
589
+ e.ignore()
590
+ return
591
+ self._scene.ctx.bus.device_activated.emit(self.inst.id)
592
+ super().mouseDoubleClickEvent(e)
593
+
594
+ def contextMenuEvent(self, e):
595
+ self.popup_menu(e.screenPos())
596
+ e.accept()
597
+
598
+ @staticmethod
599
+ def action_gates(running: bool, is_router: bool) -> dict:
600
+ """Which run-dependent menu actions are enabled. Console/logs need the lab up;
601
+ Log in does too, except a Router (its Router Lab opens offline)."""
602
+ return {"console": running, "logs": running, "login": running or is_router}
603
+
604
+ def popup_menu(self, screen_pos) -> None:
605
+ """Build + show this node's action menu. Reused by the view's right-click handler
606
+ (a plain right-click shows this; a right-drag connects instead)."""
607
+ from PySide6.QtWidgets import QMenu
608
+
609
+ from ..services.compiler import _role
610
+ self.setSelected(True)
611
+ menu = QMenu()
612
+ menu.setToolTipsVisible(True)
613
+ a_console = menu.addAction("Open console") # web dashboard (Grafana, MinIO, …)
614
+ a_login = menu.addAction("Log in")
615
+ a_logs = menu.addAction("View logs")
616
+ menu.addSeparator()
617
+ a_del = menu.addAction("Delete")
618
+ # these talk to live containers, so gate them on the lab being up. A Router is the
619
+ # exception for Log in — its Router Lab opens offline (with the local trace).
620
+ running = getattr(self._scene, "running", False)
621
+ gates = self.action_gates(running, _role(self.inst.type_key) == "router")
622
+ for act, key in ((a_console, "console"), (a_login, "login"), (a_logs, "logs")):
623
+ act.setEnabled(gates[key])
624
+ if not gates[key]:
625
+ act.setToolTip("Run the topology first")
626
+ chosen = menu.exec(screen_pos)
627
+ bus = self._scene.ctx.bus
628
+ if chosen == a_console:
629
+ bus.device_console_requested.emit(self.inst.id)
630
+ elif chosen == a_login:
631
+ bus.device_activated.emit(self.inst.id)
632
+ elif chosen == a_logs:
633
+ bus.device_logs_requested.emit(self.inst.id)
634
+ elif chosen == a_del:
635
+ bus.device_delete_requested.emit(self.inst.id)
636
+
637
+ def set_status(self, status: str) -> None:
638
+ self.status = status
639
+ self.update()
640
+
641
+ def set_board_addr(self, addr: str) -> None:
642
+ """Show (or clear) a real board's hotspot address. Repaints only on change —
643
+ this is called from a 3 s poll, and repainting every node every tick would put
644
+ the canvas under constant needless load."""
645
+ if addr == self.board_addr:
646
+ return
647
+ self.board_addr = addr
648
+ self.update()
649
+
650
+ def set_spotlight(self, on: bool) -> None:
651
+ self._spot = on
652
+ self.update()
653
+
654
+ def set_highlight(self, on: bool) -> None:
655
+ self._ring = on
656
+ self.update()
657
+
658
+
659
+ class EdgeItem(QGraphicsObject):
660
+ def __init__(self, scene: "CanvasScene", link: Link) -> None:
661
+ super().__init__()
662
+ self._scene = scene
663
+ self.link = link
664
+ self.setZValue(1)
665
+ self._path = QPainterPath()
666
+ self._packet_t: float | None = None
667
+ self._packet_color: QColor | None = None
668
+ self._flow_anim: QVariantAnimation | None = None
669
+ self.refresh()
670
+
671
+ def _is_attach(self) -> bool:
672
+ return getattr(self.link, "kind", "link") == "attach"
673
+
674
+ def _rider_role(self) -> str:
675
+ """'source' | 'sink' | '' — read from the rider end (source_id) of an attach edge."""
676
+ dev = self._scene.ctx.topology.devices.get(self.link.source_id)
677
+ return getattr(dev.type, "role", "") if dev is not None else ""
678
+
679
+ def flow(self, color: str | None = None, duration: int = 900) -> None:
680
+ """Animate a packet dot travelling along the edge (tutor + 'alive' feedback)."""
681
+ if self._is_attach():
682
+ return # attach edges carry no traffic — never animate
683
+ self._packet_color = _qcolor(color) if color else None
684
+ if self._scene.ctx.settings.reduced_motion:
685
+ return
686
+ if self._flow_anim is not None:
687
+ self._flow_anim.stop()
688
+ anim = QVariantAnimation(self)
689
+ anim.setStartValue(0.0)
690
+ anim.setEndValue(1.0)
691
+ anim.setDuration(duration)
692
+ anim.valueChanged.connect(self._set_packet)
693
+ anim.finished.connect(lambda: self._set_packet(None))
694
+ anim.start()
695
+ self._flow_anim = anim
696
+
697
+ def _set_packet(self, v) -> None:
698
+ self._packet_t = None if v is None else float(v)
699
+ self.update()
700
+
701
+ def refresh(self) -> None:
702
+ nodes = self._scene.nodes
703
+ a = nodes.get(self.link.source_id)
704
+ b = nodes.get(self.link.target_id)
705
+ if not a or not b:
706
+ return
707
+ self.prepareGeometryChange()
708
+ style = getattr(self._scene.ctx.settings, "connector_style", "orthogonal")
709
+ if style == "straight":
710
+ s = _uiscale()
711
+ ca = a.pos() + QPointF(NODE_W * s / 2, a.node_h() * s / 2)
712
+ cb = b.pos() + QPointF(NODE_W * s / 2, b.node_h() * s / 2)
713
+ path = QPainterPath(ca)
714
+ path.lineTo(cb)
715
+ self._path = path
716
+ else:
717
+ self._path = _rounded_path(_ortho_waypoints(a, b), CORNER_R)
718
+ self.update()
719
+
720
+ def boundingRect(self) -> QRectF:
721
+ if self._path.isEmpty():
722
+ return QRectF()
723
+ return self._path.boundingRect().adjusted(-4, -4, 4, 4)
724
+
725
+ def paint(self, p: QPainter, opt, widget=None) -> None:
726
+ if self._path.isEmpty():
727
+ return
728
+ t = self._scene.theme
729
+ p.setRenderHint(QPainter.Antialiasing, True)
730
+ p.setBrush(Qt.NoBrush) # open path: stroke only, never fill
731
+ if self._is_attach():
732
+ self._paint_attach(p, t)
733
+ return
734
+ p.setPen(QPen(_qcolor(t.line2), 2))
735
+ p.drawPath(self._path)
736
+ if self._packet_t is not None:
737
+ pt = self._path.pointAtPercent(self._packet_t)
738
+ col = self._packet_color or _qcolor(t.accent)
739
+ p.setBrush(col)
740
+ p.setPen(Qt.NoPen)
741
+ p.drawEllipse(pt, 5, 5)
742
+
743
+ def _paint_attach(self, p: QPainter, t) -> None:
744
+ """A rider mount: a DOTTED tether with a bold, accent-coloured polarity glyph at the MIDPOINT
745
+ (always on the visible line, whatever the length) — a big arrowhead pointing toward the donor
746
+ for a source (it injects INTO it), a diamond for a sink (it observes). Reads as an annotation
747
+ ('runs on'), never a cable."""
748
+ import math
749
+ dev = self._scene.ctx.topology.devices.get(self.link.source_id)
750
+ role = getattr(dev.type, "role", "") if dev is not None else ""
751
+ gcol = _qcolor(t.line) # match the dotted tether — big shape, subtle colour
752
+
753
+ pen = QPen(_qcolor(t.line), 2.4)
754
+ pen.setCapStyle(Qt.RoundCap)
755
+ pen.setDashPattern([1.4, 2.4])
756
+ p.setPen(pen)
757
+ p.setBrush(Qt.NoBrush)
758
+ p.drawPath(self._path)
759
+
760
+ c = self._path.pointAtPercent(0.5) # glyph at the midpoint of the tether
761
+ a0 = self._path.pointAtPercent(0.42)
762
+ a1 = self._path.pointAtPercent(0.58)
763
+ ang = math.atan2(a1.y() - a0.y(), a1.x() - a0.x()) # tangent, pointing toward the donor
764
+ dx, dy = math.cos(ang), math.sin(ang)
765
+ px, py = -math.sin(ang), math.cos(ang) # perpendicular
766
+ p.setPen(QPen(_qcolor(t.bg), 1.4)) # thin bg halo so it reads on any node
767
+ p.setBrush(gcol)
768
+ if role == "sink": # a bold diamond (observer)
769
+ r = 8.0
770
+ p.drawPolygon(QPolygonF([
771
+ QPointF(c.x() + r * dx, c.y() + r * dy), QPointF(c.x() + r * px, c.y() + r * py),
772
+ QPointF(c.x() - r * dx, c.y() - r * dy), QPointF(c.x() - r * px, c.y() - r * py)]))
773
+ else: # a big arrowhead into the donor
774
+ L, W = 16.0, 9.0
775
+ tip = QPointF(c.x() + (L / 2) * dx, c.y() + (L / 2) * dy)
776
+ bx, by = c.x() - (L / 2) * dx, c.y() - (L / 2) * dy
777
+ p.drawPolygon(QPolygonF([tip, QPointF(bx + W * px, by + W * py),
778
+ QPointF(bx - W * px, by - W * py)]))
779
+
780
+
781
+ class CalloutItem(QGraphicsObject):
782
+ """An anchored speech bubble the AI tutor places on a node."""
783
+
784
+ def __init__(self, scene: "CanvasScene", node: NodeItem, text: str) -> None:
785
+ super().__init__()
786
+ self._scene = scene
787
+ self._node = node
788
+ self.text = text
789
+ self.setZValue(60)
790
+ self._font = QFont()
791
+ self._font.setPointSize(_sp(10))
792
+ fm = QFontMetrics(self._font)
793
+ self._pad = 11
794
+ self._w = 200
795
+ br = fm.boundingRect(QRect(0, 0, self._w - 2 * self._pad, 1000),
796
+ Qt.TextWordWrap, text)
797
+ self._h = br.height() + 2 * self._pad
798
+ self._below = False
799
+ self.reposition()
800
+
801
+ def reposition(self) -> None:
802
+ np = self._node.pos()
803
+ above_y = np.y() - self._h - 16
804
+ self._below = False
805
+ scene = self.scene()
806
+ views = scene.views() if scene else []
807
+ if views:
808
+ vis = views[0].mapToScene(views[0].viewport().rect()).boundingRect()
809
+ if above_y < vis.top() + 6: # not enough room above -> go below
810
+ self._below = True
811
+ y = (np.y() + NODE_H + 16) if self._below else above_y
812
+ self.setPos(np.x() + NODE_W - 30, y)
813
+
814
+ def boundingRect(self) -> QRectF:
815
+ return QRectF(0, -12, self._w, self._h + 24)
816
+
817
+ def paint(self, p: QPainter, opt, widget=None) -> None:
818
+ t = self._scene.theme
819
+ p.setRenderHint(QPainter.Antialiasing, True)
820
+ accent = _qcolor(t.accent)
821
+ bubble = QRectF(0, 0, self._w, self._h)
822
+ p.setBrush(_qcolor(t.panel2))
823
+ p.setPen(QPen(accent, 1.6))
824
+ p.drawRoundedRect(bubble, 10, 10)
825
+ # tail points toward the node (down if bubble is above it, up if below)
826
+ if self._below:
827
+ tip, base_y = -11.0, 1.0
828
+ else:
829
+ tip, base_y = self._h + 11, self._h - 1
830
+ p.setBrush(_qcolor(t.panel2))
831
+ p.setPen(Qt.NoPen)
832
+ p.drawPolygon(QPolygonF([QPointF(26, base_y), QPointF(46, base_y), QPointF(26, tip)]))
833
+ p.setPen(QPen(accent, 1.6))
834
+ p.drawLine(QPointF(26, base_y), QPointF(26, tip))
835
+ p.drawLine(QPointF(26, tip), QPointF(46, base_y))
836
+ p.setPen(_qcolor(t.text))
837
+ p.setFont(self._font)
838
+ p.drawText(bubble.adjusted(self._pad, self._pad, -self._pad, -self._pad),
839
+ Qt.TextWordWrap, self.text)
840
+
841
+
842
+ class CanvasScene(QGraphicsScene):
843
+ def __init__(self, ctx: AppContext, theme: Theme) -> None:
844
+ super().__init__()
845
+ self.ctx = ctx
846
+ self.theme = theme
847
+ self.nodes: dict[str, NodeItem] = {}
848
+ self.edges: dict[str, EdgeItem] = {}
849
+ self.groups: dict[str, GroupItem] = {} # VPC/Subnet/Region container boxes
850
+ self.running = False # lab up? gates console/logs/login actions
851
+ self.setSceneRect(-2000, -2000, 4000, 4000)
852
+ self._cascade = 0
853
+ self._callouts: list[CalloutItem] = []
854
+ self._spotlit: list[NodeItem] = []
855
+ self._highlit: list[NodeItem] = []
856
+
857
+ ctx.bus.device_added.connect(self._on_device_added)
858
+ ctx.bus.device_removed.connect(self._on_device_removed)
859
+ ctx.bus.link_added.connect(self._on_link_added)
860
+ ctx.bus.device_changed.connect(self._on_device_changed)
861
+ ctx.bus.addressing_changed.connect(self._refresh_node_labels)
862
+ ctx.bus.warnings_changed.connect(self._on_warnings)
863
+ ctx.bus.mission_flags_changed.connect(self._on_mission_flags)
864
+ # tutor "present" channel
865
+ ctx.bus.present_spotlight.connect(self._on_spotlight)
866
+ ctx.bus.present_highlight.connect(self._on_highlight)
867
+ ctx.bus.present_callout.connect(self._on_callout)
868
+ ctx.bus.present_packet.connect(self._on_packet)
869
+ ctx.bus.present_clear.connect(self._on_clear_stage)
870
+ ctx.bus.addressing_changed.connect(self._on_addressing)
871
+ ctx.bus.edges_restyled.connect(self._on_restyle)
872
+ ctx.bus.mission_changed.connect(self._on_mission)
873
+
874
+ def _on_mission(self, _mission) -> None:
875
+ for n in self.nodes.values(): # show/hide off-goal ✕ badges
876
+ n.update()
877
+
878
+ def _on_restyle(self) -> None:
879
+ """Re-route every edge after the connector style (bent/straight) changes."""
880
+ for edge in self.edges.values():
881
+ edge.refresh()
882
+
883
+
884
+ def _on_addressing(self) -> None:
885
+ for n in self.nodes.values():
886
+ n.update()
887
+
888
+ def set_theme(self, theme: Theme) -> None:
889
+ self.theme = theme
890
+ self.update()
891
+ scale = _uiscale()
892
+ for n in self.nodes.values():
893
+ n.refresh_theme()
894
+ n.setScale(scale) # follow the current text-size setting (card grows with text)
895
+ n.update()
896
+ for g in self.groups.values():
897
+ g.update()
898
+ for e in self.edges.values():
899
+ e.refresh() # re-route: node extents changed with the scale
900
+ e.update()
901
+
902
+ def drawBackground(self, painter: QPainter, rect: QRectF) -> None:
903
+ painter.fillRect(rect, _qcolor(self.theme.bg))
904
+ if self.ctx.settings.grid:
905
+ painter.setPen(QPen(_qcolor(self.theme.grid), 1))
906
+ left = int(rect.left()) - (int(rect.left()) % GRID)
907
+ top = int(rect.top()) - (int(rect.top()) % GRID)
908
+ x = left
909
+ while x < rect.right():
910
+ painter.drawLine(int(x), int(rect.top()), int(x), int(rect.bottom()))
911
+ x += GRID
912
+ y = top
913
+ while y < rect.bottom():
914
+ painter.drawLine(int(rect.left()), int(y), int(rect.right()), int(y))
915
+ y += GRID
916
+ self._draw_vignette(painter, rect)
917
+ self._draw_slot_hulls(painter)
918
+
919
+ def _draw_slot_hulls(self, painter: QPainter) -> None:
920
+ """Draw a labelled hull around each group of slot-tagged elements.
921
+
922
+ A fragment's slot is a *parameter*: while authoring, the canvas holds a stand-in provider so
923
+ you can wire your delta to something real. Without this, that scaffolding is indistinguishable
924
+ from the fragment itself — you see a whole network and can't tell which third is yours. The
925
+ hull is DERIVED (the bounding box of everything sharing a slot tag), so nothing is positioned
926
+ by hand and it stays right when nodes move. Label = slot · what fills it · how many."""
927
+ groups: dict[str, list] = {}
928
+ for n in self.nodes.values():
929
+ s = getattr(n.inst, "slot", "")
930
+ if s:
931
+ groups.setdefault(s, []).append(n)
932
+ if not groups:
933
+ return
934
+ t = self.theme
935
+ painter.setRenderHint(QPainter.Antialiasing, True)
936
+ f = QFont(); f.setPointSize(9); f.setBold(True)
937
+ painter.setFont(f)
938
+ for name, items in sorted(groups.items()):
939
+ box = None
940
+ source = ""
941
+ for n in items:
942
+ source = source or getattr(n.inst, "slot_source", "")
943
+ r = QRectF(n.pos(), QSizeF(NODE_W * n.scale(), n.node_h() * n.scale()))
944
+ box = r if box is None else box.united(r)
945
+ if box is None:
946
+ continue
947
+ box = box.adjusted(-18, -26, 18, 18)
948
+ col = _qcolor(t.line)
949
+ fill = QColor(col); fill.setAlpha(16)
950
+ painter.setBrush(fill)
951
+ pen = QPen(col, 1.4)
952
+ pen.setStyle(Qt.DashLine) # dashed = a placeholder, not a real boundary
953
+ painter.setPen(pen)
954
+ painter.drawRoundedRect(box, 12, 12)
955
+ lbl = QColor(col); lbl.setAlpha(210)
956
+ painter.setPen(QPen(lbl, 1))
957
+ painter.drawText(QRectF(box.left() + 10, box.top() + 4, box.width() - 20, 18),
958
+ Qt.AlignLeft | Qt.AlignVCenter,
959
+ self._slot_label(name, len(items), source))
960
+
961
+ def _slot_label(self, name: str, n_items: int, source: str = "") -> str:
962
+ """`nets · cap-lan · 3 elements` — the slot, WHAT is filling it, and the group's size.
963
+
964
+ The composer labels members `root_lans0`, `root_lans1`, … — the trailing index is the member
965
+ number, so strip it for display and show the slot's own name."""
966
+ import re
967
+ base = name.split("_")[-1] if "_" in name else name
968
+ base = re.sub(r"\d+$", "", base) or base
969
+ head = f"{base} · {source}" if source else base
970
+ return f"{head} · {n_items} element(s)"
971
+
972
+ def _draw_vignette(self, painter: QPainter, rect: QRectF) -> None:
973
+ """A soft radial shade toward the viewport edges so the board reads as a
974
+ lit surface with depth. Anchored in scene coords (consistent across the
975
+ partial redraws drawBackground receives)."""
976
+ views = self.views()
977
+ if not views:
978
+ return
979
+ vp = views[0].viewport().rect()
980
+ center = views[0].mapToScene(vp.center())
981
+ corner = views[0].mapToScene(vp.topLeft())
982
+ radius = math.hypot(corner.x() - center.x(), corner.y() - center.y())
983
+ if radius <= 0:
984
+ return
985
+ edge = QColor(0, 0, 0, 78) if self.theme.dark else QColor(22, 30, 46, 30)
986
+ clear = QColor(edge.red(), edge.green(), edge.blue(), 0)
987
+ grad = QRadialGradient(center, radius)
988
+ grad.setColorAt(0.0, clear)
989
+ grad.setColorAt(0.58, clear)
990
+ grad.setColorAt(1.0, edge)
991
+ painter.fillRect(rect, QBrush(grad))
992
+
993
+ # -- model -> scene ----------------------------------------------------- #
994
+ def _on_device_added(self, device_id: str) -> None:
995
+ inst = self.ctx.topology.devices[device_id]
996
+ if inst.x == 0 and inst.y == 0:
997
+ self._cascade += 1
998
+ inst.x = -120 + (self._cascade % 6) * 150
999
+ inst.y = -90 + (self._cascade % 4) * 110
1000
+ if inst.type_key in grouping.BOX_TYPES: # VPC / Subnet / Region -> a box
1001
+ group = GroupItem(self, inst)
1002
+ self.groups[device_id] = group
1003
+ self.addItem(group)
1004
+ else:
1005
+ node = NodeItem(self, inst)
1006
+ self.nodes[device_id] = node
1007
+ self.addItem(node)
1008
+ self.recompute_membership() # a new box may capture nodes, or vice versa
1009
+ m = getattr(self.ctx, "mission", None) # Wizard: flag an off-goal drop
1010
+ if m is not None and not m.allows(inst.type_key):
1011
+ self.ctx.bus.present_callout.emit(
1012
+ device_id, f"Off-goal — not part of “{m.goal}”. Click the ✕ to remove it.")
1013
+
1014
+ def _on_device_removed(self, device_id: str) -> None:
1015
+ node = self.nodes.pop(device_id, None)
1016
+ if node:
1017
+ self.removeItem(node)
1018
+ group = self.groups.pop(device_id, None)
1019
+ if group:
1020
+ self.removeItem(group)
1021
+ # children of a deleted box fall back to no VPC (the flat bridge)
1022
+ for d in self.ctx.topology.devices.values():
1023
+ if d.parent_id == device_id:
1024
+ d.parent_id = None
1025
+ for lid in [l.id for l in list(self.ctx.topology.links.values())]:
1026
+ pass # links already pruned in model; clean orphan edges below
1027
+ for eid, edge in list(self.edges.items()):
1028
+ if eid not in self.ctx.topology.links:
1029
+ self.removeItem(edge)
1030
+ self.edges.pop(eid, None)
1031
+ if group:
1032
+ self.recompute_membership()
1033
+
1034
+ def _move_children(self, parent_id: str, dx: float, dy: float) -> None:
1035
+ """Move every item that belongs to `parent_id` by (dx, dy) so a box's contents
1036
+ travel with it (nested boxes recurse via their own itemChange)."""
1037
+ for item in list(self.nodes.values()) + list(self.groups.values()):
1038
+ if item.inst.parent_id == parent_id:
1039
+ item.setPos(item.pos().x() + dx, item.pos().y() + dy)
1040
+
1041
+ def recompute_membership(self) -> None:
1042
+ """Reassign every element's parent_id from where it now sits relative to the boxes.
1043
+ Emits topology_changed (so the compiler re-reads VPC membership) only on a change."""
1044
+ if not self.groups:
1045
+ # no boxes: clear any stale memberships (e.g., last box just deleted)
1046
+ changed = False
1047
+ for d in self.ctx.topology.devices.values():
1048
+ if d.parent_id is not None:
1049
+ d.parent_id = None
1050
+ changed = True
1051
+ if changed:
1052
+ self.ctx.bus.topology_changed.emit()
1053
+ return
1054
+ boxes = [(g.inst.id, g.pos().x(), g.pos().y(), float(g.inst.w), float(g.inst.h))
1055
+ for g in self.groups.values()]
1056
+ centers: dict = {}
1057
+ for n in self.nodes.values():
1058
+ c = n.sceneBoundingRect().center()
1059
+ centers[n.inst.id] = (c.x(), c.y())
1060
+ for g in self.groups.values():
1061
+ centers[g.inst.id] = (g.pos().x() + g.inst.w / 2.0,
1062
+ g.pos().y() + g.inst.h / 2.0)
1063
+ parent_of = {d.id: d.parent_id for d in self.ctx.topology.devices.values()}
1064
+ changed = False
1065
+ for did, pid in grouping.recompute(centers, boxes, parent_of).items():
1066
+ dev = self.ctx.topology.devices.get(did)
1067
+ if dev is not None and dev.parent_id != pid:
1068
+ dev.parent_id = pid
1069
+ changed = True
1070
+ if changed:
1071
+ self.ctx.bus.topology_changed.emit()
1072
+
1073
+ def _on_link_added(self, link_id: str) -> None:
1074
+ link = self.ctx.topology.links[link_id]
1075
+ edge = EdgeItem(self, link)
1076
+ self.edges[link_id] = edge
1077
+ self.addItem(edge)
1078
+ edge.flow() # subtle 'alive' feedback on connect
1079
+
1080
+ def _on_device_changed(self, device_id: str) -> None:
1081
+ node = self.nodes.get(device_id)
1082
+ if node:
1083
+ node.prepareGeometryChange() # size tier may change the node height
1084
+ node.update()
1085
+ self.update_edges_for(device_id)
1086
+ group = self.groups.get(device_id)
1087
+ if group:
1088
+ group.update() # repaint title/CIDR after an edit
1089
+
1090
+ def _refresh_node_labels(self) -> None:
1091
+ for node in self.nodes.values():
1092
+ node.update() # repaint IP labels after addressing changes
1093
+
1094
+ def _on_warnings(self) -> None:
1095
+ warns = self.ctx.warnings
1096
+ for node in self.nodes.values():
1097
+ msgs = warns.get(node.inst.name)
1098
+ node.setToolTip("\n".join(msgs) if msgs else "")
1099
+ node.update()
1100
+
1101
+ def _on_mission_flags(self) -> None:
1102
+ flags = getattr(self.ctx, "mission_flags", {})
1103
+ for node in self.nodes.values():
1104
+ reason = flags.get(node.inst.id)
1105
+ if reason:
1106
+ node.setToolTip(reason)
1107
+ node.update() # repaint red badge/glow (or clear it)
1108
+
1109
+ def update_edges_for(self, device_id: str) -> None:
1110
+ for edge in self.edges.values():
1111
+ if device_id in (edge.link.source_id, edge.link.target_id):
1112
+ edge.refresh()
1113
+
1114
+ # -- tutor "present" handlers ------------------------------------------- #
1115
+ def _on_spotlight(self, dev_ids) -> None:
1116
+ self._on_clear_stage()
1117
+ if not dev_ids:
1118
+ return
1119
+ targets = set(dev_ids)
1120
+ for nid, node in self.nodes.items():
1121
+ if nid in targets:
1122
+ node.set_spotlight(True)
1123
+ node.setOpacity(1.0)
1124
+ self._spotlit.append(node)
1125
+ else:
1126
+ node.setOpacity(0.7) # gently dim — topology stays clearly legible
1127
+ for edge in self.edges.values():
1128
+ edge.setOpacity(0.6)
1129
+
1130
+ def _on_highlight(self, dev_ids) -> None:
1131
+ for n in self._highlit:
1132
+ n.set_highlight(False)
1133
+ self._highlit = []
1134
+ for nid in (dev_ids or []):
1135
+ node = self.nodes.get(nid)
1136
+ if node:
1137
+ node.set_highlight(True)
1138
+ self._highlit.append(node)
1139
+
1140
+ def _on_callout(self, dev_id, text) -> None:
1141
+ node = self.nodes.get(dev_id)
1142
+ if node is None:
1143
+ return
1144
+ c = CalloutItem(self, node, text)
1145
+ self._callouts.append(c)
1146
+ self.addItem(c)
1147
+ c.reposition() # now in-scene: can flip below the node if it'd clip the top
1148
+
1149
+ def _on_packet(self, dev_ids) -> None:
1150
+ ids = list(dev_ids or [])
1151
+ for a, b in zip(ids, ids[1:]):
1152
+ edge = self._edge_between(a, b)
1153
+ if edge:
1154
+ edge.flow()
1155
+
1156
+ def _edge_between(self, a: str, b: str) -> "EdgeItem | None":
1157
+ for e in self.edges.values():
1158
+ if {e.link.source_id, e.link.target_id} == {a, b}:
1159
+ return e
1160
+ return None
1161
+
1162
+ def _on_clear_stage(self) -> None:
1163
+ for n in self._spotlit:
1164
+ n.set_spotlight(False)
1165
+ for n in self._highlit:
1166
+ n.set_highlight(False)
1167
+ self._spotlit = []
1168
+ self._highlit = []
1169
+ for node in self.nodes.values():
1170
+ node.setOpacity(1.0)
1171
+ for edge in self.edges.values():
1172
+ edge.setOpacity(1.0)
1173
+ for c in self._callouts:
1174
+ self.removeItem(c)
1175
+ self._callouts = []
1176
+
1177
+
1178
+ class LiveClientItem(QGraphicsObject):
1179
+ """A real device sitting on a GINI32 board's radio, right now.
1180
+
1181
+ This is the one thing on the canvas that nobody drew. It is OBSERVED: the board
1182
+ reports which devices are associated to its hotspot, and each becomes one of
1183
+ these, hanging off the board. It is never part of the topology, never saved, and
1184
+ cannot be selected or moved — because the person holding the phone owns it, not
1185
+ the person editing the diagram. It vanishes when the device walks away.
1186
+ """
1187
+ W, H = 118.0, 40.0
1188
+
1189
+ def __init__(self, theme, mac: str, ip: str) -> None:
1190
+ super().__init__()
1191
+ self._theme = theme
1192
+ self.mac = mac
1193
+ self.ip = ip
1194
+ self.setZValue(-0.5) # behind real elements
1195
+ self.setAcceptedMouseButtons(Qt.NoButton)
1196
+ # "?" is a real value here, not a missing key: a stock-built firmware cannot read
1197
+ # the DHCP leases (that needs GB_HAVE_STA_IPS) and reports every station as
1198
+ # `mac/?`. Rendering the sentinel straight into the sentence produced "on this
1199
+ # board's Wi-Fi as ?", which reads like a bug in the canvas rather than a
1200
+ # limitation of the board. Say what is actually known instead.
1201
+ where = (f"on this board's Wi-Fi as {ip}" if ip and ip != "?"
1202
+ else "on this board's Wi-Fi (address not reported by the board)")
1203
+ self.setToolTip(f"{mac}\n{where}\n"
1204
+ f"(a real device — not part of the saved topology)")
1205
+ self.setOpacity(0.0)
1206
+ self._fade(0.96)
1207
+
1208
+ def _fade(self, to: float) -> None:
1209
+ anim = QVariantAnimation(self)
1210
+ anim.setStartValue(self.opacity())
1211
+ anim.setEndValue(to)
1212
+ anim.setDuration(220)
1213
+ anim.setEasingCurve(QEasingCurve.OutCubic)
1214
+ anim.valueChanged.connect(self.setOpacity)
1215
+ anim.start(QVariantAnimation.DeleteWhenStopped)
1216
+ self._anim = anim
1217
+
1218
+ def boundingRect(self) -> QRectF:
1219
+ return QRectF(-2, -2, self.W + 4, self.H + 4)
1220
+
1221
+ def paint(self, p: QPainter, *_) -> None:
1222
+ t = self._theme # in canvas.py `theme` IS the palette
1223
+ p.setRenderHint(QPainter.Antialiasing, True)
1224
+ accent = QColor(t.accent_for("green"))
1225
+
1226
+ # dashed = "here now, not drawn": visually distinct from every real element
1227
+ path = QPainterPath()
1228
+ path.addRoundedRect(QRectF(0, 0, self.W, self.H), 9, 9)
1229
+ p.fillPath(path, QBrush(QColor(t.panel2)))
1230
+ pen = QPen(accent, 1.2, Qt.DashLine)
1231
+ pen.setDashPattern([4, 3])
1232
+ p.setPen(pen)
1233
+ p.drawPath(path)
1234
+
1235
+ # a small radio glyph, so it reads as "arrived over the air"
1236
+ p.setPen(QPen(accent, 1.4))
1237
+ cx, cy = 15.0, self.H / 2
1238
+ for r in (3.5, 7.0):
1239
+ p.drawArc(QRectF(cx - r, cy - r, r * 2, r * 2), -50 * 16, 100 * 16)
1240
+ p.setBrush(QBrush(accent))
1241
+ p.drawEllipse(QPointF(cx - 1, cy), 1.6, 1.6)
1242
+
1243
+ f = QFont(p.font()); f.setPointSizeF(8.4); f.setBold(True)
1244
+ p.setFont(f); p.setPen(QPen(QColor(t.text)))
1245
+ p.drawText(QRectF(28, 5, self.W - 34, 15), Qt.AlignVCenter | Qt.AlignLeft, self.ip)
1246
+ f.setBold(False); f.setPointSizeF(7.2)
1247
+ p.setFont(f); p.setPen(QPen(QColor(t.muted)))
1248
+ fm = QFontMetrics(f)
1249
+ p.drawText(QRectF(28, 20, self.W - 34, 14), Qt.AlignVCenter | Qt.AlignLeft,
1250
+ fm.elidedText(self.mac, Qt.ElideMiddle, int(self.W - 36)))
1251
+
1252
+
1253
+ class GhostItem(QGraphicsObject):
1254
+ """A translucent preview of a placeable neighbour, drawn during X-ray. Tap it and the
1255
+ view turns it into a real element wired to the long-pressed node. A ghost with
1256
+ ``type_key is None`` is the non-actionable '+N more' chip."""
1257
+ W, H = 156.0, 52.0
1258
+
1259
+ def __init__(self, view, target_id, type_key, why, required) -> None:
1260
+ super().__init__()
1261
+ self._view = view
1262
+ self.target_id = target_id
1263
+ self.type_key = type_key
1264
+ self.why = why
1265
+ self.required = required
1266
+ self._hover = False
1267
+ self.setAcceptHoverEvents(type_key is not None)
1268
+ self.setOpacity(0.0)
1269
+ self._fade_in()
1270
+
1271
+ def _fade_in(self) -> None:
1272
+ if self._view.ctx.settings.reduced_motion:
1273
+ self.setOpacity(0.95 if self.type_key else 0.7)
1274
+ return
1275
+ anim = QVariantAnimation(self)
1276
+ anim.setStartValue(0.0)
1277
+ anim.setEndValue(0.95 if self.type_key else 0.7)
1278
+ anim.setDuration(160)
1279
+ anim.setEasingCurve(QEasingCurve.OutCubic)
1280
+ anim.valueChanged.connect(lambda v: self.setOpacity(float(v)))
1281
+ anim.start()
1282
+ self._anim = anim
1283
+
1284
+ def boundingRect(self) -> QRectF:
1285
+ return QRectF(0, 0, self.W, self.H)
1286
+
1287
+ def hoverEnterEvent(self, e):
1288
+ self._hover = True; self.setOpacity(1.0); self.update()
1289
+
1290
+ def hoverLeaveEvent(self, e):
1291
+ self._hover = False; self.setOpacity(0.95); self.update()
1292
+
1293
+ def paint(self, p: QPainter, opt, widget=None) -> None:
1294
+ from ..domain.devices import REGISTRY
1295
+ t = self._view.scene_.theme
1296
+ p.setRenderHint(QPainter.Antialiasing, True)
1297
+ rect = QRectF(1, 1, self.W - 2, self.H - 2)
1298
+
1299
+ if self.type_key is None: # the "+N more" chip
1300
+ p.setBrush(_qcolor(t.panel2))
1301
+ p.setPen(QPen(_qcolor(t.line2), 1.4, Qt.DashLine))
1302
+ p.drawRoundedRect(rect, 10, 10)
1303
+ p.setPen(_qcolor(t.muted))
1304
+ f = QFont(); f.setPointSize(_sp(10)); f.setBold(True); p.setFont(f)
1305
+ p.drawText(rect, Qt.AlignCenter, self.why)
1306
+ return
1307
+
1308
+ dt = REGISTRY[self.type_key]
1309
+ accent = _qcolor(t.accent_for(dt.accent.value))
1310
+ p.setBrush(_qcolor(t.panel2 if not self._hover else t.panel))
1311
+ pen = QPen(accent, 2.4 if self._hover else 1.6)
1312
+ pen.setStyle(Qt.SolidLine if self._hover else Qt.DashLine)
1313
+ p.setPen(pen)
1314
+ p.drawRoundedRect(rect, 10, 10)
1315
+
1316
+ soft = QColor(accent); soft.setAlpha(40)
1317
+ p.setBrush(soft); p.setPen(Qt.NoPen)
1318
+ p.drawRoundedRect(QRectF(9, 11, 30, 30), 7, 7)
1319
+ p.drawPixmap(14, 16, icons.render_pixmap(dt.icon, t.accent_for(dt.accent.value), size=20))
1320
+
1321
+ p.setPen(_qcolor(t.text))
1322
+ f = QFont(); f.setPointSize(_sp(10)); f.setWeight(QFont.DemiBold); p.setFont(f)
1323
+ p.drawText(QRectF(46, 7, self.W - 52, 16), Qt.AlignVCenter, dt.label)
1324
+
1325
+ sub = QRectF(46, 26, self.W - 52, 16)
1326
+ if self._hover:
1327
+ p.setPen(accent)
1328
+ fh = QFont(); fh.setPointSize(_sp(8)); fh.setBold(True); p.setFont(fh)
1329
+ p.drawText(sub, Qt.AlignVCenter, "+ tap to add")
1330
+ else:
1331
+ p.setPen(_qcolor(t.faint))
1332
+ fw = QFont(); fw.setPointSize(_sp(8)); p.setFont(fw)
1333
+ fm = p.fontMetrics()
1334
+ txt = ("needed · " if self.required else "") + self.why
1335
+ p.drawText(sub, Qt.AlignVCenter, fm.elidedText(txt, Qt.ElideRight, int(self.W - 54)))
1336
+
1337
+ if self.required: # a small accent dot = "this link is needed"
1338
+ p.setBrush(accent); p.setPen(Qt.NoPen)
1339
+ p.drawEllipse(QRectF(self.W - 16, 9, 7, 7))
1340
+
1341
+ def mousePressEvent(self, e):
1342
+ if e.button() == Qt.LeftButton and self.type_key is not None:
1343
+ self._view._activate_ghost(self)
1344
+ e.accept()
1345
+ return
1346
+ super().mousePressEvent(e)
1347
+
1348
+
1349
+ class CanvasView(QGraphicsView):
1350
+ def __init__(self, ctx: AppContext, theme: Theme) -> None:
1351
+ self.scene_ = CanvasScene(ctx, theme)
1352
+ super().__init__(self.scene_)
1353
+ self.ctx = ctx
1354
+ self.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing)
1355
+ self.setDragMode(QGraphicsView.RubberBandDrag)
1356
+ self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
1357
+ self.setAcceptDrops(True)
1358
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
1359
+ self._connect_mode = False
1360
+ self._connect_first: str | None = None
1361
+ self._zoom = 1.0
1362
+ # right-drag-to-connect (the book's gesture): we drive right-click ourselves so a
1363
+ # plain right-click shows the node menu and a right-DRAG creates a link.
1364
+ self.setContextMenuPolicy(Qt.PreventContextMenu)
1365
+ self._rc_from: NodeItem | None = None
1366
+ self._rc_moved = False
1367
+ self._rc_start = None
1368
+ self._rc_line = None
1369
+ # connect mode accepts EITHER gesture from the same press: a drag (release on the target) or
1370
+ # a click-click (press again on the target). Both are natural, students use both, and having
1371
+ # only one of them work was the single most frustrating thing in the app.
1372
+ self._cn_from: NodeItem | None = None
1373
+ self._cn_start = None
1374
+ self._cn_dragged = False
1375
+
1376
+ # X-ray: long-press a node to reveal what it can connect to
1377
+ self._lp_node: NodeItem | None = None
1378
+ self._lp_start = None
1379
+ self._lp_timer = QTimer(self)
1380
+ self._lp_timer.setSingleShot(True)
1381
+ self._lp_timer.timeout.connect(self._fire_xray)
1382
+ self._xray_on = False
1383
+ self._xray_items: list = [] # ghost cards + connector lines on the scene
1384
+ self._ghosts: list = [] # the clickable ghost cards
1385
+ self._xray_target: NodeItem | None = None
1386
+ self._xray_timer = QTimer(self) # auto-dismiss the overlay after a while
1387
+ self._xray_timer.setSingleShot(True)
1388
+ self._xray_timer.timeout.connect(self.clear_xray)
1389
+
1390
+ # tutor narration banner (overlaid on the viewport)
1391
+ self._narration = QLabel(self)
1392
+ self._narration.setObjectName("Narration")
1393
+ self._narration.setWordWrap(True)
1394
+ self._narration.hide()
1395
+ self._narr_timer = QTimer(self)
1396
+ self._narr_timer.setSingleShot(True)
1397
+ self._narr_timer.timeout.connect(self._narration.hide)
1398
+ ctx.bus.present_narrate.connect(self._show_narration)
1399
+ ctx.bus.focus_requested.connect(self.focus_on_devices) # e.g. a staged mission board
1400
+ ctx.bus.present_clear.connect(self._narration.hide)
1401
+ # Wizard: the assistant resolves goal-relevant neighbours (LLM) and hands them back
1402
+ ctx.bus.wizard_ghosts_requested.connect(self._on_wizard_requested)
1403
+ ctx.bus.wizard_ghosts_ready.connect(self._on_wizard_ghosts)
1404
+ ctx.bus.mission_changed.connect(lambda m: m is None and self.clear_xray())
1405
+
1406
+ def set_live_clients(self, live: dict) -> dict:
1407
+ """Reconcile the devices currently on each board's radio with what is drawn.
1408
+
1409
+ `live` maps a GINI32 element id -> [{mac, ip}, ...]. Returns
1410
+ {'joined': [...], 'left': [...]} so the caller can narrate the change.
1411
+ These items are ephemeral by construction: nothing here touches the topology.
1412
+ """
1413
+ existing = getattr(self, "_live_clients", None)
1414
+ if existing is None:
1415
+ existing = self._live_clients = {} # (device_id, mac) -> item
1416
+
1417
+ want: dict[tuple, dict] = {}
1418
+ for did, clients in (live or {}).items():
1419
+ for c in clients:
1420
+ want[(did, c.get("mac", ""))] = c
1421
+
1422
+ joined, left = [], []
1423
+
1424
+ for key in list(existing):
1425
+ if key not in want:
1426
+ item = existing.pop(key)
1427
+ if item.scene() is not None:
1428
+ item.scene().removeItem(item)
1429
+ left.append(f"{key[1]}")
1430
+
1431
+ for key, c in want.items():
1432
+ if key in existing:
1433
+ continue
1434
+ did, mac = key
1435
+ node = self.scene_.nodes.get(did)
1436
+ if node is None:
1437
+ continue
1438
+ item = LiveClientItem(self.scene_.theme, mac, c.get("ip", "?"))
1439
+ self.scene_.addItem(item)
1440
+ existing[key] = item
1441
+ joined.append(f"{mac} ({c.get('ip', '?')})")
1442
+
1443
+ # lay each board's devices out beneath it, stacked
1444
+ for did in {k[0] for k in existing}:
1445
+ node = self.scene_.nodes.get(did)
1446
+ if node is None:
1447
+ continue
1448
+ mine = [existing[k] for k in sorted(existing) if k[0] == did]
1449
+ for i, item in enumerate(mine):
1450
+ item.setPos(node.x() + (NODE_W - LiveClientItem.W) / 2,
1451
+ node.y() + NODE_H + 16 + i * (LiveClientItem.H + 6))
1452
+
1453
+ return {"joined": joined, "left": left}
1454
+
1455
+ def clear_live_clients(self) -> None:
1456
+ """Drop every observed device — the lab stopped, so we know nothing."""
1457
+ for item in getattr(self, "_live_clients", {}).values():
1458
+ if item.scene() is not None:
1459
+ item.scene().removeItem(item)
1460
+ self._live_clients = {}
1461
+
1462
+ def _show_narration(self, text: str) -> None:
1463
+ th = self.scene_.theme
1464
+ self._narration.setStyleSheet(
1465
+ f"#Narration{{background:{th.panel2};color:{th.text};"
1466
+ f"border:1.5px solid {th.accent};border-radius:12px;"
1467
+ f"padding:10px 14px;font-size:13px;}}")
1468
+ self._narration.setText("\U0001F916 " + text)
1469
+ self._position_narration()
1470
+ self._narration.show()
1471
+ self._narration.raise_()
1472
+ self._narr_timer.start(7000)
1473
+
1474
+ def _position_narration(self) -> None:
1475
+ margin = 18
1476
+ w = min(560, max(200, self.viewport().width() - 2 * margin))
1477
+ self._narration.setFixedWidth(w)
1478
+ self._narration.adjustSize()
1479
+ x = (self.width() - self._narration.width()) // 2
1480
+ y = self.height() - self._narration.height() - margin
1481
+ self._narration.move(x, y)
1482
+
1483
+ def resizeEvent(self, e) -> None:
1484
+ super().resizeEvent(e)
1485
+ if self._narration.isVisible():
1486
+ self._position_narration()
1487
+
1488
+ # -- X-ray: long-press spawns ghost previews of the valid neighbours ----- #
1489
+ MAX_GHOSTS = 8 # cap the ring so it stays readable
1490
+
1491
+ def _fire_xray(self) -> None:
1492
+ from ..domain import connection_rules as cr
1493
+ node = self._lp_node
1494
+ self._lp_node = None
1495
+ if node is None or node.inst.id not in self.ctx.topology.devices:
1496
+ return
1497
+ if getattr(self.ctx, "mission", None) is not None:
1498
+ # Wizard: the assistant resolves goal-relevant neighbours via the model (async)
1499
+ self.ctx.bus.wizard_ghosts_requested.emit(node.inst.id)
1500
+ return
1501
+ partners = cr.partners_for(node.inst.type_key)
1502
+ if not partners:
1503
+ return
1504
+ self.clear_xray()
1505
+ self._xray_target = node
1506
+ node.set_xray("target") # ring the pressed element
1507
+ self._build_ghosts(node, partners)
1508
+ self._xray_on = True
1509
+ self._xray_timer.start(15000)
1510
+
1511
+ # -- Wizard X-ray: async, model-filtered ghosts --------------------------- #
1512
+ def _on_wizard_requested(self, device_id: str) -> None:
1513
+ node = self.scene_.nodes.get(device_id)
1514
+ if node is None:
1515
+ return
1516
+ self.clear_xray()
1517
+ self._xray_target = node
1518
+ self._xray_on = True
1519
+ node.set_xray("target")
1520
+ self._show_thinking(node) # placeholder until the model answers
1521
+ self._xray_timer.start(20000)
1522
+
1523
+ def _show_thinking(self, node) -> None:
1524
+ import math
1525
+ c = node.sceneBoundingRect().center()
1526
+ g = GhostItem(self, node.inst.id, None, "thinking…", False)
1527
+ g.setPos(c.x() - GhostItem.W / 2, c.y() - 215.0 - GhostItem.H / 2)
1528
+ g.setZValue(20)
1529
+ self.scene_.addItem(g)
1530
+ self._xray_items.append(g)
1531
+
1532
+ def _on_wizard_ghosts(self, device_id: str, items) -> None:
1533
+ if not self._xray_on or self._xray_target is None \
1534
+ or self._xray_target.inst.id != device_id:
1535
+ return
1536
+ for it in self._xray_items: # drop the "thinking…" chip (keep the ring)
1537
+ self.scene_.removeItem(it)
1538
+ self._xray_items.clear(); self._ghosts.clear()
1539
+ self._build_ghosts_items(self._xray_target, list(items))
1540
+
1541
+ def _build_ghosts(self, node, partners) -> None:
1542
+ import math
1543
+ from PySide6.QtGui import QColor, QPen
1544
+ from PySide6.QtWidgets import QGraphicsLineItem
1545
+ shown = partners[: self.MAX_GHOSTS]
1546
+ extra = len(partners) - len(shown)
1547
+ slots = len(shown) + (1 if extra > 0 else 0)
1548
+ c = node.sceneBoundingRect().center()
1549
+ rx, ry = 215.0, 175.0 # elliptical ring (wider than tall)
1550
+ pen = QPen(QColor(self.scene_.theme.accent), 1.8, Qt.DashLine)
1551
+ for i in range(slots):
1552
+ ang = -math.pi / 2 + i * (2 * math.pi / slots)
1553
+ gx, gy = c.x() + rx * math.cos(ang), c.y() + ry * math.sin(ang)
1554
+ line = QGraphicsLineItem(c.x(), c.y(), gx, gy)
1555
+ line.setPen(pen); line.setOpacity(0.5); line.setZValue(5)
1556
+ self.scene_.addItem(line); self._xray_items.append(line)
1557
+ if extra > 0 and i == slots - 1:
1558
+ g = GhostItem(self, node.inst.id, None, f"+{extra} more", False)
1559
+ else:
1560
+ p = shown[i]
1561
+ g = GhostItem(self, node.inst.id, p.type_key, p.why, p.required)
1562
+ g.setPos(gx - GhostItem.W / 2, gy - GhostItem.H / 2)
1563
+ g.setZValue(20)
1564
+ self.scene_.addItem(g); self._xray_items.append(g)
1565
+ if g.type_key is not None:
1566
+ self._ghosts.append(g)
1567
+
1568
+ def _build_ghosts_items(self, node, items) -> None:
1569
+ """Build the ghost ring from model-chosen (type_key, reason) pairs."""
1570
+ import math
1571
+ from PySide6.QtGui import QColor, QPen
1572
+ from PySide6.QtWidgets import QGraphicsLineItem
1573
+ if not items:
1574
+ self._show_thinking(node) # nothing relevant — leave a gentle note
1575
+ self._xray_items[-1].why = "no goal-relevant options here"
1576
+ self._xray_items[-1].update()
1577
+ return
1578
+ shown = items[: self.MAX_GHOSTS]
1579
+ extra = len(items) - len(shown)
1580
+ slots = len(shown) + (1 if extra > 0 else 0)
1581
+ c = node.sceneBoundingRect().center()
1582
+ rx, ry = 215.0, 175.0
1583
+ pen = QPen(QColor(self.scene_.theme.accent), 1.8, Qt.DashLine)
1584
+ for i in range(slots):
1585
+ ang = -math.pi / 2 + i * (2 * math.pi / slots)
1586
+ gx, gy = c.x() + rx * math.cos(ang), c.y() + ry * math.sin(ang)
1587
+ line = QGraphicsLineItem(c.x(), c.y(), gx, gy)
1588
+ line.setPen(pen); line.setOpacity(0.5); line.setZValue(5)
1589
+ self.scene_.addItem(line); self._xray_items.append(line)
1590
+ if extra > 0 and i == slots - 1:
1591
+ g = GhostItem(self, node.inst.id, None, f"+{extra} more", False)
1592
+ else:
1593
+ type_key, reason = shown[i]
1594
+ g = GhostItem(self, node.inst.id, type_key, reason, False)
1595
+ g.setPos(gx - GhostItem.W / 2, gy - GhostItem.H / 2)
1596
+ g.setZValue(20)
1597
+ self.scene_.addItem(g); self._xray_items.append(g)
1598
+ if g.type_key is not None:
1599
+ self._ghosts.append(g)
1600
+
1601
+ def _ghost_at(self, view_pos):
1602
+ item = self.itemAt(view_pos)
1603
+ while item is not None and not isinstance(item, GhostItem):
1604
+ item = item.parentItem()
1605
+ return item if isinstance(item, GhostItem) else None
1606
+
1607
+ def _activate_ghost(self, ghost) -> None:
1608
+ """Tap a ghost -> create that element where it sits, wired to the pressed node."""
1609
+ if ghost.type_key is None: # the "+N more" chip is not actionable
1610
+ self.clear_xray()
1611
+ return
1612
+ pos, target_id, type_key = ghost.pos(), ghost.target_id, ghost.type_key
1613
+ self.clear_xray()
1614
+ if target_id not in self.ctx.topology.devices:
1615
+ return
1616
+ try:
1617
+ inst = self.ctx.add_device(type_key, x=pos.x(), y=pos.y())
1618
+ self.ctx.connect(target_id, inst.id)
1619
+ self.ctx.select(inst.id)
1620
+ except Exception as ex: # noqa: BLE001
1621
+ self.ctx.log(f"Couldn't add element: {ex}", "info")
1622
+ return
1623
+ if getattr(self.ctx, "mission", None) is not None: # auto-walk: ghosts for the new one
1624
+ self.ctx.bus.wizard_ghosts_requested.emit(inst.id)
1625
+
1626
+ def clear_xray(self) -> None:
1627
+ self._xray_on = False
1628
+ self._xray_timer.stop()
1629
+ for it in self._xray_items:
1630
+ self.scene_.removeItem(it)
1631
+ self._xray_items.clear()
1632
+ self._ghosts.clear()
1633
+ if self._xray_target is not None:
1634
+ self._xray_target.set_xray(None)
1635
+ self._xray_target = None
1636
+
1637
+ def keyPressEvent(self, e) -> None:
1638
+ if e.key() == Qt.Key_Escape and self._xray_on:
1639
+ self.clear_xray()
1640
+ e.accept()
1641
+ return
1642
+ super().keyPressEvent(e)
1643
+
1644
+ # zoom with Ctrl+wheel ------------------------------------------------- #
1645
+ def wheelEvent(self, e) -> None:
1646
+ if e.modifiers() & Qt.ControlModifier:
1647
+ factor = 1.15 if e.angleDelta().y() > 0 else 1 / 1.15
1648
+ new = self._zoom * factor
1649
+ if 0.3 < new < 3.0:
1650
+ self._zoom = new
1651
+ self.scale(factor, factor)
1652
+ else:
1653
+ super().wheelEvent(e)
1654
+
1655
+ # -- finding things that are off-screen ---------------------------------- #
1656
+ def visible_scene_rect(self) -> QRectF:
1657
+ return self.mapToScene(self.viewport().rect()).boundingRect()
1658
+
1659
+ def focus_on_rect(self, rect: QRectF, *, margin: float = 70.0) -> None:
1660
+ """Bring `rect` into view — zoom out to fit only if it doesn't already fit, else just pan."""
1661
+ if rect.isNull() or rect.isEmpty():
1662
+ return
1663
+ r = rect.adjusted(-margin, -margin, margin, margin)
1664
+ vis = self.visible_scene_rect()
1665
+ if r.width() > vis.width() or r.height() > vis.height():
1666
+ self.fitInView(r, Qt.KeepAspectRatio)
1667
+ self._zoom = self.transform().m11()
1668
+ else:
1669
+ self.centerOn(r.center())
1670
+
1671
+ def focus_on_devices(self, ids=None) -> None:
1672
+ """Frame the given devices (or everything, when ids is None). Used after a mission STAGES a
1673
+ board — otherwise the pre-built elements can land off-screen and the student thinks the
1674
+ mission placed nothing (they shouldn't have to hunt for it)."""
1675
+ items = list(self.scene_.nodes.values()) + list(self.scene_.groups.values())
1676
+ if ids:
1677
+ want = set(ids)
1678
+ items = [i for i in items if i.inst.id in want]
1679
+ if not items:
1680
+ return
1681
+ rect = items[0].sceneBoundingRect()
1682
+ for it in items[1:]:
1683
+ rect = rect.united(it.sceneBoundingRect())
1684
+ self.focus_on_rect(rect)
1685
+
1686
+ def drawForeground(self, painter: QPainter, rect: QRectF) -> None:
1687
+ """Edge markers for elements that are OFF-SCREEN — a coloured dot pinned to the edge of the
1688
+ viewport in the direction of each hidden element, so a student can always see that something
1689
+ exists out there and which way to scroll."""
1690
+ super().drawForeground(painter, rect)
1691
+ scene = self.scene_
1692
+ if scene is None:
1693
+ return
1694
+ vis = self.visible_scene_rect()
1695
+ z = max(self._zoom, 0.05)
1696
+ inset = 13.0 / z # keep the dot ~13px inside the edge, at any zoom
1697
+ bounds = vis.adjusted(inset, inset, -inset, -inset)
1698
+ if bounds.width() <= 0 or bounds.height() <= 0:
1699
+ return
1700
+ radius = 5.0 / z
1701
+ theme = getattr(scene, "theme", None)
1702
+ painter.save()
1703
+ try:
1704
+ painter.setRenderHint(QPainter.Antialiasing, True)
1705
+ for it in list(scene.nodes.values()) + list(scene.groups.values()):
1706
+ b = it.sceneBoundingRect()
1707
+ if vis.intersects(b):
1708
+ continue # visible — nothing to point at
1709
+ c = b.center()
1710
+ x = min(max(c.x(), bounds.left()), bounds.right())
1711
+ y = min(max(c.y(), bounds.top()), bounds.bottom())
1712
+ try:
1713
+ col = _qcolor(theme.accent_for(it.inst.type.accent.value))
1714
+ except Exception:
1715
+ col = QColor(120, 140, 170)
1716
+ painter.setBrush(QBrush(col))
1717
+ painter.setPen(QPen(QColor(255, 255, 255, 200), 1.5 / z))
1718
+ painter.drawEllipse(QPointF(x, y), radius, radius)
1719
+ finally:
1720
+ painter.restore() # never leave the painter unbalanced
1721
+
1722
+ def zoom_by(self, factor: float) -> None:
1723
+ new = self._zoom * factor
1724
+ if 0.3 < new < 3.0:
1725
+ self._zoom = new
1726
+ self.scale(factor, factor)
1727
+
1728
+ def reset_zoom(self) -> None:
1729
+ self.resetTransform()
1730
+ self._zoom = 1.0
1731
+
1732
+ # connect mode --------------------------------------------------------- #
1733
+ def set_connect_mode(self, on: bool) -> None:
1734
+ self._connect_mode = on
1735
+ self._end_connect_gesture() # never leave a half-drawn wire behind
1736
+ self.setCursor(Qt.CrossCursor if on else Qt.ArrowCursor)
1737
+ self.setDragMode(QGraphicsView.NoDrag if on else QGraphicsView.RubberBandDrag)
1738
+
1739
+ @staticmethod
1740
+ def _as_node(item) -> "NodeItem | None":
1741
+ """Climb from a graphics item to the NodeItem that owns it (an icon, a label and a badge are
1742
+ all children of the node)."""
1743
+ while item is not None and not isinstance(item, NodeItem):
1744
+ item = item.parentItem()
1745
+ return item if isinstance(item, NodeItem) else None
1746
+
1747
+ def _node_at(self, view_pos, *, slack: int = 0) -> "NodeItem | None":
1748
+ """The node under the cursor — the ONE hit test every gesture uses.
1749
+
1750
+ Two things it has to get right, both of which used to make wiring feel like a coin flip:
1751
+
1752
+ * Look THROUGH whatever is on top. `itemAt()` returns only the topmost item, so an edge
1753
+ crossing the icon, a VPC box, or a callout would mask the node entirely: the press landed
1754
+ on "empty canvas" even though the cursor was dead-centre on the element. That's why the
1755
+ gesture worked on a fresh canvas and got flakier the more you drew — the failure rate was
1756
+ really the odds of something overlapping the icon. We scan the whole stack, top to bottom,
1757
+ and take the first NodeItem in it.
1758
+
1759
+ * `slack` widens the hit box by a few pixels, because a 3px miss on a 40px icon is a miss by
1760
+ the user's standards but a hit by their intent. EVERY connect gesture passes slack now —
1761
+ the right-drag path didn't, which was the other half of the coin flip.
1762
+ """
1763
+ for it in self.items(view_pos): # the full stack under the cursor, topmost first
1764
+ node = self._as_node(it)
1765
+ if node is not None:
1766
+ return node
1767
+ if slack <= 0:
1768
+ return None
1769
+ from PySide6.QtCore import QRect
1770
+ rect = QRect(view_pos.x() - slack, view_pos.y() - slack, slack * 2, slack * 2)
1771
+ for it in self.items(rect):
1772
+ node = self._as_node(it)
1773
+ if node is not None:
1774
+ return node
1775
+ return None
1776
+
1777
+ def mousePressEvent(self, e) -> None:
1778
+ if self._xray_on:
1779
+ ghost = self._ghost_at(e.pos()) if e.button() == Qt.LeftButton else None
1780
+ if ghost is not None: # tap a ghost -> add it (already connected)
1781
+ self._activate_ghost(ghost)
1782
+ e.accept()
1783
+ return
1784
+ self.clear_xray() # click anywhere else dismisses the overlay
1785
+ # a left-click on empty canvas exits sticky modes (connect / explain). Emit BEFORE
1786
+ # the connect handling so the mode is off by the time we get there. In connect mode we
1787
+ # allow a few px of slack, or a near-miss on an icon cancels the mode mid-gesture.
1788
+ if e.button() == Qt.LeftButton and self._node_at(
1789
+ e.pos(), slack=_HIT_SLACK if self._connect_mode else 0) is None:
1790
+ self.ctx.bus.canvas_background_clicked.emit()
1791
+ # arm the long-press X-ray on a plain left-press over a node (cancelled by drag)
1792
+ if (e.button() == Qt.LeftButton and not self._connect_mode):
1793
+ node = self._node_at(e.pos())
1794
+ if node is not None:
1795
+ self._lp_node = node
1796
+ self._lp_start = e.pos()
1797
+ self._lp_timer.start(480)
1798
+ if self._connect_mode and e.button() == Qt.LeftButton:
1799
+ node = self._node_at(e.pos(), slack=_HIT_SLACK)
1800
+ if node is not None:
1801
+ devices = self.ctx.topology.devices
1802
+ # drop a stale first endpoint (its device was deleted, or the project was
1803
+ # reloaded between clicks) so we never link to a gone device
1804
+ if self._connect_first is not None and self._connect_first not in devices:
1805
+ self._connect_first = None
1806
+ if self._connect_first is None:
1807
+ # ARM. This press may become either gesture — a drag (release on the target) or
1808
+ # a click-click (press again on the target). We commit to neither yet; both end
1809
+ # up in the same place, so the student can't pick "the wrong one".
1810
+ self._connect_first = node.inst.id
1811
+ self._cn_from = node
1812
+ self._cn_start = e.pos()
1813
+ self._cn_dragged = False
1814
+ elif self._connect_first != node.inst.id:
1815
+ self._link(self._connect_first, node.inst.id)
1816
+ self._end_connect_gesture()
1817
+ e.accept()
1818
+ return
1819
+ # right-button on a node: maybe a connect-drag, maybe the context menu (decided
1820
+ # on release by whether the mouse moved). Same slack as connect mode — this path had none,
1821
+ # so a 3px miss meant the press fell through and the drag simply never started.
1822
+ if e.button() == Qt.RightButton:
1823
+ node = self._node_at(e.pos(), slack=_HIT_SLACK)
1824
+ if node is not None:
1825
+ self._trace(f"right-PRESS at {e.pos().x()},{e.pos().y()} → armed on {node.inst.name}")
1826
+ else:
1827
+ self._trace(f"right-PRESS at {e.pos().x()},{e.pos().y()} → NO NODE. "
1828
+ + self._miss_report(e.pos()))
1829
+ if node is not None:
1830
+ self._rc_from = node
1831
+ self._rc_start = e.pos()
1832
+ self._rc_moved = False
1833
+ e.accept()
1834
+ return
1835
+ super().mousePressEvent(e)
1836
+
1837
+ def _miss_report(self, view_pos) -> str:
1838
+ """When a press finds no node, say exactly WHY — the two possible answers need different
1839
+ fixes and 'sometimes it works' cannot distinguish them:
1840
+
1841
+ * items under the cursor are non-node things → something is MASKING the node (hit test);
1842
+ * nothing under the cursor and the nearest node is N px away → the press really did land
1843
+ on empty canvas, and the question becomes why the cursor was where the user didn't think
1844
+ it was (coordinate-space bug: widget vs viewport vs scene).
1845
+ """
1846
+ under = [type(i).__name__ for i in self.items(view_pos)]
1847
+ sp = self.mapToScene(view_pos)
1848
+ best, best_d = None, 1e9
1849
+ for n in self.scene_.nodes.values():
1850
+ r = n.sceneBoundingRect()
1851
+ dx = max(r.left() - sp.x(), 0, sp.x() - r.right())
1852
+ dy = max(r.top() - sp.y(), 0, sp.y() - r.bottom())
1853
+ d = (dx * dx + dy * dy) ** 0.5
1854
+ if d < best_d:
1855
+ best, best_d = n, d
1856
+ near = (f"nearest node {best.inst.name} is {best_d:.0f}px away (scene)"
1857
+ if best is not None else "no nodes on the canvas")
1858
+ return (f"under cursor: {under or '[]'} · scene pt ({sp.x():.0f},{sp.y():.0f}) · "
1859
+ f"{near} · zoom={self._zoom:.2f}")
1860
+
1861
+ def _trace(self, msg: str) -> None:
1862
+ """Gesture tracing, off by default. Wiring bugs are reported as 'sometimes it works' — which
1863
+ is unfalsifiable without knowing WHICH of the four steps dropped the gesture. Run with
1864
+ GINI_TRACE_GESTURES=1 and every press/drag/release decision prints to the Console, so a bug
1865
+ report becomes a transcript instead of a hunch."""
1866
+ import os
1867
+ if os.environ.get("GINI_TRACE_GESTURES"):
1868
+ self.ctx.log(f"[gesture] {msg}", "info")
1869
+
1870
+ def mouseDoubleClickEvent(self, e) -> None:
1871
+ """THE BUG behind "sometimes the press just doesn't happen".
1872
+
1873
+ Qt does not send a second Press for a rapid second click. The sequence is:
1874
+
1875
+ Press → Release → **DblClick** → Release
1876
+
1877
+ so the second press arrives as `mouseDoubleClickEvent` — a completely different handler,
1878
+ which we never overrode. `mousePressEvent` was therefore never called, the gesture never
1879
+ armed, and nothing was even logged (which is what made this so hard to see: the failure was
1880
+ an ABSENCE). Wire elements slowly and everything works; wire them quickly, one after
1881
+ another — exactly what you do hooking six machines to a router — and every other gesture
1882
+ evaporates.
1883
+
1884
+ This is NOT a right-button problem. CONNECT MODE HAS IT TOO: a fast second left-drag is
1885
+ swallowed identically. It only *seemed* reliable because aiming in connect mode is slower,
1886
+ so you rarely beat the double-click interval. Removing the right-drag would have left the
1887
+ bug in the gesture we kept.
1888
+
1889
+ A double-click is still a press. Treat it as one — for whichever button is mid-gesture.
1890
+ Left double-click OUTSIDE connect mode keeps its real meaning: log in to the device."""
1891
+ wiring = e.button() == Qt.RightButton or (self._connect_mode
1892
+ and e.button() == Qt.LeftButton)
1893
+ if wiring:
1894
+ btn = "right" if e.button() == Qt.RightButton else "left"
1895
+ self._trace(f"{btn}-DOUBLECLICK (Qt sent DblClick instead of Press) → treating as press")
1896
+ self.mousePressEvent(e)
1897
+ return
1898
+ super().mouseDoubleClickEvent(e) # a plain left double-click still opens the console
1899
+
1900
+ def mouseMoveEvent(self, e) -> None:
1901
+ if self._lp_node is not None and self._lp_start is not None:
1902
+ if (e.pos() - self._lp_start).manhattanLength() > 6: # moving = a drag, not a hold
1903
+ self._lp_timer.stop()
1904
+ self._lp_node = None
1905
+ # connect mode: once a first endpoint is armed, the wire FOLLOWS THE CURSOR — whether you're
1906
+ # dragging or you clicked once and let go. Without this there is no feedback at all after the
1907
+ # first click, which is why the mode felt broken rather than merely unfamiliar.
1908
+ if self._connect_mode and self._cn_from is not None:
1909
+ if not self._cn_dragged and (e.pos() - self._cn_start).manhattanLength() > 4:
1910
+ self._cn_dragged = True
1911
+ self._draw_wire(self._cn_from, e.pos())
1912
+ e.accept()
1913
+ return
1914
+ if self._rc_from is not None:
1915
+ if not self._rc_moved and (e.pos() - self._rc_start).manhattanLength() > 6:
1916
+ self._rc_moved = True
1917
+ if self._rc_moved:
1918
+ self._draw_wire(self._rc_from, e.pos())
1919
+ e.accept()
1920
+ return
1921
+ super().mouseMoveEvent(e)
1922
+
1923
+ def mouseReleaseEvent(self, e) -> None:
1924
+ self._lp_timer.stop() # a hold that's released isn't a long-press
1925
+ self._lp_node = None
1926
+ # connect mode, left-release: if you DRAGGED, this release is the second endpoint. If you
1927
+ # merely clicked, stay armed and keep the wire on the cursor for a second click.
1928
+ if (self._connect_mode and self._cn_from is not None
1929
+ and e.button() == Qt.LeftButton and self._cn_dragged):
1930
+ target = self._node_at(e.pos(), slack=_HIT_SLACK)
1931
+ if target is not None and target.inst.id != self._cn_from.inst.id:
1932
+ self._link(self._cn_from.inst.id, target.inst.id)
1933
+ self._end_connect_gesture()
1934
+ else:
1935
+ # dragged out to nowhere — abandon the whole gesture rather than leaving a hidden
1936
+ # armed endpoint that would surprise the next click
1937
+ self._end_connect_gesture()
1938
+ e.accept()
1939
+ return
1940
+ if self._rc_from is not None and e.button() == Qt.RightButton:
1941
+ self._clear_rc_line()
1942
+ src = self._rc_from
1943
+ self._rc_from = None
1944
+ tgt = self._node_at(e.pos(), slack=_HIT_SLACK)
1945
+ self._trace(f"right-RELEASE: moved={self._rc_moved} target="
1946
+ + (tgt.inst.name if tgt is not None else "NONE (dropped on empty canvas)"))
1947
+ if self._rc_moved: # right-DRAG -> connect to the target
1948
+ target = self._node_at(e.pos(), slack=_HIT_SLACK) # …and slack on the DROP too
1949
+ if target is not None and target.inst.id != src.inst.id:
1950
+ try:
1951
+ self.ctx.connect(src.inst.id, target.inst.id)
1952
+ except Exception as ex: # noqa: BLE001
1953
+ self.ctx.log(f"Couldn't connect: {ex}", "info")
1954
+ else: # plain right-click -> context menu
1955
+ src.popup_menu(e.globalPosition().toPoint())
1956
+ e.accept()
1957
+ return
1958
+ super().mouseReleaseEvent(e)
1959
+
1960
+ # -- one implementation of "draw a wire from a node to the cursor" -------- #
1961
+ def _link(self, src_id: str, dst_id: str) -> None:
1962
+ try:
1963
+ self.ctx.connect(src_id, dst_id)
1964
+ except Exception as ex: # noqa: BLE001 — grammar refusals land here
1965
+ self.ctx.log(f"Couldn't connect: {ex}", "info")
1966
+
1967
+ def _end_connect_gesture(self) -> None:
1968
+ self._connect_first = None
1969
+ self._cn_from = None
1970
+ self._cn_dragged = False
1971
+ self._clear_rc_line()
1972
+
1973
+ def _draw_wire(self, from_node, view_pos) -> None:
1974
+ from PySide6.QtCore import QLineF
1975
+ from PySide6.QtGui import QColor, QPen
1976
+ from PySide6.QtWidgets import QGraphicsLineItem
1977
+ p1 = from_node.sceneBoundingRect().center()
1978
+ p2 = self.mapToScene(view_pos)
1979
+ if self._rc_line is None:
1980
+ self._rc_line = QGraphicsLineItem()
1981
+ pen = QPen(QColor(self.scene_.theme.accent), 2.0, Qt.DashLine)
1982
+ self._rc_line.setPen(pen)
1983
+ self._rc_line.setZValue(5)
1984
+ self.scene_.addItem(self._rc_line)
1985
+ self._rc_line.setLine(QLineF(p1, p2))
1986
+
1987
+ def _clear_rc_line(self) -> None:
1988
+ if self._rc_line is not None:
1989
+ self.scene_.removeItem(self._rc_line)
1990
+ self._rc_line = None
1991
+
1992
+ # drag & drop from palette --------------------------------------------- #
1993
+ def dragEnterEvent(self, e) -> None:
1994
+ if e.mimeData().hasFormat(MIME):
1995
+ e.acceptProposedAction()
1996
+
1997
+ def dragMoveEvent(self, e) -> None:
1998
+ if e.mimeData().hasFormat(MIME):
1999
+ e.acceptProposedAction()
2000
+
2001
+ def dropEvent(self, e) -> None:
2002
+ if not e.mimeData().hasFormat(MIME):
2003
+ return
2004
+ key = bytes(e.mimeData().data(MIME)).decode()
2005
+ sp = self.mapToScene(e.position().toPoint())
2006
+ self.ctx.add_device(key, x=sp.x() - NODE_W / 2, y=sp.y() - NODE_H / 2)
2007
+ e.acceptProposedAction()