Coverage for vibefpmgui/graph_view.py: 89%
743 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Interactive dependency diagram drawn with pyqtgraph.
3The diagram lives in a pyqtgraph ``ViewBox`` (mouse-wheel zoom, drag to pan,
4auto-fit, image/SVG export). Edges are ``PlotCurveItem`` curves with
5``ArrowItem`` heads; packages are custom ``GraphicsObject`` boxes that can be
6dragged around.
8Several layouts (``LAYOUTS``), color themes (``THEMES``) and edge styles
9(``EDGE_STYLES``) are available. Clicking a package highlights everything it
10depends on and everything that depends on it.
11"""
13from __future__ import annotations
15import html
16import math
17import os
18import time
19from itertools import pairwise
21import qtpy
23# Make sure pyqtgraph uses the same Qt binding that qtpy picked.
24os.environ.setdefault("PYQTGRAPH_QT_LIB", qtpy.API_NAME)
26import numpy as np
27import pyqtgraph as pg
28from qtpy.QtCore import (
29 QEvent,
30 QPoint,
31 QPointF,
32 QRectF,
33 QStringListModel,
34 Qt,
35 QTimer,
36 Signal,
37)
38from qtpy.QtGui import (
39 QBrush,
40 QColor,
41 QFont,
42 QFontMetricsF,
43 QLinearGradient,
44 QPainter,
45 QPainterPath,
46 QPalette,
47 QPen,
48)
49from qtpy.QtWidgets import (
50 QApplication,
51 QCompleter,
52 QLabel,
53 QLineEdit,
54 QStackedLayout,
55 QWidget,
56)
58from . import manifest as m
60pg.setConfigOptions(antialias=True)
62KIND_LABELS = {
63 m.ROOT: "Project",
64 m.GIT: "Git",
65 m.PATH: "Local path",
66 m.REGISTRY: "Registry",
67 m.META: "Metapackage",
68 m.MISSING: "Not found",
69 m.DEV_ONLY: "Not downloaded",
70}
71KIND_ORDER = (m.ROOT, m.GIT, m.PATH, m.REGISTRY, m.META, m.MISSING, m.DEV_ONLY)
72DEV_COLOR = "#7048e8" # the "dev" badge: a package only a dev-dependency (of its parents)
74# ----------------------------------------------------------------- styles
75LAYERED_TB, LAYERED_LR, RADIAL, CIRCULAR, FORCE = (
76 "layered-tb", "layered-lr", "radial", "circular", "force")
77LAYOUTS = {
78 LAYERED_TB: "Layered (top → bottom)",
79 LAYERED_LR: "Layered (left → right)",
80 RADIAL: "Radial tree",
81 CIRCULAR: "Circular",
82 FORCE: "Force-directed",
83}
84EDGE_STYLES = {"curved": "Curved", "straight": "Straight"}
86# fill / border are per node kind; border None means "a darker fill".
87THEMES = {
88 "light": {
89 "name": "Light", "background": "#f8f9fa", "text": "#1b1b1b", "subtext": "#495057",
90 "edge": "#8a8f98", "down": "#e8590c", "up": "#1c7ed6", "shadow": 40, "glow": False,
91 "fill": {m.ROOT: "#f6c85f", m.GIT: "#9dd0f5", m.PATH: "#a8e6a1",
92 m.REGISTRY: "#d7b8f3", m.META: "#f3c6a5", m.MISSING: "#e9ecef",
93 m.DEV_ONLY: "#f1f3f5"},
94 "border": None},
95 "dark": {
96 "name": "Dark", "background": "#1e1f22", "text": "#f1f3f5", "subtext": "#ced4da",
97 "edge": "#6c717a", "down": "#ff922b", "up": "#4dabf7", "shadow": 90, "glow": False,
98 "fill": {m.ROOT: "#b8860b", m.GIT: "#2f6aa3", m.PATH: "#2f7d43",
99 m.REGISTRY: "#6a45a0", m.META: "#a35a2a", m.MISSING: "#3d4046",
100 m.DEV_ONLY: "#2b2d31"},
101 "border": None},
102 "blueprint": {
103 "name": "Blueprint", "background": "#0b3d6b", "text": "#ffffff", "subtext": "#cfe3f7",
104 "edge": "#9cc3e6", "down": "#ffd43b", "up": "#63e6be", "shadow": 0, "glow": False,
105 "fill": {k: "#0e4f8a" for k in KIND_ORDER},
106 "border": {m.ROOT: "#ffffff", m.GIT: "#a5d8ff", m.PATH: "#b2f2bb",
107 m.REGISTRY: "#e5dbff", m.META: "#ffd8a8", m.MISSING: "#748ba3",
108 m.DEV_ONLY: "#5c7a96"}},
109 "neon": {
110 "name": "Neon", "background": "#0d0221", "text": "#ffffff", "subtext": "#c9c2e6",
111 "edge": "#4a3d7a", "down": "#ff2a6d", "up": "#05d9e8", "shadow": 0, "glow": True,
112 "fill": {k: "#160a3a" for k in KIND_ORDER},
113 "border": {m.ROOT: "#f9f871", m.GIT: "#00f5ff", m.PATH: "#39ff14",
114 m.REGISTRY: "#ff00e6", m.META: "#ff9f1c", m.MISSING: "#6c6c8a",
115 m.DEV_ONLY: "#4f4f6e"}},
116}
117AUTO_THEME = "auto"
118THEME_CHOICES = {AUTO_THEME: "Auto (follow system)", **{k: v["name"] for k, v in THEMES.items()}}
120H_GAP = 40
121V_GAP = 90
122PAD_X = 16
123PAD_Y = 9
126def resolve_theme(key: str) -> dict:
127 if key == AUTO_THEME:
128 dark = QApplication.palette().color(QPalette.Base).lightness() < 128
129 key = "dark" if dark else "light"
130 return THEMES.get(key, THEMES["light"])
133CONFLICT_COLOR = "#f08c00" # the version-conflict badge
136def node_border(theme: dict, kind: str) -> QColor:
137 if theme["border"]:
138 return QColor(theme["border"][kind])
139 return QColor(theme["fill"][kind]).darker(160)
142# ------------------------------------------------------------------ items
143def _pinned(package: m.Package) -> str:
144 """The tag, branch or commit a git dependency is pinned to ("" if none)."""
145 if not package.git_ref:
146 return ""
147 key, _, value = package.git_ref.partition(" ")
148 if key == "tag":
149 return f"v{value}" if value[:1].isdigit() else value # ("1.6.1" as the others' "v1.6.1")
150 return value[:7] if key == "rev" else package.git_ref
153def _subtitle(package: m.Package) -> str:
154 """Under a box's name: the tag, branch or commit it's pinned to (what the
155 manifest asks for comes first: the package's own fpm.toml may give an
156 older version; the sidebar shows that one), else its version, else its
157 kind ("Git"…; for a dependency's dev-dependency fpm doesn't download: the
158 kind it would be)."""
159 pinned = _pinned(package)
160 if pinned:
161 return pinned
162 if package.version:
163 return f"v{package.version}"
164 kind = package.asked_kind if package.kind == m.DEV_ONLY else package.kind
165 return KIND_LABELS.get(kind, "") or KIND_LABELS.get(package.kind, "")
168class NodeItem(pg.GraphicsObject):
169 """A package box, positioned by its top-left corner in view coordinates."""
171 def __init__(self, view: DependencyGraphView, package: m.Package):
172 super().__init__()
173 self.view = view
174 self.hovered = False
175 self.selected = False
176 self.dimmed = False
177 self.conflict: list[m.Declaration] = [] # different declarations of this package
178 self.dev = False # only a dev-dependency (every edge to it is): a "dev" badge
179 self._drag_offset = QPointF()
180 self._last_click = 0.0
181 self._make_fonts()
182 self.set_package(package)
183 self.setZValue(10)
184 self.setCursor(Qt.PointingHandCursor)
186 def _make_fonts(self):
187 """Fonts based on the current application font (follows View → text size)."""
188 self.title_font = QFont(QApplication.font())
189 self.title_font.setBold(True)
190 self.title_font.setPointSizeF(self.title_font.pointSizeF() + 1)
191 self.sub_font = QFont(QApplication.font())
192 self.sub_font.setPointSizeF(max(self.title_font.pointSizeF() - 3, 7))
194 def refresh_fonts(self):
195 self._make_fonts()
196 self.set_package(self.package) # recomputes the box size
198 def set_package(self, package: m.Package):
199 self.package = package
200 self.subtitle = _subtitle(package)
201 tw = QFontMetricsF(self.title_font).horizontalAdvance(package.name)
202 sw = QFontMetricsF(self.sub_font).horizontalAdvance(self.subtitle)
203 th = QFontMetricsF(self.title_font).height()
204 sh = QFontMetricsF(self.sub_font).height()
205 self.prepareGeometryChange()
206 self.rect = QRectF(0, 0, max(tw, sw, 70) + 2 * PAD_X, th + sh + 2 * PAD_Y)
207 tip = [f"<b>{package.name}</b> ({KIND_LABELS[package.kind]})"]
208 if package.description: 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true
209 tip.append(package.description)
210 if package.error:
211 tip.append(f"<i>{package.error}</i>")
212 if self.dev and package.kind != m.DEV_ONLY:
213 tip.append("<i>Only a dev-dependency: fpm fetches it to build the project's tests and "
214 "examples.</i>")
215 if self.conflict:
216 tip.append("<b style='color:#e8590c'>⚠ Version conflict</b> — asked for differently:")
217 used = m.spec_key(self.conflict[0])
218 tip += [f" {html.escape(d.parent)}: {html.escape(m.describe_spec(d.spec))}"
219 + (" <i>(used)</i>" if i == 0 else " <i>(the same)</i>" if m.spec_key(d) == used else "")
220 for i, d in enumerate(self.conflict)]
221 self.setToolTip("<br>".join(tip))
222 self.update()
224 def set_dev(self, dev: bool):
225 self.dev = dev
226 self.set_package(self.package) # (the tooltip)
228 def set_conflict(self, declarations: list[m.Declaration] | None):
229 self.conflict = list(declarations or [])
230 self.set_package(self.package) # (the tooltip)
232 # geometry helpers (view coordinates)
233 def center(self) -> QPointF:
234 return self.pos() + self.rect.center()
236 def scene_rect(self) -> QRectF:
237 return self.rect.translated(self.pos())
239 def set_center(self, point: QPointF):
240 self.setPos(point - self.rect.center())
242 def boundingRect(self):
243 return self.rect.adjusted(-12, -12, 12, 12)
245 def paint(self, painter: QPainter, option, widget=None):
246 theme = self.view.theme
247 kind = self.package.kind
248 painter.setRenderHint(QPainter.Antialiasing)
249 painter.setOpacity(0.3 if self.dimmed else 1.0)
250 radius = 9
251 fill = QColor(theme["fill"][kind])
252 border = node_border(theme, kind)
253 highlight = self.selected or self.hovered
255 if theme["shadow"]:
256 painter.setPen(Qt.NoPen)
257 for i, grow in enumerate((4, 2)):
258 painter.setBrush(QColor(0, 0, 0, theme["shadow"] // (i + 2)))
259 painter.drawRoundedRect(self.rect.adjusted(-grow + 2, -grow + 4, grow + 2, grow + 4),
260 radius + grow, radius + grow)
261 if theme["glow"] or highlight:
262 glow = QColor(theme["down"]) if self.selected else QColor(border)
263 strength = 1.0 if highlight else 0.5
264 painter.setBrush(Qt.NoBrush)
265 for width, alpha in ((9, 25), (6, 45), (3, 80)):
266 glow.setAlpha(int(alpha * strength))
267 painter.setPen(QPen(glow, width))
268 painter.drawRoundedRect(self.rect, radius, radius)
270 gradient = QLinearGradient(self.rect.topLeft(), self.rect.bottomLeft())
271 gradient.setColorAt(0, fill.lighter(118))
272 gradient.setColorAt(1, fill)
273 pen = QPen(QColor(theme["down"]) if self.selected else border, 2.4 if highlight else 1.4)
274 if kind in (m.MISSING, m.DEV_ONLY):
275 pen.setStyle(Qt.DashLine)
276 painter.setPen(pen)
277 painter.setBrush(QBrush(gradient))
278 painter.drawRoundedRect(self.rect, radius, radius)
280 # Kind accent stripe for themes that use one fill color for all kinds.
281 if theme["border"]:
282 body = QPainterPath()
283 body.addRoundedRect(self.rect.adjusted(3, 3, -3, -3), radius - 2, radius - 2)
284 strip = QPainterPath()
285 strip.addRect(QRectF(self.rect.left(), self.rect.top(), 7, self.rect.height()))
286 painter.setPen(Qt.NoPen)
287 painter.setBrush(border)
288 painter.drawPath(body.intersected(strip))
290 th = QFontMetricsF(self.title_font).height()
291 painter.setFont(self.title_font)
292 painter.setPen(QColor(theme["text"]))
293 painter.drawText(QRectF(0, PAD_Y, self.rect.width(), th), Qt.AlignCenter, self.package.name)
294 painter.setFont(self.sub_font)
295 painter.setPen(QColor(theme["subtext"]))
296 painter.drawText(QRectF(0, PAD_Y + th, self.rect.width(), self.rect.height() - th - 2 * PAD_Y),
297 Qt.AlignCenter, self.subtitle)
299 if self.conflict: # an orange "!" badge on the top-right corner
300 badge = QRectF(self.rect.right() - 11, self.rect.top() - 9, 20, 20)
301 painter.setPen(QPen(QColor("white"), 1.5))
302 painter.setBrush(QColor(CONFLICT_COLOR))
303 painter.drawEllipse(badge)
304 font = QFont(self.title_font)
305 font.setPointSizeF(max(self.sub_font.pointSizeF(), 9))
306 painter.setFont(font)
307 painter.setPen(QColor("white"))
308 painter.drawText(badge, Qt.AlignCenter, "!")
309 if self.dev: # a "dev" tag on the top-left corner
310 font = QFont(self.sub_font)
311 font.setBold(True)
312 fm = QFontMetricsF(font)
313 badge = QRectF(self.rect.left() - 6, self.rect.top() - fm.height() / 2 - 2,
314 fm.horizontalAdvance("dev") + 10, fm.height() + 4)
315 painter.setPen(QPen(QColor("white"), 1.2))
316 painter.setBrush(QColor(DEV_COLOR))
317 painter.drawRoundedRect(badge, badge.height() / 2, badge.height() / 2)
318 painter.setFont(font)
319 painter.setPen(QColor("white"))
320 painter.drawText(badge, Qt.AlignCenter, "dev")
322 # pyqtgraph mouse events
323 def hoverEvent(self, ev):
324 if ev.isExit():
325 if self.hovered:
326 self.hovered = False
327 self.view._hover_changed(self, False)
328 return
329 ev.acceptClicks(Qt.LeftButton)
330 ev.acceptDrags(Qt.LeftButton)
331 if not self.hovered:
332 self.hovered = True
333 self.view._hover_changed(self, True)
335 def mouseClickEvent(self, ev):
336 if ev.button() == Qt.RightButton: # the package's menu (see MainWindow)
337 ev.accept()
338 self.view.select(self.package.name, center=False)
339 self.view.package_context_menu.emit(self.package.name, ev.screenPos().toPoint())
340 return
341 if ev.button() != Qt.LeftButton:
342 ev.ignore()
343 return
344 ev.accept()
345 # pyqtgraph only flags ev.double() for some event orders, so also
346 # detect a second click within the system double-click interval.
347 now = time.monotonic()
348 interval = QApplication.doubleClickInterval() / 1000
349 if ev.double() or now - self._last_click < interval:
350 self._last_click = 0.0
351 self.view.package_activated.emit(self.package.name)
352 else:
353 self._last_click = now
354 self.view.select(self.package.name, center=False)
356 def mouseDragEvent(self, ev):
357 if ev.button() != Qt.LeftButton:
358 ev.ignore()
359 return
360 ev.accept()
361 if ev.isStart():
362 self._drag_offset = self.pos() - self.mapToParent(ev.buttonDownPos())
363 self.view._stop_animation()
364 self.setPos(self.mapToParent(ev.pos()) + self._drag_offset)
365 self.view._update_edges(self)
368class EdgeItem:
369 """A dependency arrow: a curve plus an arrow head, both pyqtgraph items."""
371 def __init__(self, view: DependencyGraphView, source: NodeItem, target: NodeItem, dev: bool):
372 self.view, self.source, self.target, self.dev = view, source, target, dev
373 self.curve = pg.PlotCurveItem()
374 self.arrow = pg.ArrowItem(headLen=12, tipAngle=32, tailLen=None, pen=None)
375 self.state = "normal" # normal | hover | down | up | dim
377 def add_to(self, viewbox):
378 viewbox.addItem(self.curve)
379 viewbox.addItem(self.arrow)
381 def remove_from(self, viewbox):
382 viewbox.removeItem(self.curve)
383 viewbox.removeItem(self.arrow)
385 def apply_style(self):
386 theme = self.view.theme
387 emphasis = self.state in ("down", "up", "hover")
388 color = QColor({"down": theme["down"], "up": theme["up"],
389 "hover": theme["down"]}.get(self.state, theme["edge"]))
390 if self.state == "dim":
391 color.setAlpha(60)
392 pen = pg.mkPen(color, width=2.6 if emphasis else 1.5,
393 style=Qt.DashLine if self.dev else Qt.SolidLine)
394 self.curve.setPen(pen)
395 self.arrow.setStyle(brush=pg.mkBrush(color), pen=pg.mkPen(color))
396 self.curve.setZValue(2 if emphasis else 0)
397 self.arrow.setZValue(3 if emphasis else 1)
399 def update_geometry(self):
400 points, (dx, dy) = edge_geometry(self.source.scene_rect(), self.target.scene_rect(),
401 self.view.layout_name, self.view.edge_style == "curved")
402 self.curve.setData(points[:, 0], points[:, 1])
403 # ArrowItem points left at angle 0 and rotates clockwise on screen.
404 self.arrow.setStyle(angle=math.degrees(math.atan2(-dy, -dx)))
405 self.arrow.setPos(points[-1, 0], points[-1, 1])
408# --------------------------------------------------------------- geometry
409def _clip_to_rect(rect: QRectF, toward: QPointF) -> QPointF:
410 """Point where the ray from the rect centre toward ``toward`` leaves it."""
411 c = rect.center()
412 dx, dy = toward.x() - c.x(), toward.y() - c.y()
413 if abs(dx) < 1e-9 and abs(dy) < 1e-9: 413 ↛ 414line 413 didn't jump to line 414 because the condition on line 413 was never true
414 return c
415 hw, hh = rect.width() / 2 + 1, rect.height() / 2 + 1
416 t = min(hw / abs(dx) if dx else math.inf, hh / abs(dy) if dy else math.inf)
417 return QPointF(c.x() + dx * t, c.y() + dy * t)
420def _bezier(p0, c1, c2, p3, n=28) -> np.ndarray:
421 t = np.linspace(0, 1, n)[:, None]
422 pts = np.array([[p.x(), p.y()] for p in (p0, c1, c2, p3)])
423 return ((1 - t) ** 3 * pts[0] + 3 * (1 - t) ** 2 * t * pts[1]
424 + 3 * (1 - t) * t ** 2 * pts[2] + t ** 3 * pts[3])
427def edge_geometry(s: QRectF, t: QRectF, layout: str, curved: bool):
428 """Return (Nx2 points, end direction) for an arrow from rect s to rect t."""
429 sc, tc = s.center(), t.center()
430 if curved and layout == LAYERED_TB and t.top() > s.bottom() + 4:
431 p0, p3 = QPointF(sc.x(), s.bottom()), QPointF(tc.x(), t.top())
432 dy = (p3.y() - p0.y()) / 2
433 c1, c2 = QPointF(p0.x(), p0.y() + dy), QPointF(p3.x(), p3.y() - dy)
434 elif curved and layout == LAYERED_LR and t.left() > s.right() + 4:
435 p0, p3 = QPointF(s.right(), sc.y()), QPointF(t.left(), tc.y())
436 dx = (p3.x() - p0.x()) / 2
437 c1, c2 = QPointF(p0.x() + dx, p0.y()), QPointF(p3.x() - dx, p3.y())
438 elif curved:
439 # A gentle arc bowing to one side of the straight line.
440 v = tc - sc
441 ctrl = (sc + tc) / 2 + QPointF(-v.y(), v.x()) * 0.18
442 p0, p3 = _clip_to_rect(s, ctrl), _clip_to_rect(t, ctrl)
443 c1, c2 = p0 + (ctrl - p0) * (2 / 3), p3 + (ctrl - p3) * (2 / 3)
444 else:
445 p0, p3 = _clip_to_rect(s, tc), _clip_to_rect(t, sc)
446 pts = np.array([[p0.x(), p0.y()], [p3.x(), p3.y()]])
447 return pts, (p3.x() - p0.x(), p3.y() - p0.y())
448 pts = _bezier(p0, c1, c2, p3)
449 d = pts[-1] - pts[-3]
450 return pts, (d[0], d[1])
453# ----------------------------------------------------------------- layouts
454def _adjacency(graph: m.DependencyGraph):
455 children = {name: [] for name in graph.packages}
456 parents = {name: [] for name in graph.packages}
457 for e in graph.edges:
458 if e.child not in children[e.parent]: 458 ↛ 457line 458 didn't jump to line 457 because the condition on line 458 was always true
459 children[e.parent].append(e.child)
460 parents[e.child].append(e.parent)
461 return children, parents
464def compute_layers(graph: m.DependencyGraph) -> list[list[str]]:
465 """Assign each package to a layer (longest path from the root) and order
466 the layers to reduce crossings."""
467 children, parents = _adjacency(graph)
468 depth = {graph.root: 0}
469 # Longest-path layering; the iteration cap guards against cycles.
470 for _ in range(len(graph.packages)): 470 ↛ 479line 470 didn't jump to line 479 because the loop on line 470 didn't complete
471 changed = False
472 for e in graph.edges:
473 new_depth = depth[e.parent] + 1 if e.parent in depth else None
474 if new_depth is not None and depth.get(e.child, -1) < new_depth < len(graph.packages):
475 depth[e.child] = new_depth
476 changed = True
477 if not changed:
478 break
479 for name in graph.packages:
480 depth.setdefault(name, 1)
482 layers: list[list[str]] = [[] for _ in range(max(depth.values()) + 1)]
483 for name in graph.packages: # BFS order gives a sensible starting order
484 layers[depth[name]].append(name)
486 def barycenter(name, neighbours, index):
487 pos = [index[n] for n in neighbours[name] if n in index]
488 return sum(pos) / len(pos) if pos else index.get(name, 0)
490 for _ in range(4):
491 for i in range(1, len(layers)): # downward sweep
492 index = {n: k for k, n in enumerate(layers[i - 1])}
493 layers[i].sort(key=lambda n: barycenter(n, parents, index))
494 for i in range(len(layers) - 2, 0, -1): # upward sweep
495 index = {n: k for k, n in enumerate(layers[i + 1])}
496 layers[i].sort(key=lambda n: barycenter(n, children, index))
497 return [layer for layer in layers if layer]
500def layout_layered(graph, sizes, horizontal=False) -> dict[str, QPointF]:
501 layers = compute_layers(graph)
502 pos = {}
503 if not horizontal:
504 y = 0.0
505 for layer in layers:
506 width = sum(sizes[n][0] for n in layer) + H_GAP * (len(layer) - 1)
507 height = max(sizes[n][1] for n in layer)
508 x = -width / 2
509 for name in layer:
510 pos[name] = QPointF(x + sizes[name][0] / 2, y + height / 2)
511 x += sizes[name][0] + H_GAP
512 y += height + V_GAP
513 else:
514 gap = 22
515 x = 0.0
516 for layer in layers:
517 col_w = max(sizes[n][0] for n in layer)
518 total = sum(sizes[n][1] for n in layer) + gap * (len(layer) - 1)
519 y = -total / 2
520 for name in layer:
521 pos[name] = QPointF(x + col_w / 2, y + sizes[name][1] / 2)
522 y += sizes[name][1] + gap
523 x += col_w + V_GAP * 1.3
524 return pos
527def layout_radial(graph, sizes) -> dict[str, QPointF]:
528 """Root in the middle, a breadth-first spanning tree fanned out in rings."""
529 children, _ = _adjacency(graph)
530 rank = {n: i for i, n in enumerate(n for layer in compute_layers(graph) for n in layer)}
531 tree = {n: [] for n in graph.packages}
532 depth = {graph.root: 0}
533 queue = [graph.root]
534 while queue:
535 node = queue.pop(0)
536 for child in sorted(children[node], key=rank.get):
537 if child not in depth:
538 depth[child] = depth[node] + 1
539 tree[node].append(child)
540 queue.append(child)
541 for n in graph.packages: # anything unreachable hangs off the root
542 if n not in depth: 542 ↛ 543line 542 didn't jump to line 543 because the condition on line 542 was never true
543 depth[n] = 1
544 tree[graph.root].append(n)
546 leaves: dict[str, int] = {}
548 def count(n):
549 leaves[n] = sum(count(c) for c in tree[n]) or 1
550 return leaves[n]
552 angle: dict[str, float] = {}
554 def assign(n, a0, a1):
555 angle[n] = (a0 + a1) / 2
556 a = a0
557 for c in tree[n]:
558 span = (a1 - a0) * leaves[c] / leaves[n]
559 assign(c, a, a + span)
560 a += span
562 count(graph.root)
563 assign(graph.root, -math.pi / 2, 3 * math.pi / 2)
565 # Every leaf gets an equal slice of the circle, so neighbours on a ring
566 # are at least one slice apart; make each ring big enough for that.
567 radius = [0.0]
568 slice_angle = 2 * math.pi / leaves[graph.root]
569 step = max(max(h for _, h in sizes.values()) * 3, 150)
570 for d in range(1, max(depth.values()) + 1):
571 widest = max(sizes[n][0] for n in graph.packages if depth[n] == d)
572 radius.append(max(radius[-1] + step, (widest + H_GAP) / slice_angle))
573 pos = {n: QPointF(radius[depth[n]] * math.cos(angle[n]),
574 radius[depth[n]] * math.sin(angle[n])) for n in graph.packages}
575 return _remove_overlaps(pos, sizes)
578def layout_circular(graph, sizes) -> dict[str, QPointF]:
579 order = [n for layer in compute_layers(graph) for n in layer]
580 r = max(sum(sizes[n][0] + H_GAP for n in order) / (2 * math.pi), 150)
581 step = 2 * math.pi / max(len(order), 1)
582 return {n: QPointF(r * math.cos(-math.pi / 2 + i * step), r * math.sin(-math.pi / 2 + i * step))
583 for i, n in enumerate(order)}
586def layout_force(graph, sizes) -> dict[str, QPointF]:
587 """Fruchterman-Reingold, seeded from the layered layout (deterministic)."""
588 names = list(graph.packages)
589 start = layout_layered(graph, sizes)
590 n = len(names)
591 if n < 2: 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true
592 return start
593 pos = np.array([[start[k].x(), start[k].y()] for k in names], dtype=float)
594 index = {k: i for i, k in enumerate(names)}
595 edges = np.array([(index[e.parent], index[e.child]) for e in graph.edges], dtype=int).reshape(-1, 2)
596 k = 190.0
597 temperature = 120.0
598 for _ in range(400):
599 delta = pos[:, None, :] - pos[None, :, :]
600 dist2 = (delta ** 2).sum(-1) + np.eye(n)
601 disp = (delta * (k * k / dist2)[:, :, None]).sum(1) # repulsion
602 if len(edges): # attraction along edges 602 ↛ 607line 602 didn't jump to line 607 because the condition on line 602 was always true
603 d = pos[edges[:, 1]] - pos[edges[:, 0]]
604 pull = d * (np.sqrt((d ** 2).sum(-1))[:, None] / k)
605 np.add.at(disp, edges[:, 0], pull)
606 np.add.at(disp, edges[:, 1], -pull)
607 disp -= (pos - pos.mean(0)) * 0.01 # gentle gravity
608 length = np.sqrt((disp ** 2).sum(-1))[:, None] + 1e-9
609 pos += disp / length * np.minimum(length, temperature)
610 temperature = max(1.0, temperature * 0.985)
611 result = {name: QPointF(*pos[i]) for i, name in enumerate(names)}
612 return _remove_overlaps(result, sizes)
615def _remove_overlaps(pos: dict[str, QPointF], sizes, margin=14, iterations=80):
616 """Push apart any boxes that overlap, along the axis of least overlap."""
617 names = list(pos)
618 p = {k: [pos[k].x(), pos[k].y()] for k in names}
619 for _ in range(iterations): 619 ↛ 633line 619 didn't jump to line 633 because the loop on line 619 didn't complete
620 moved = False
621 for i, a in enumerate(names):
622 for b in names[i + 1:]:
623 ox = (sizes[a][0] + sizes[b][0]) / 2 + margin - abs(p[a][0] - p[b][0])
624 oy = (sizes[a][1] + sizes[b][1]) / 2 + margin - abs(p[a][1] - p[b][1])
625 if ox > 0 and oy > 0: 625 ↛ 626line 625 didn't jump to line 626 because the condition on line 625 was never true
626 moved = True
627 axis, amount = (0, ox) if ox < oy else (1, oy)
628 s = 1 if p[a][axis] >= p[b][axis] else -1
629 p[a][axis] += s * amount / 2
630 p[b][axis] -= s * amount / 2
631 if not moved: 631 ↛ 619line 631 didn't jump to line 619 because the condition on line 631 was always true
632 break
633 return {k: QPointF(*v) for k, v in p.items()}
636LAYOUT_FUNCTIONS = {
637 LAYERED_TB: lambda g, s: layout_layered(g, s, horizontal=False),
638 LAYERED_LR: lambda g, s: layout_layered(g, s, horizontal=True),
639 RADIAL: layout_radial,
640 CIRCULAR: layout_circular,
641 FORCE: layout_force,
642}
645# ------------------------------------------------------------------- view
646class DependencyGraphView(QWidget):
647 """Shows a `vibefpmgui.manifest.DependencyGraph`. Wheel zooms, drag pans,
648 nodes can be dragged, click selects (highlighting what the package
649 depends on and what uses it), double-click opens."""
651 package_selected = Signal(str)
652 package_activated = Signal(str)
653 package_context_menu = Signal(str, QPoint) # right-click: package name, screen position
655 def __init__(self, parent=None):
656 super().__init__(parent)
657 self.layout_name = LAYERED_TB
658 self.edge_style = "curved"
659 self.theme_key = AUTO_THEME
660 self.theme = resolve_theme(self.theme_key)
661 self.graph: m.DependencyGraph | None = None
662 self.conflicts: dict[str, list[m.Declaration]] = {}
663 self.nodes: dict[str, NodeItem] = {}
664 self.edges: list[EdgeItem] = []
665 self.selected: str | None = None
667 self.plot = pg.GraphicsLayoutWidget()
668 self.plot.ci.setContentsMargins(0, 0, 0, 0)
669 self.vb = self.plot.addViewBox()
670 self.vb.setAspectLocked(True)
671 self.vb.invertY(True) # y grows downward, like the screen
672 self.vb.setMenuEnabled(False)
673 self.plot.scene().sigMouseClicked.connect(self._scene_clicked)
675 # "Find package…": over the graph's top-left corner.
676 self.search = QLineEdit(self.plot)
677 self.search.setPlaceholderText("Find package…")
678 self.search.setClearButtonEnabled(True)
679 self.search.setFixedWidth(220)
680 # Readable on every graph theme (light, dark, blueprint, neon).
681 self.search.setStyleSheet("QLineEdit { background: rgba(128, 128, 128, 55); border: 1px solid "
682 "rgba(128, 128, 128, 140); border-radius: 7px; padding: 4px 8px; }"
683 "QLineEdit:focus { border-color: rgba(77, 148, 255, 220); }")
684 self.search.setToolTip("Type to highlight matching packages; Enter selects the next match, "
685 "Esc clears")
686 self.search_model = QStringListModel(self)
687 completer = QCompleter(self.search_model, self.search)
688 completer.setCaseSensitivity(Qt.CaseInsensitive)
689 completer.setFilterMode(Qt.MatchContains)
690 completer.activated.connect(lambda text: self._jump_to_match(text))
691 self.search.setCompleter(completer)
692 self.search.textChanged.connect(self._highlight_matches)
693 self.search.returnPressed.connect(lambda: self._jump_to_match(None))
694 self.search.installEventFilter(self)
695 self.search.move(10, 10)
697 self.message = QLabel()
698 self.message.setAlignment(Qt.AlignCenter)
699 self.message.setWordWrap(True)
700 self.stack = QStackedLayout(self)
701 self.stack.addWidget(self.message)
702 self.stack.addWidget(self.plot)
704 self._anim_timer = QTimer(self)
705 self._anim_timer.setInterval(16)
706 self._anim_timer.timeout.connect(self._animate_step)
707 self._anim_from: dict[str, QPointF] = {}
708 self._anim_to: dict[str, QPointF] = {}
709 self._anim_frame = 0
710 self._anim_frames = 24
712 self._apply_theme_colors()
713 self.clear()
715 # ------------------------------------------------------------ options
716 def set_layout(self, name: str, animate: bool = True):
717 if name in LAYOUTS: 717 ↛ exitline 717 didn't return from function 'set_layout' because the condition on line 717 was always true
718 self.layout_name = name
719 self.relayout(animate)
721 def set_edge_style(self, style: str):
722 if style in EDGE_STYLES: 722 ↛ exitline 722 didn't return from function 'set_edge_style' because the condition on line 722 was always true
723 self.edge_style = style
724 self._update_edges()
726 def set_theme(self, key: str):
727 self.theme_key = key if key in THEME_CHOICES else AUTO_THEME
728 self.refresh_theme()
730 def refresh_theme(self):
731 """Re-resolve the theme (e.g. after the system switches dark mode)."""
732 self.theme = resolve_theme(self.theme_key)
733 self._apply_theme_colors()
734 for node in self.nodes.values():
735 node.update()
736 for edge in self.edges:
737 edge.apply_style()
739 def _apply_theme_colors(self):
740 self.plot.setBackground(self.theme["background"])
741 self.message.setStyleSheet(
742 f"background:{self.theme['background']}; color:{self.theme['subtext']};")
744 def package_colors(self) -> dict[str, str]:
745 """The color that identifies each package kind in the current theme."""
746 return {k: (node_border(self.theme, k).name() if self.theme["border"] else self.theme["fill"][k])
747 for k in KIND_ORDER}
749 def refresh_fonts(self):
750 """Resize the package boxes after the text size changed, keeping each
751 box centred where it was, then tidy overlaps by re-running the layout
752 only if boxes now collide."""
753 for node in self.nodes.values():
754 center = node.center()
755 node.refresh_fonts()
756 node.set_center(center)
757 rects = [n.scene_rect() for n in self.nodes.values()]
758 if any(a.intersects(b) for i, a in enumerate(rects) for b in rects[i + 1:]): 758 ↛ 759line 758 didn't jump to line 759 because the condition on line 758 was never true
759 self.relayout(animate=False)
760 else:
761 self._update_edges()
763 def legend_html(self) -> str:
764 theme = self.theme
765 parts = [f'<span style="background:{theme["fill"][k]}; color:{theme["text"]}">'
766 f' <span style="color:{node_border(theme, k).name()}">●</span>'
767 f' {KIND_LABELS[k]} </span>' for k in KIND_ORDER
768 if k != m.DEV_ONLY or k in self._kinds_shown()]
769 text = (" ".join(parts)
770 + f" <span style='color:{theme['down']}'>━</span> depends on"
771 + f" <span style='color:{theme['up']}'>━</span> used by"
772 + " <i>dashed = dev</i>")
773 if any(node.dev for node in self.nodes.values()):
774 text += f" <span style='background:{DEV_COLOR}; color:white'> dev </span> only a dev-dependency"
775 if self.conflicts:
776 text += (f" <span style='color:{CONFLICT_COLOR}'><b>!</b> version conflict"
777 f"{'s' * (len(self.conflicts) != 1)}</span>")
778 return text
780 def _kinds_shown(self) -> set[str]:
781 return {node.package.kind for node in self.nodes.values()}
783 # -------------------------------------------------------------- graph
784 def clear(self, message="Open an fpm.toml file to see its dependency graph."):
785 self.graph = None
786 self.conflicts = {}
787 self._clear_items()
788 self.message.setText(message)
789 self.stack.setCurrentWidget(self.message)
791 def _clear_items(self):
792 self._stop_animation()
793 for node in self.nodes.values():
794 self.vb.removeItem(node)
795 for edge in self.edges:
796 edge.remove_from(self.vb)
797 self.nodes, self.edges, self.selected = {}, [], None
799 def set_graph(self, graph: m.DependencyGraph):
800 self._clear_items()
801 self.graph = graph
802 self.stack.setCurrentWidget(self.plot)
803 for name, pkg in graph.packages.items():
804 node = NodeItem(self, pkg)
805 self.vb.addItem(node)
806 self.nodes[name] = node
807 seen = set()
808 for e in graph.edges:
809 if (e.parent, e.child) in seen: 809 ↛ 810line 809 didn't jump to line 810 because the condition on line 809 was never true
810 continue
811 seen.add((e.parent, e.child))
812 edge = EdgeItem(self, self.nodes[e.parent], self.nodes[e.child], e.dev)
813 edge.add_to(self.vb)
814 edge.apply_style()
815 self.edges.append(edge)
816 self._mark_dev()
817 self._mark_conflicts()
818 self.relayout(animate=False)
820 def update_packages(self, graph: m.DependencyGraph):
821 """Swap in refreshed package details without redoing the layout."""
822 self.graph = graph
823 for name, node in self.nodes.items():
824 if name in graph.packages: 824 ↛ 823line 824 didn't jump to line 823 because the condition on line 824 was always true
825 node.set_package(graph.packages[name])
826 self._mark_conflicts()
827 self._update_edges()
829 def _mark_dev(self):
830 """The packages only dev-dependencies: every edge to them is a dev one."""
831 dev: dict[str, bool] = {}
832 for e in self.graph.edges if self.graph else []:
833 dev[e.child] = dev.get(e.child, True) and e.dev
834 for name, node in self.nodes.items():
835 node.set_dev(dev.get(name, False))
837 def _mark_conflicts(self):
838 self.conflicts = m.conflicts(self.graph) if self.graph else {}
839 for name, node in self.nodes.items():
840 node.set_conflict(self.conflicts.get(name))
841 names = sorted(self.nodes, key=str.lower)
842 self.search_model.setStringList(names)
844 def relayout(self, animate: bool = True):
845 """Place every package with the current layout (undoing any drags)."""
846 if not self.graph or not self.nodes:
847 return
848 sizes = {n: (node.rect.width(), node.rect.height()) for n, node in self.nodes.items()}
849 target = LAYOUT_FUNCTIONS[self.layout_name](self.graph, sizes)
850 if animate and self.isVisible():
851 self._anim_from = {n: node.center() for n, node in self.nodes.items()}
852 self._anim_to = target
853 self._anim_frame = 0
854 self._anim_timer.start()
855 else:
856 self._stop_animation()
857 for name, point in target.items():
858 self.nodes[name].set_center(point)
859 self._update_edges()
860 self.fit()
862 def _animate_step(self):
863 self._anim_frame += 1
864 t = min(self._anim_frame / self._anim_frames, 1.0)
865 ease = 1 - (1 - t) ** 3
866 for name, node in self.nodes.items():
867 a, b = self._anim_from.get(name), self._anim_to.get(name)
868 if a is not None and b is not None: 868 ↛ 866line 868 didn't jump to line 866 because the condition on line 868 was always true
869 node.set_center(a + (b - a) * ease)
870 self._update_edges()
871 if t >= 1.0:
872 self._anim_timer.stop()
873 self.fit()
875 def _stop_animation(self):
876 self._anim_timer.stop()
878 def _update_edges(self, node: NodeItem | None = None):
879 for edge in self.edges:
880 if node is None or edge.source is node or edge.target is node: 880 ↛ 879line 880 didn't jump to line 879 because the condition on line 880 was always true
881 edge.update_geometry()
883 # ---------------------------------------------------------- selection
884 def select(self, name: str | None, center: bool = True):
885 """Select a package (None clears) and highlight its dependency chains."""
886 if name is not None and name not in self.nodes: 886 ↛ 887line 886 didn't jump to line 887 because the condition on line 886 was never true
887 return
888 self.selected = name
889 down, up = self._reachable(name, forward=True), self._reachable(name, forward=False)
890 for n, node in self.nodes.items():
891 node.selected = n == name
892 node.dimmed = name is not None and n != name and n not in down and n not in up
893 node.update()
894 for edge in self.edges:
895 p, c = edge.source.package.name, edge.target.package.name
896 if name is None:
897 edge.state = "hover" if edge.source.hovered or edge.target.hovered else "normal"
898 elif p in down | {name} and c in down:
899 edge.state = "down"
900 elif c in up | {name} and p in up:
901 edge.state = "up"
902 else:
903 edge.state = "dim"
904 edge.apply_style()
905 if name is not None:
906 self.package_selected.emit(name)
907 if center:
908 self.vb.translateBy(self.nodes[name].center() - self.vb.viewRect().center())
910 def show_chains(self, name: str, chains: list[list[str]]):
911 """Highlight the dependency chains from the project down to ``name``
912 (Why Is This Here?); everything else is dimmed. A click on the
913 background clears it, like a selection."""
914 on_chain = {n for chain in chains for n in chain}
915 links = {(a, b) for chain in chains for a, b in pairwise(chain)}
916 self.selected = name
917 for n, node in self.nodes.items():
918 node.selected = n == name
919 node.dimmed = n not in on_chain
920 node.update()
921 for edge in self.edges:
922 pair = (edge.source.package.name, edge.target.package.name)
923 edge.state = "down" if pair in links else "dim"
924 edge.apply_style()
926 # ---------------------------------------------------------- searching
927 def focus_search(self):
928 self.search.setFocus()
929 self.search.selectAll()
931 def _matches(self, text: str) -> list[str]:
932 """Package names containing ``text``: ones starting with it first."""
933 text = text.strip().lower()
934 if not text: 934 ↛ 935line 934 didn't jump to line 935 because the condition on line 934 was never true
935 return []
936 names = sorted(self.nodes, key=str.lower)
937 return ([n for n in names if n.lower().startswith(text)]
938 + [n for n in names if text in n.lower() and not n.lower().startswith(text)])
940 def _highlight_matches(self, text: str):
941 """While typing: the matching packages stand out, the rest are dimmed."""
942 if not text.strip():
943 self.select(self.selected, center=False) if self.selected else self.select(None)
944 return
945 matches = set(self._matches(text))
946 for name, node in self.nodes.items():
947 node.selected = False
948 node.dimmed = name not in matches
949 node.update()
950 for edge in self.edges:
951 ends = {edge.source.package.name, edge.target.package.name}
952 edge.state = "normal" if ends <= matches else "dim"
953 edge.apply_style()
955 def _jump_to_match(self, name: str | None):
956 """Enter: select (and center) the next matching package."""
957 matches = [name] if name in self.nodes else self._matches(self.search.text())
958 if not matches: 958 ↛ 959line 958 didn't jump to line 959 because the condition on line 958 was never true
959 return
960 index = (matches.index(self.selected) + 1) % len(matches) if self.selected in matches else 0
961 self.select(matches[index], center=True)
963 def eventFilter(self, obj, event):
964 if obj is self.search and event.type() == QEvent.KeyPress and event.key() == Qt.Key_Escape:
965 self.search.clear()
966 self.plot.setFocus()
967 return True
968 return super().eventFilter(obj, event)
970 def _reachable(self, name: str | None, forward: bool) -> set[str]:
971 if name is None or not self.graph:
972 return set()
973 nxt: dict[str, set[str]] = {}
974 for e in self.graph.edges:
975 a, b = (e.parent, e.child) if forward else (e.child, e.parent)
976 nxt.setdefault(a, set()).add(b)
977 seen, stack = set(), [name]
978 while stack:
979 for n in nxt.get(stack.pop(), ()):
980 if n not in seen: 980 ↛ 979line 980 didn't jump to line 979 because the condition on line 980 was always true
981 seen.add(n)
982 stack.append(n)
983 seen.discard(name)
984 return seen
986 def _hover_changed(self, node: NodeItem, entered: bool):
987 node.update()
988 if self.selected is not None:
989 return # keep the selection highlighting
990 for edge in self.edges:
991 if edge.source is node or edge.target is node:
992 edge.state = "hover" if entered else "normal"
993 edge.apply_style()
995 def _scene_clicked(self, ev):
996 # A click on empty space clears the selection.
997 if ev.button() != Qt.LeftButton or ev.double():
998 return
999 if not any(isinstance(i, NodeItem) for i in self.plot.scene().items(ev.scenePos())):
1000 self.select(None)
1002 # --------------------------------------------------------------- view
1003 def fit(self):
1004 if self.nodes: 1004 ↛ exitline 1004 didn't return from function 'fit' because the condition on line 1004 was always true
1005 rect = QRectF()
1006 for node in self.nodes.values():
1007 rect = rect.united(node.scene_rect())
1008 self.vb.setRange(rect=rect, padding=0.08)
1009 # Don't blow small graphs up beyond 130 %.
1010 px = self.vb.viewPixelSize()[0]
1011 if 0 < px < 1 / 1.3:
1012 factor = 1 / (1.3 * px)
1013 self.vb.scaleBy((factor, factor), center=rect.center())
1015 def export_image(self, filename: str) -> bool:
1016 """Save the whole diagram as PNG/JPG, or SVG if the name ends in .svg."""
1017 from pyqtgraph import exporters
1019 x_range, y_range = self.vb.viewRange()
1020 self.fit()
1021 try:
1022 if filename.lower().endswith(".svg"):
1023 # pyqtgraph's SVGExporter chokes on these custom items, so
1024 # render the scene straight into Qt's SVG generator instead.
1025 from qtpy.QtSvg import QSvgGenerator
1027 source = self.vb.sceneBoundingRect()
1028 target = QRectF(0, 0, source.width(), source.height())
1029 gen = QSvgGenerator()
1030 gen.setFileName(filename)
1031 gen.setSize(source.size().toSize())
1032 gen.setViewBox(target)
1033 gen.setTitle(self.graph.root if self.graph else "dependencies")
1034 painter = QPainter(gen)
1035 painter.fillRect(target, QColor(self.theme["background"]))
1036 self.plot.scene().render(painter, target, source)
1037 painter.end()
1038 else:
1039 exporter = exporters.ImageExporter(self.plot.scene())
1040 params = exporter.parameters()
1041 params["width"] = int(self.plot.width() * 2)
1042 params["antialias"] = True
1043 exporter.export(filename)
1044 return os.path.exists(filename)
1045 except Exception: # noqa: BLE001 - report failure to the caller
1046 return False
1047 finally:
1048 self.vb.setRange(xRange=x_range, yRange=y_range, padding=0)