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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- 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()
|