vibefpmgui.graph_view
Interactive dependency diagram drawn with pyqtgraph.
The diagram lives in a pyqtgraph ViewBox (mouse-wheel zoom, drag to pan,
auto-fit, image/SVG export). Edges are PlotCurveItem curves with
ArrowItem heads; packages are custom GraphicsObject boxes that can be
dragged around.
Several layouts (LAYOUTS), color themes (THEMES) and edge styles
(EDGE_STYLES) are available. Clicking a package highlights everything it
depends on and everything that depends on it.
1"""Interactive dependency diagram drawn with pyqtgraph. 2 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. 7 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""" 12 13from __future__ import annotations 14 15import html 16import math 17import os 18import time 19from itertools import pairwise 20 21import qtpy 22 23# Make sure pyqtgraph uses the same Qt binding that qtpy picked. 24os.environ.setdefault("PYQTGRAPH_QT_LIB", qtpy.API_NAME) 25 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) 57 58from . import manifest as m 59 60pg.setConfigOptions(antialias=True) 61 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) 73 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"} 85 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()}} 119 120H_GAP = 40 121V_GAP = 90 122PAD_X = 16 123PAD_Y = 9 124 125 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"]) 131 132 133CONFLICT_COLOR = "#f08c00" # the version-conflict badge 134 135 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) 140 141 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 151 152 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, "") 166 167 168class NodeItem(pg.GraphicsObject): 169 """A package box, positioned by its top-left corner in view coordinates.""" 170 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) 185 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)) 193 194 def refresh_fonts(self): 195 self._make_fonts() 196 self.set_package(self.package) # recomputes the box size 197 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: 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() 223 224 def set_dev(self, dev: bool): 225 self.dev = dev 226 self.set_package(self.package) # (the tooltip) 227 228 def set_conflict(self, declarations: list[m.Declaration] | None): 229 self.conflict = list(declarations or []) 230 self.set_package(self.package) # (the tooltip) 231 232 # geometry helpers (view coordinates) 233 def center(self) -> QPointF: 234 return self.pos() + self.rect.center() 235 236 def scene_rect(self) -> QRectF: 237 return self.rect.translated(self.pos()) 238 239 def set_center(self, point: QPointF): 240 self.setPos(point - self.rect.center()) 241 242 def boundingRect(self): 243 return self.rect.adjusted(-12, -12, 12, 12) 244 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 254 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) 269 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) 279 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)) 289 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) 298 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") 321 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) 334 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) 355 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) 366 367 368class EdgeItem: 369 """A dependency arrow: a curve plus an arrow head, both pyqtgraph items.""" 370 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 376 377 def add_to(self, viewbox): 378 viewbox.addItem(self.curve) 379 viewbox.addItem(self.arrow) 380 381 def remove_from(self, viewbox): 382 viewbox.removeItem(self.curve) 383 viewbox.removeItem(self.arrow) 384 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) 398 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]) 406 407 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: 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) 418 419 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]) 425 426 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]) 451 452 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]: 459 children[e.parent].append(e.child) 460 parents[e.child].append(e.parent) 461 return children, parents 462 463 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)): 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) 481 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) 485 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) 489 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] 498 499 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 525 526 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: 543 depth[n] = 1 544 tree[graph.root].append(n) 545 546 leaves: dict[str, int] = {} 547 548 def count(n): 549 leaves[n] = sum(count(c) for c in tree[n]) or 1 550 return leaves[n] 551 552 angle: dict[str, float] = {} 553 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 561 562 count(graph.root) 563 assign(graph.root, -math.pi / 2, 3 * math.pi / 2) 564 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) 576 577 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)} 584 585 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: 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 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) 613 614 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): 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: 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: 632 break 633 return {k: QPointF(*v) for k, v in p.items()} 634 635 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} 643 644 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.""" 650 651 package_selected = Signal(str) 652 package_activated = Signal(str) 653 package_context_menu = Signal(str, QPoint) # right-click: package name, screen position 654 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 666 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) 674 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) 696 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) 703 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 711 712 self._apply_theme_colors() 713 self.clear() 714 715 # ------------------------------------------------------------ options 716 def set_layout(self, name: str, animate: bool = True): 717 if name in LAYOUTS: 718 self.layout_name = name 719 self.relayout(animate) 720 721 def set_edge_style(self, style: str): 722 if style in EDGE_STYLES: 723 self.edge_style = style 724 self._update_edges() 725 726 def set_theme(self, key: str): 727 self.theme_key = key if key in THEME_CHOICES else AUTO_THEME 728 self.refresh_theme() 729 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() 738 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']};") 743 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} 748 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:]): 759 self.relayout(animate=False) 760 else: 761 self._update_edges() 762 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 779 780 def _kinds_shown(self) -> set[str]: 781 return {node.package.kind for node in self.nodes.values()} 782 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) 790 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 798 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: 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) 819 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: 825 node.set_package(graph.packages[name]) 826 self._mark_conflicts() 827 self._update_edges() 828 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)) 836 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) 843 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() 861 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: 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() 874 875 def _stop_animation(self): 876 self._anim_timer.stop() 877 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: 881 edge.update_geometry() 882 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: 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()) 909 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() 925 926 # ---------------------------------------------------------- searching 927 def focus_search(self): 928 self.search.setFocus() 929 self.search.selectAll() 930 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: 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)]) 939 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() 954 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: 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) 962 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) 969 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: 981 seen.add(n) 982 stack.append(n) 983 seen.discard(name) 984 return seen 985 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() 994 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) 1001 1002 # --------------------------------------------------------------- view 1003 def fit(self): 1004 if self.nodes: 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()) 1014 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 1018 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 1026 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)
169class NodeItem(pg.GraphicsObject): 170 """A package box, positioned by its top-left corner in view coordinates.""" 171 172 def __init__(self, view: DependencyGraphView, package: m.Package): 173 super().__init__() 174 self.view = view 175 self.hovered = False 176 self.selected = False 177 self.dimmed = False 178 self.conflict: list[m.Declaration] = [] # different declarations of this package 179 self.dev = False # only a dev-dependency (every edge to it is): a "dev" badge 180 self._drag_offset = QPointF() 181 self._last_click = 0.0 182 self._make_fonts() 183 self.set_package(package) 184 self.setZValue(10) 185 self.setCursor(Qt.PointingHandCursor) 186 187 def _make_fonts(self): 188 """Fonts based on the current application font (follows View → text size).""" 189 self.title_font = QFont(QApplication.font()) 190 self.title_font.setBold(True) 191 self.title_font.setPointSizeF(self.title_font.pointSizeF() + 1) 192 self.sub_font = QFont(QApplication.font()) 193 self.sub_font.setPointSizeF(max(self.title_font.pointSizeF() - 3, 7)) 194 195 def refresh_fonts(self): 196 self._make_fonts() 197 self.set_package(self.package) # recomputes the box size 198 199 def set_package(self, package: m.Package): 200 self.package = package 201 self.subtitle = _subtitle(package) 202 tw = QFontMetricsF(self.title_font).horizontalAdvance(package.name) 203 sw = QFontMetricsF(self.sub_font).horizontalAdvance(self.subtitle) 204 th = QFontMetricsF(self.title_font).height() 205 sh = QFontMetricsF(self.sub_font).height() 206 self.prepareGeometryChange() 207 self.rect = QRectF(0, 0, max(tw, sw, 70) + 2 * PAD_X, th + sh + 2 * PAD_Y) 208 tip = [f"<b>{package.name}</b> ({KIND_LABELS[package.kind]})"] 209 if package.description: 210 tip.append(package.description) 211 if package.error: 212 tip.append(f"<i>{package.error}</i>") 213 if self.dev and package.kind != m.DEV_ONLY: 214 tip.append("<i>Only a dev-dependency: fpm fetches it to build the project's tests and " 215 "examples.</i>") 216 if self.conflict: 217 tip.append("<b style='color:#e8590c'>⚠ Version conflict</b> — asked for differently:") 218 used = m.spec_key(self.conflict[0]) 219 tip += [f" {html.escape(d.parent)}: {html.escape(m.describe_spec(d.spec))}" 220 + (" <i>(used)</i>" if i == 0 else " <i>(the same)</i>" if m.spec_key(d) == used else "") 221 for i, d in enumerate(self.conflict)] 222 self.setToolTip("<br>".join(tip)) 223 self.update() 224 225 def set_dev(self, dev: bool): 226 self.dev = dev 227 self.set_package(self.package) # (the tooltip) 228 229 def set_conflict(self, declarations: list[m.Declaration] | None): 230 self.conflict = list(declarations or []) 231 self.set_package(self.package) # (the tooltip) 232 233 # geometry helpers (view coordinates) 234 def center(self) -> QPointF: 235 return self.pos() + self.rect.center() 236 237 def scene_rect(self) -> QRectF: 238 return self.rect.translated(self.pos()) 239 240 def set_center(self, point: QPointF): 241 self.setPos(point - self.rect.center()) 242 243 def boundingRect(self): 244 return self.rect.adjusted(-12, -12, 12, 12) 245 246 def paint(self, painter: QPainter, option, widget=None): 247 theme = self.view.theme 248 kind = self.package.kind 249 painter.setRenderHint(QPainter.Antialiasing) 250 painter.setOpacity(0.3 if self.dimmed else 1.0) 251 radius = 9 252 fill = QColor(theme["fill"][kind]) 253 border = node_border(theme, kind) 254 highlight = self.selected or self.hovered 255 256 if theme["shadow"]: 257 painter.setPen(Qt.NoPen) 258 for i, grow in enumerate((4, 2)): 259 painter.setBrush(QColor(0, 0, 0, theme["shadow"] // (i + 2))) 260 painter.drawRoundedRect(self.rect.adjusted(-grow + 2, -grow + 4, grow + 2, grow + 4), 261 radius + grow, radius + grow) 262 if theme["glow"] or highlight: 263 glow = QColor(theme["down"]) if self.selected else QColor(border) 264 strength = 1.0 if highlight else 0.5 265 painter.setBrush(Qt.NoBrush) 266 for width, alpha in ((9, 25), (6, 45), (3, 80)): 267 glow.setAlpha(int(alpha * strength)) 268 painter.setPen(QPen(glow, width)) 269 painter.drawRoundedRect(self.rect, radius, radius) 270 271 gradient = QLinearGradient(self.rect.topLeft(), self.rect.bottomLeft()) 272 gradient.setColorAt(0, fill.lighter(118)) 273 gradient.setColorAt(1, fill) 274 pen = QPen(QColor(theme["down"]) if self.selected else border, 2.4 if highlight else 1.4) 275 if kind in (m.MISSING, m.DEV_ONLY): 276 pen.setStyle(Qt.DashLine) 277 painter.setPen(pen) 278 painter.setBrush(QBrush(gradient)) 279 painter.drawRoundedRect(self.rect, radius, radius) 280 281 # Kind accent stripe for themes that use one fill color for all kinds. 282 if theme["border"]: 283 body = QPainterPath() 284 body.addRoundedRect(self.rect.adjusted(3, 3, -3, -3), radius - 2, radius - 2) 285 strip = QPainterPath() 286 strip.addRect(QRectF(self.rect.left(), self.rect.top(), 7, self.rect.height())) 287 painter.setPen(Qt.NoPen) 288 painter.setBrush(border) 289 painter.drawPath(body.intersected(strip)) 290 291 th = QFontMetricsF(self.title_font).height() 292 painter.setFont(self.title_font) 293 painter.setPen(QColor(theme["text"])) 294 painter.drawText(QRectF(0, PAD_Y, self.rect.width(), th), Qt.AlignCenter, self.package.name) 295 painter.setFont(self.sub_font) 296 painter.setPen(QColor(theme["subtext"])) 297 painter.drawText(QRectF(0, PAD_Y + th, self.rect.width(), self.rect.height() - th - 2 * PAD_Y), 298 Qt.AlignCenter, self.subtitle) 299 300 if self.conflict: # an orange "!" badge on the top-right corner 301 badge = QRectF(self.rect.right() - 11, self.rect.top() - 9, 20, 20) 302 painter.setPen(QPen(QColor("white"), 1.5)) 303 painter.setBrush(QColor(CONFLICT_COLOR)) 304 painter.drawEllipse(badge) 305 font = QFont(self.title_font) 306 font.setPointSizeF(max(self.sub_font.pointSizeF(), 9)) 307 painter.setFont(font) 308 painter.setPen(QColor("white")) 309 painter.drawText(badge, Qt.AlignCenter, "!") 310 if self.dev: # a "dev" tag on the top-left corner 311 font = QFont(self.sub_font) 312 font.setBold(True) 313 fm = QFontMetricsF(font) 314 badge = QRectF(self.rect.left() - 6, self.rect.top() - fm.height() / 2 - 2, 315 fm.horizontalAdvance("dev") + 10, fm.height() + 4) 316 painter.setPen(QPen(QColor("white"), 1.2)) 317 painter.setBrush(QColor(DEV_COLOR)) 318 painter.drawRoundedRect(badge, badge.height() / 2, badge.height() / 2) 319 painter.setFont(font) 320 painter.setPen(QColor("white")) 321 painter.drawText(badge, Qt.AlignCenter, "dev") 322 323 # pyqtgraph mouse events 324 def hoverEvent(self, ev): 325 if ev.isExit(): 326 if self.hovered: 327 self.hovered = False 328 self.view._hover_changed(self, False) 329 return 330 ev.acceptClicks(Qt.LeftButton) 331 ev.acceptDrags(Qt.LeftButton) 332 if not self.hovered: 333 self.hovered = True 334 self.view._hover_changed(self, True) 335 336 def mouseClickEvent(self, ev): 337 if ev.button() == Qt.RightButton: # the package's menu (see MainWindow) 338 ev.accept() 339 self.view.select(self.package.name, center=False) 340 self.view.package_context_menu.emit(self.package.name, ev.screenPos().toPoint()) 341 return 342 if ev.button() != Qt.LeftButton: 343 ev.ignore() 344 return 345 ev.accept() 346 # pyqtgraph only flags ev.double() for some event orders, so also 347 # detect a second click within the system double-click interval. 348 now = time.monotonic() 349 interval = QApplication.doubleClickInterval() / 1000 350 if ev.double() or now - self._last_click < interval: 351 self._last_click = 0.0 352 self.view.package_activated.emit(self.package.name) 353 else: 354 self._last_click = now 355 self.view.select(self.package.name, center=False) 356 357 def mouseDragEvent(self, ev): 358 if ev.button() != Qt.LeftButton: 359 ev.ignore() 360 return 361 ev.accept() 362 if ev.isStart(): 363 self._drag_offset = self.pos() - self.mapToParent(ev.buttonDownPos()) 364 self.view._stop_animation() 365 self.setPos(self.mapToParent(ev.pos()) + self._drag_offset) 366 self.view._update_edges(self)
A package box, positioned by its top-left corner in view coordinates.
172 def __init__(self, view: DependencyGraphView, package: m.Package): 173 super().__init__() 174 self.view = view 175 self.hovered = False 176 self.selected = False 177 self.dimmed = False 178 self.conflict: list[m.Declaration] = [] # different declarations of this package 179 self.dev = False # only a dev-dependency (every edge to it is): a "dev" badge 180 self._drag_offset = QPointF() 181 self._last_click = 0.0 182 self._make_fonts() 183 self.set_package(package) 184 self.setZValue(10) 185 self.setCursor(Qt.PointingHandCursor)
__init__(self, /, parent: PySide6.QtWidgets.QGraphicsItem | None = None, *, opacity: float | None = None, enabled: bool | None = None, visible: bool | None = None, pos: PySide6.QtCore.QPointF | None = None, x: float | None = None, y: float | None = None, z: float | None = None, rotation: float | None = None, scale: float | None = None, transformOriginPoint: PySide6.QtCore.QPointF | None = None, effect: PySide6.QtWidgets.QGraphicsEffect | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
199 def set_package(self, package: m.Package): 200 self.package = package 201 self.subtitle = _subtitle(package) 202 tw = QFontMetricsF(self.title_font).horizontalAdvance(package.name) 203 sw = QFontMetricsF(self.sub_font).horizontalAdvance(self.subtitle) 204 th = QFontMetricsF(self.title_font).height() 205 sh = QFontMetricsF(self.sub_font).height() 206 self.prepareGeometryChange() 207 self.rect = QRectF(0, 0, max(tw, sw, 70) + 2 * PAD_X, th + sh + 2 * PAD_Y) 208 tip = [f"<b>{package.name}</b> ({KIND_LABELS[package.kind]})"] 209 if package.description: 210 tip.append(package.description) 211 if package.error: 212 tip.append(f"<i>{package.error}</i>") 213 if self.dev and package.kind != m.DEV_ONLY: 214 tip.append("<i>Only a dev-dependency: fpm fetches it to build the project's tests and " 215 "examples.</i>") 216 if self.conflict: 217 tip.append("<b style='color:#e8590c'>⚠ Version conflict</b> — asked for differently:") 218 used = m.spec_key(self.conflict[0]) 219 tip += [f" {html.escape(d.parent)}: {html.escape(m.describe_spec(d.spec))}" 220 + (" <i>(used)</i>" if i == 0 else " <i>(the same)</i>" if m.spec_key(d) == used else "") 221 for i, d in enumerate(self.conflict)] 222 self.setToolTip("<br>".join(tip)) 223 self.update()
246 def paint(self, painter: QPainter, option, widget=None): 247 theme = self.view.theme 248 kind = self.package.kind 249 painter.setRenderHint(QPainter.Antialiasing) 250 painter.setOpacity(0.3 if self.dimmed else 1.0) 251 radius = 9 252 fill = QColor(theme["fill"][kind]) 253 border = node_border(theme, kind) 254 highlight = self.selected or self.hovered 255 256 if theme["shadow"]: 257 painter.setPen(Qt.NoPen) 258 for i, grow in enumerate((4, 2)): 259 painter.setBrush(QColor(0, 0, 0, theme["shadow"] // (i + 2))) 260 painter.drawRoundedRect(self.rect.adjusted(-grow + 2, -grow + 4, grow + 2, grow + 4), 261 radius + grow, radius + grow) 262 if theme["glow"] or highlight: 263 glow = QColor(theme["down"]) if self.selected else QColor(border) 264 strength = 1.0 if highlight else 0.5 265 painter.setBrush(Qt.NoBrush) 266 for width, alpha in ((9, 25), (6, 45), (3, 80)): 267 glow.setAlpha(int(alpha * strength)) 268 painter.setPen(QPen(glow, width)) 269 painter.drawRoundedRect(self.rect, radius, radius) 270 271 gradient = QLinearGradient(self.rect.topLeft(), self.rect.bottomLeft()) 272 gradient.setColorAt(0, fill.lighter(118)) 273 gradient.setColorAt(1, fill) 274 pen = QPen(QColor(theme["down"]) if self.selected else border, 2.4 if highlight else 1.4) 275 if kind in (m.MISSING, m.DEV_ONLY): 276 pen.setStyle(Qt.DashLine) 277 painter.setPen(pen) 278 painter.setBrush(QBrush(gradient)) 279 painter.drawRoundedRect(self.rect, radius, radius) 280 281 # Kind accent stripe for themes that use one fill color for all kinds. 282 if theme["border"]: 283 body = QPainterPath() 284 body.addRoundedRect(self.rect.adjusted(3, 3, -3, -3), radius - 2, radius - 2) 285 strip = QPainterPath() 286 strip.addRect(QRectF(self.rect.left(), self.rect.top(), 7, self.rect.height())) 287 painter.setPen(Qt.NoPen) 288 painter.setBrush(border) 289 painter.drawPath(body.intersected(strip)) 290 291 th = QFontMetricsF(self.title_font).height() 292 painter.setFont(self.title_font) 293 painter.setPen(QColor(theme["text"])) 294 painter.drawText(QRectF(0, PAD_Y, self.rect.width(), th), Qt.AlignCenter, self.package.name) 295 painter.setFont(self.sub_font) 296 painter.setPen(QColor(theme["subtext"])) 297 painter.drawText(QRectF(0, PAD_Y + th, self.rect.width(), self.rect.height() - th - 2 * PAD_Y), 298 Qt.AlignCenter, self.subtitle) 299 300 if self.conflict: # an orange "!" badge on the top-right corner 301 badge = QRectF(self.rect.right() - 11, self.rect.top() - 9, 20, 20) 302 painter.setPen(QPen(QColor("white"), 1.5)) 303 painter.setBrush(QColor(CONFLICT_COLOR)) 304 painter.drawEllipse(badge) 305 font = QFont(self.title_font) 306 font.setPointSizeF(max(self.sub_font.pointSizeF(), 9)) 307 painter.setFont(font) 308 painter.setPen(QColor("white")) 309 painter.drawText(badge, Qt.AlignCenter, "!") 310 if self.dev: # a "dev" tag on the top-left corner 311 font = QFont(self.sub_font) 312 font.setBold(True) 313 fm = QFontMetricsF(font) 314 badge = QRectF(self.rect.left() - 6, self.rect.top() - fm.height() / 2 - 2, 315 fm.horizontalAdvance("dev") + 10, fm.height() + 4) 316 painter.setPen(QPen(QColor("white"), 1.2)) 317 painter.setBrush(QColor(DEV_COLOR)) 318 painter.drawRoundedRect(badge, badge.height() / 2, badge.height() / 2) 319 painter.setFont(font) 320 painter.setPen(QColor("white")) 321 painter.drawText(badge, Qt.AlignCenter, "dev")
paint(self, painter: PySide6.QtGui.QPainter, option: PySide6.QtWidgets.QStyleOptionGraphicsItem, /, widget: PySide6.QtWidgets.QWidget | None = None) -> None
324 def hoverEvent(self, ev): 325 if ev.isExit(): 326 if self.hovered: 327 self.hovered = False 328 self.view._hover_changed(self, False) 329 return 330 ev.acceptClicks(Qt.LeftButton) 331 ev.acceptDrags(Qt.LeftButton) 332 if not self.hovered: 333 self.hovered = True 334 self.view._hover_changed(self, True)
336 def mouseClickEvent(self, ev): 337 if ev.button() == Qt.RightButton: # the package's menu (see MainWindow) 338 ev.accept() 339 self.view.select(self.package.name, center=False) 340 self.view.package_context_menu.emit(self.package.name, ev.screenPos().toPoint()) 341 return 342 if ev.button() != Qt.LeftButton: 343 ev.ignore() 344 return 345 ev.accept() 346 # pyqtgraph only flags ev.double() for some event orders, so also 347 # detect a second click within the system double-click interval. 348 now = time.monotonic() 349 interval = QApplication.doubleClickInterval() / 1000 350 if ev.double() or now - self._last_click < interval: 351 self._last_click = 0.0 352 self.view.package_activated.emit(self.package.name) 353 else: 354 self._last_click = now 355 self.view.select(self.package.name, center=False)
357 def mouseDragEvent(self, ev): 358 if ev.button() != Qt.LeftButton: 359 ev.ignore() 360 return 361 ev.accept() 362 if ev.isStart(): 363 self._drag_offset = self.pos() - self.mapToParent(ev.buttonDownPos()) 364 self.view._stop_animation() 365 self.setPos(self.mapToParent(ev.pos()) + self._drag_offset) 366 self.view._update_edges(self)
369class EdgeItem: 370 """A dependency arrow: a curve plus an arrow head, both pyqtgraph items.""" 371 372 def __init__(self, view: DependencyGraphView, source: NodeItem, target: NodeItem, dev: bool): 373 self.view, self.source, self.target, self.dev = view, source, target, dev 374 self.curve = pg.PlotCurveItem() 375 self.arrow = pg.ArrowItem(headLen=12, tipAngle=32, tailLen=None, pen=None) 376 self.state = "normal" # normal | hover | down | up | dim 377 378 def add_to(self, viewbox): 379 viewbox.addItem(self.curve) 380 viewbox.addItem(self.arrow) 381 382 def remove_from(self, viewbox): 383 viewbox.removeItem(self.curve) 384 viewbox.removeItem(self.arrow) 385 386 def apply_style(self): 387 theme = self.view.theme 388 emphasis = self.state in ("down", "up", "hover") 389 color = QColor({"down": theme["down"], "up": theme["up"], 390 "hover": theme["down"]}.get(self.state, theme["edge"])) 391 if self.state == "dim": 392 color.setAlpha(60) 393 pen = pg.mkPen(color, width=2.6 if emphasis else 1.5, 394 style=Qt.DashLine if self.dev else Qt.SolidLine) 395 self.curve.setPen(pen) 396 self.arrow.setStyle(brush=pg.mkBrush(color), pen=pg.mkPen(color)) 397 self.curve.setZValue(2 if emphasis else 0) 398 self.arrow.setZValue(3 if emphasis else 1) 399 400 def update_geometry(self): 401 points, (dx, dy) = edge_geometry(self.source.scene_rect(), self.target.scene_rect(), 402 self.view.layout_name, self.view.edge_style == "curved") 403 self.curve.setData(points[:, 0], points[:, 1]) 404 # ArrowItem points left at angle 0 and rotates clockwise on screen. 405 self.arrow.setStyle(angle=math.degrees(math.atan2(-dy, -dx))) 406 self.arrow.setPos(points[-1, 0], points[-1, 1])
A dependency arrow: a curve plus an arrow head, both pyqtgraph items.
372 def __init__(self, view: DependencyGraphView, source: NodeItem, target: NodeItem, dev: bool): 373 self.view, self.source, self.target, self.dev = view, source, target, dev 374 self.curve = pg.PlotCurveItem() 375 self.arrow = pg.ArrowItem(headLen=12, tipAngle=32, tailLen=None, pen=None) 376 self.state = "normal" # normal | hover | down | up | dim
386 def apply_style(self): 387 theme = self.view.theme 388 emphasis = self.state in ("down", "up", "hover") 389 color = QColor({"down": theme["down"], "up": theme["up"], 390 "hover": theme["down"]}.get(self.state, theme["edge"])) 391 if self.state == "dim": 392 color.setAlpha(60) 393 pen = pg.mkPen(color, width=2.6 if emphasis else 1.5, 394 style=Qt.DashLine if self.dev else Qt.SolidLine) 395 self.curve.setPen(pen) 396 self.arrow.setStyle(brush=pg.mkBrush(color), pen=pg.mkPen(color)) 397 self.curve.setZValue(2 if emphasis else 0) 398 self.arrow.setZValue(3 if emphasis else 1)
400 def update_geometry(self): 401 points, (dx, dy) = edge_geometry(self.source.scene_rect(), self.target.scene_rect(), 402 self.view.layout_name, self.view.edge_style == "curved") 403 self.curve.setData(points[:, 0], points[:, 1]) 404 # ArrowItem points left at angle 0 and rotates clockwise on screen. 405 self.arrow.setStyle(angle=math.degrees(math.atan2(-dy, -dx))) 406 self.arrow.setPos(points[-1, 0], points[-1, 1])
428def edge_geometry(s: QRectF, t: QRectF, layout: str, curved: bool): 429 """Return (Nx2 points, end direction) for an arrow from rect s to rect t.""" 430 sc, tc = s.center(), t.center() 431 if curved and layout == LAYERED_TB and t.top() > s.bottom() + 4: 432 p0, p3 = QPointF(sc.x(), s.bottom()), QPointF(tc.x(), t.top()) 433 dy = (p3.y() - p0.y()) / 2 434 c1, c2 = QPointF(p0.x(), p0.y() + dy), QPointF(p3.x(), p3.y() - dy) 435 elif curved and layout == LAYERED_LR and t.left() > s.right() + 4: 436 p0, p3 = QPointF(s.right(), sc.y()), QPointF(t.left(), tc.y()) 437 dx = (p3.x() - p0.x()) / 2 438 c1, c2 = QPointF(p0.x() + dx, p0.y()), QPointF(p3.x() - dx, p3.y()) 439 elif curved: 440 # A gentle arc bowing to one side of the straight line. 441 v = tc - sc 442 ctrl = (sc + tc) / 2 + QPointF(-v.y(), v.x()) * 0.18 443 p0, p3 = _clip_to_rect(s, ctrl), _clip_to_rect(t, ctrl) 444 c1, c2 = p0 + (ctrl - p0) * (2 / 3), p3 + (ctrl - p3) * (2 / 3) 445 else: 446 p0, p3 = _clip_to_rect(s, tc), _clip_to_rect(t, sc) 447 pts = np.array([[p0.x(), p0.y()], [p3.x(), p3.y()]]) 448 return pts, (p3.x() - p0.x(), p3.y() - p0.y()) 449 pts = _bezier(p0, c1, c2, p3) 450 d = pts[-1] - pts[-3] 451 return pts, (d[0], d[1])
Return (Nx2 points, end direction) for an arrow from rect s to rect t.
465def compute_layers(graph: m.DependencyGraph) -> list[list[str]]: 466 """Assign each package to a layer (longest path from the root) and order 467 the layers to reduce crossings.""" 468 children, parents = _adjacency(graph) 469 depth = {graph.root: 0} 470 # Longest-path layering; the iteration cap guards against cycles. 471 for _ in range(len(graph.packages)): 472 changed = False 473 for e in graph.edges: 474 new_depth = depth[e.parent] + 1 if e.parent in depth else None 475 if new_depth is not None and depth.get(e.child, -1) < new_depth < len(graph.packages): 476 depth[e.child] = new_depth 477 changed = True 478 if not changed: 479 break 480 for name in graph.packages: 481 depth.setdefault(name, 1) 482 483 layers: list[list[str]] = [[] for _ in range(max(depth.values()) + 1)] 484 for name in graph.packages: # BFS order gives a sensible starting order 485 layers[depth[name]].append(name) 486 487 def barycenter(name, neighbours, index): 488 pos = [index[n] for n in neighbours[name] if n in index] 489 return sum(pos) / len(pos) if pos else index.get(name, 0) 490 491 for _ in range(4): 492 for i in range(1, len(layers)): # downward sweep 493 index = {n: k for k, n in enumerate(layers[i - 1])} 494 layers[i].sort(key=lambda n: barycenter(n, parents, index)) 495 for i in range(len(layers) - 2, 0, -1): # upward sweep 496 index = {n: k for k, n in enumerate(layers[i + 1])} 497 layers[i].sort(key=lambda n: barycenter(n, children, index)) 498 return [layer for layer in layers if layer]
Assign each package to a layer (longest path from the root) and order the layers to reduce crossings.
501def layout_layered(graph, sizes, horizontal=False) -> dict[str, QPointF]: 502 layers = compute_layers(graph) 503 pos = {} 504 if not horizontal: 505 y = 0.0 506 for layer in layers: 507 width = sum(sizes[n][0] for n in layer) + H_GAP * (len(layer) - 1) 508 height = max(sizes[n][1] for n in layer) 509 x = -width / 2 510 for name in layer: 511 pos[name] = QPointF(x + sizes[name][0] / 2, y + height / 2) 512 x += sizes[name][0] + H_GAP 513 y += height + V_GAP 514 else: 515 gap = 22 516 x = 0.0 517 for layer in layers: 518 col_w = max(sizes[n][0] for n in layer) 519 total = sum(sizes[n][1] for n in layer) + gap * (len(layer) - 1) 520 y = -total / 2 521 for name in layer: 522 pos[name] = QPointF(x + col_w / 2, y + sizes[name][1] / 2) 523 y += sizes[name][1] + gap 524 x += col_w + V_GAP * 1.3 525 return pos
528def layout_radial(graph, sizes) -> dict[str, QPointF]: 529 """Root in the middle, a breadth-first spanning tree fanned out in rings.""" 530 children, _ = _adjacency(graph) 531 rank = {n: i for i, n in enumerate(n for layer in compute_layers(graph) for n in layer)} 532 tree = {n: [] for n in graph.packages} 533 depth = {graph.root: 0} 534 queue = [graph.root] 535 while queue: 536 node = queue.pop(0) 537 for child in sorted(children[node], key=rank.get): 538 if child not in depth: 539 depth[child] = depth[node] + 1 540 tree[node].append(child) 541 queue.append(child) 542 for n in graph.packages: # anything unreachable hangs off the root 543 if n not in depth: 544 depth[n] = 1 545 tree[graph.root].append(n) 546 547 leaves: dict[str, int] = {} 548 549 def count(n): 550 leaves[n] = sum(count(c) for c in tree[n]) or 1 551 return leaves[n] 552 553 angle: dict[str, float] = {} 554 555 def assign(n, a0, a1): 556 angle[n] = (a0 + a1) / 2 557 a = a0 558 for c in tree[n]: 559 span = (a1 - a0) * leaves[c] / leaves[n] 560 assign(c, a, a + span) 561 a += span 562 563 count(graph.root) 564 assign(graph.root, -math.pi / 2, 3 * math.pi / 2) 565 566 # Every leaf gets an equal slice of the circle, so neighbours on a ring 567 # are at least one slice apart; make each ring big enough for that. 568 radius = [0.0] 569 slice_angle = 2 * math.pi / leaves[graph.root] 570 step = max(max(h for _, h in sizes.values()) * 3, 150) 571 for d in range(1, max(depth.values()) + 1): 572 widest = max(sizes[n][0] for n in graph.packages if depth[n] == d) 573 radius.append(max(radius[-1] + step, (widest + H_GAP) / slice_angle)) 574 pos = {n: QPointF(radius[depth[n]] * math.cos(angle[n]), 575 radius[depth[n]] * math.sin(angle[n])) for n in graph.packages} 576 return _remove_overlaps(pos, sizes)
Root in the middle, a breadth-first spanning tree fanned out in rings.
579def layout_circular(graph, sizes) -> dict[str, QPointF]: 580 order = [n for layer in compute_layers(graph) for n in layer] 581 r = max(sum(sizes[n][0] + H_GAP for n in order) / (2 * math.pi), 150) 582 step = 2 * math.pi / max(len(order), 1) 583 return {n: QPointF(r * math.cos(-math.pi / 2 + i * step), r * math.sin(-math.pi / 2 + i * step)) 584 for i, n in enumerate(order)}
587def layout_force(graph, sizes) -> dict[str, QPointF]: 588 """Fruchterman-Reingold, seeded from the layered layout (deterministic).""" 589 names = list(graph.packages) 590 start = layout_layered(graph, sizes) 591 n = len(names) 592 if n < 2: 593 return start 594 pos = np.array([[start[k].x(), start[k].y()] for k in names], dtype=float) 595 index = {k: i for i, k in enumerate(names)} 596 edges = np.array([(index[e.parent], index[e.child]) for e in graph.edges], dtype=int).reshape(-1, 2) 597 k = 190.0 598 temperature = 120.0 599 for _ in range(400): 600 delta = pos[:, None, :] - pos[None, :, :] 601 dist2 = (delta ** 2).sum(-1) + np.eye(n) 602 disp = (delta * (k * k / dist2)[:, :, None]).sum(1) # repulsion 603 if len(edges): # attraction along edges 604 d = pos[edges[:, 1]] - pos[edges[:, 0]] 605 pull = d * (np.sqrt((d ** 2).sum(-1))[:, None] / k) 606 np.add.at(disp, edges[:, 0], pull) 607 np.add.at(disp, edges[:, 1], -pull) 608 disp -= (pos - pos.mean(0)) * 0.01 # gentle gravity 609 length = np.sqrt((disp ** 2).sum(-1))[:, None] + 1e-9 610 pos += disp / length * np.minimum(length, temperature) 611 temperature = max(1.0, temperature * 0.985) 612 result = {name: QPointF(*pos[i]) for i, name in enumerate(names)} 613 return _remove_overlaps(result, sizes)
Fruchterman-Reingold, seeded from the layered layout (deterministic).
647class DependencyGraphView(QWidget): 648 """Shows a `vibefpmgui.manifest.DependencyGraph`. Wheel zooms, drag pans, 649 nodes can be dragged, click selects (highlighting what the package 650 depends on and what uses it), double-click opens.""" 651 652 package_selected = Signal(str) 653 package_activated = Signal(str) 654 package_context_menu = Signal(str, QPoint) # right-click: package name, screen position 655 656 def __init__(self, parent=None): 657 super().__init__(parent) 658 self.layout_name = LAYERED_TB 659 self.edge_style = "curved" 660 self.theme_key = AUTO_THEME 661 self.theme = resolve_theme(self.theme_key) 662 self.graph: m.DependencyGraph | None = None 663 self.conflicts: dict[str, list[m.Declaration]] = {} 664 self.nodes: dict[str, NodeItem] = {} 665 self.edges: list[EdgeItem] = [] 666 self.selected: str | None = None 667 668 self.plot = pg.GraphicsLayoutWidget() 669 self.plot.ci.setContentsMargins(0, 0, 0, 0) 670 self.vb = self.plot.addViewBox() 671 self.vb.setAspectLocked(True) 672 self.vb.invertY(True) # y grows downward, like the screen 673 self.vb.setMenuEnabled(False) 674 self.plot.scene().sigMouseClicked.connect(self._scene_clicked) 675 676 # "Find package…": over the graph's top-left corner. 677 self.search = QLineEdit(self.plot) 678 self.search.setPlaceholderText("Find package…") 679 self.search.setClearButtonEnabled(True) 680 self.search.setFixedWidth(220) 681 # Readable on every graph theme (light, dark, blueprint, neon). 682 self.search.setStyleSheet("QLineEdit { background: rgba(128, 128, 128, 55); border: 1px solid " 683 "rgba(128, 128, 128, 140); border-radius: 7px; padding: 4px 8px; }" 684 "QLineEdit:focus { border-color: rgba(77, 148, 255, 220); }") 685 self.search.setToolTip("Type to highlight matching packages; Enter selects the next match, " 686 "Esc clears") 687 self.search_model = QStringListModel(self) 688 completer = QCompleter(self.search_model, self.search) 689 completer.setCaseSensitivity(Qt.CaseInsensitive) 690 completer.setFilterMode(Qt.MatchContains) 691 completer.activated.connect(lambda text: self._jump_to_match(text)) 692 self.search.setCompleter(completer) 693 self.search.textChanged.connect(self._highlight_matches) 694 self.search.returnPressed.connect(lambda: self._jump_to_match(None)) 695 self.search.installEventFilter(self) 696 self.search.move(10, 10) 697 698 self.message = QLabel() 699 self.message.setAlignment(Qt.AlignCenter) 700 self.message.setWordWrap(True) 701 self.stack = QStackedLayout(self) 702 self.stack.addWidget(self.message) 703 self.stack.addWidget(self.plot) 704 705 self._anim_timer = QTimer(self) 706 self._anim_timer.setInterval(16) 707 self._anim_timer.timeout.connect(self._animate_step) 708 self._anim_from: dict[str, QPointF] = {} 709 self._anim_to: dict[str, QPointF] = {} 710 self._anim_frame = 0 711 self._anim_frames = 24 712 713 self._apply_theme_colors() 714 self.clear() 715 716 # ------------------------------------------------------------ options 717 def set_layout(self, name: str, animate: bool = True): 718 if name in LAYOUTS: 719 self.layout_name = name 720 self.relayout(animate) 721 722 def set_edge_style(self, style: str): 723 if style in EDGE_STYLES: 724 self.edge_style = style 725 self._update_edges() 726 727 def set_theme(self, key: str): 728 self.theme_key = key if key in THEME_CHOICES else AUTO_THEME 729 self.refresh_theme() 730 731 def refresh_theme(self): 732 """Re-resolve the theme (e.g. after the system switches dark mode).""" 733 self.theme = resolve_theme(self.theme_key) 734 self._apply_theme_colors() 735 for node in self.nodes.values(): 736 node.update() 737 for edge in self.edges: 738 edge.apply_style() 739 740 def _apply_theme_colors(self): 741 self.plot.setBackground(self.theme["background"]) 742 self.message.setStyleSheet( 743 f"background:{self.theme['background']}; color:{self.theme['subtext']};") 744 745 def package_colors(self) -> dict[str, str]: 746 """The color that identifies each package kind in the current theme.""" 747 return {k: (node_border(self.theme, k).name() if self.theme["border"] else self.theme["fill"][k]) 748 for k in KIND_ORDER} 749 750 def refresh_fonts(self): 751 """Resize the package boxes after the text size changed, keeping each 752 box centred where it was, then tidy overlaps by re-running the layout 753 only if boxes now collide.""" 754 for node in self.nodes.values(): 755 center = node.center() 756 node.refresh_fonts() 757 node.set_center(center) 758 rects = [n.scene_rect() for n in self.nodes.values()] 759 if any(a.intersects(b) for i, a in enumerate(rects) for b in rects[i + 1:]): 760 self.relayout(animate=False) 761 else: 762 self._update_edges() 763 764 def legend_html(self) -> str: 765 theme = self.theme 766 parts = [f'<span style="background:{theme["fill"][k]}; color:{theme["text"]}">' 767 f' <span style="color:{node_border(theme, k).name()}">●</span>' 768 f' {KIND_LABELS[k]} </span>' for k in KIND_ORDER 769 if k != m.DEV_ONLY or k in self._kinds_shown()] 770 text = (" ".join(parts) 771 + f" <span style='color:{theme['down']}'>━</span> depends on" 772 + f" <span style='color:{theme['up']}'>━</span> used by" 773 + " <i>dashed = dev</i>") 774 if any(node.dev for node in self.nodes.values()): 775 text += f" <span style='background:{DEV_COLOR}; color:white'> dev </span> only a dev-dependency" 776 if self.conflicts: 777 text += (f" <span style='color:{CONFLICT_COLOR}'><b>!</b> version conflict" 778 f"{'s' * (len(self.conflicts) != 1)}</span>") 779 return text 780 781 def _kinds_shown(self) -> set[str]: 782 return {node.package.kind for node in self.nodes.values()} 783 784 # -------------------------------------------------------------- graph 785 def clear(self, message="Open an fpm.toml file to see its dependency graph."): 786 self.graph = None 787 self.conflicts = {} 788 self._clear_items() 789 self.message.setText(message) 790 self.stack.setCurrentWidget(self.message) 791 792 def _clear_items(self): 793 self._stop_animation() 794 for node in self.nodes.values(): 795 self.vb.removeItem(node) 796 for edge in self.edges: 797 edge.remove_from(self.vb) 798 self.nodes, self.edges, self.selected = {}, [], None 799 800 def set_graph(self, graph: m.DependencyGraph): 801 self._clear_items() 802 self.graph = graph 803 self.stack.setCurrentWidget(self.plot) 804 for name, pkg in graph.packages.items(): 805 node = NodeItem(self, pkg) 806 self.vb.addItem(node) 807 self.nodes[name] = node 808 seen = set() 809 for e in graph.edges: 810 if (e.parent, e.child) in seen: 811 continue 812 seen.add((e.parent, e.child)) 813 edge = EdgeItem(self, self.nodes[e.parent], self.nodes[e.child], e.dev) 814 edge.add_to(self.vb) 815 edge.apply_style() 816 self.edges.append(edge) 817 self._mark_dev() 818 self._mark_conflicts() 819 self.relayout(animate=False) 820 821 def update_packages(self, graph: m.DependencyGraph): 822 """Swap in refreshed package details without redoing the layout.""" 823 self.graph = graph 824 for name, node in self.nodes.items(): 825 if name in graph.packages: 826 node.set_package(graph.packages[name]) 827 self._mark_conflicts() 828 self._update_edges() 829 830 def _mark_dev(self): 831 """The packages only dev-dependencies: every edge to them is a dev one.""" 832 dev: dict[str, bool] = {} 833 for e in self.graph.edges if self.graph else []: 834 dev[e.child] = dev.get(e.child, True) and e.dev 835 for name, node in self.nodes.items(): 836 node.set_dev(dev.get(name, False)) 837 838 def _mark_conflicts(self): 839 self.conflicts = m.conflicts(self.graph) if self.graph else {} 840 for name, node in self.nodes.items(): 841 node.set_conflict(self.conflicts.get(name)) 842 names = sorted(self.nodes, key=str.lower) 843 self.search_model.setStringList(names) 844 845 def relayout(self, animate: bool = True): 846 """Place every package with the current layout (undoing any drags).""" 847 if not self.graph or not self.nodes: 848 return 849 sizes = {n: (node.rect.width(), node.rect.height()) for n, node in self.nodes.items()} 850 target = LAYOUT_FUNCTIONS[self.layout_name](self.graph, sizes) 851 if animate and self.isVisible(): 852 self._anim_from = {n: node.center() for n, node in self.nodes.items()} 853 self._anim_to = target 854 self._anim_frame = 0 855 self._anim_timer.start() 856 else: 857 self._stop_animation() 858 for name, point in target.items(): 859 self.nodes[name].set_center(point) 860 self._update_edges() 861 self.fit() 862 863 def _animate_step(self): 864 self._anim_frame += 1 865 t = min(self._anim_frame / self._anim_frames, 1.0) 866 ease = 1 - (1 - t) ** 3 867 for name, node in self.nodes.items(): 868 a, b = self._anim_from.get(name), self._anim_to.get(name) 869 if a is not None and b is not None: 870 node.set_center(a + (b - a) * ease) 871 self._update_edges() 872 if t >= 1.0: 873 self._anim_timer.stop() 874 self.fit() 875 876 def _stop_animation(self): 877 self._anim_timer.stop() 878 879 def _update_edges(self, node: NodeItem | None = None): 880 for edge in self.edges: 881 if node is None or edge.source is node or edge.target is node: 882 edge.update_geometry() 883 884 # ---------------------------------------------------------- selection 885 def select(self, name: str | None, center: bool = True): 886 """Select a package (None clears) and highlight its dependency chains.""" 887 if name is not None and name not in self.nodes: 888 return 889 self.selected = name 890 down, up = self._reachable(name, forward=True), self._reachable(name, forward=False) 891 for n, node in self.nodes.items(): 892 node.selected = n == name 893 node.dimmed = name is not None and n != name and n not in down and n not in up 894 node.update() 895 for edge in self.edges: 896 p, c = edge.source.package.name, edge.target.package.name 897 if name is None: 898 edge.state = "hover" if edge.source.hovered or edge.target.hovered else "normal" 899 elif p in down | {name} and c in down: 900 edge.state = "down" 901 elif c in up | {name} and p in up: 902 edge.state = "up" 903 else: 904 edge.state = "dim" 905 edge.apply_style() 906 if name is not None: 907 self.package_selected.emit(name) 908 if center: 909 self.vb.translateBy(self.nodes[name].center() - self.vb.viewRect().center()) 910 911 def show_chains(self, name: str, chains: list[list[str]]): 912 """Highlight the dependency chains from the project down to ``name`` 913 (Why Is This Here?); everything else is dimmed. A click on the 914 background clears it, like a selection.""" 915 on_chain = {n for chain in chains for n in chain} 916 links = {(a, b) for chain in chains for a, b in pairwise(chain)} 917 self.selected = name 918 for n, node in self.nodes.items(): 919 node.selected = n == name 920 node.dimmed = n not in on_chain 921 node.update() 922 for edge in self.edges: 923 pair = (edge.source.package.name, edge.target.package.name) 924 edge.state = "down" if pair in links else "dim" 925 edge.apply_style() 926 927 # ---------------------------------------------------------- searching 928 def focus_search(self): 929 self.search.setFocus() 930 self.search.selectAll() 931 932 def _matches(self, text: str) -> list[str]: 933 """Package names containing ``text``: ones starting with it first.""" 934 text = text.strip().lower() 935 if not text: 936 return [] 937 names = sorted(self.nodes, key=str.lower) 938 return ([n for n in names if n.lower().startswith(text)] 939 + [n for n in names if text in n.lower() and not n.lower().startswith(text)]) 940 941 def _highlight_matches(self, text: str): 942 """While typing: the matching packages stand out, the rest are dimmed.""" 943 if not text.strip(): 944 self.select(self.selected, center=False) if self.selected else self.select(None) 945 return 946 matches = set(self._matches(text)) 947 for name, node in self.nodes.items(): 948 node.selected = False 949 node.dimmed = name not in matches 950 node.update() 951 for edge in self.edges: 952 ends = {edge.source.package.name, edge.target.package.name} 953 edge.state = "normal" if ends <= matches else "dim" 954 edge.apply_style() 955 956 def _jump_to_match(self, name: str | None): 957 """Enter: select (and center) the next matching package.""" 958 matches = [name] if name in self.nodes else self._matches(self.search.text()) 959 if not matches: 960 return 961 index = (matches.index(self.selected) + 1) % len(matches) if self.selected in matches else 0 962 self.select(matches[index], center=True) 963 964 def eventFilter(self, obj, event): 965 if obj is self.search and event.type() == QEvent.KeyPress and event.key() == Qt.Key_Escape: 966 self.search.clear() 967 self.plot.setFocus() 968 return True 969 return super().eventFilter(obj, event) 970 971 def _reachable(self, name: str | None, forward: bool) -> set[str]: 972 if name is None or not self.graph: 973 return set() 974 nxt: dict[str, set[str]] = {} 975 for e in self.graph.edges: 976 a, b = (e.parent, e.child) if forward else (e.child, e.parent) 977 nxt.setdefault(a, set()).add(b) 978 seen, stack = set(), [name] 979 while stack: 980 for n in nxt.get(stack.pop(), ()): 981 if n not in seen: 982 seen.add(n) 983 stack.append(n) 984 seen.discard(name) 985 return seen 986 987 def _hover_changed(self, node: NodeItem, entered: bool): 988 node.update() 989 if self.selected is not None: 990 return # keep the selection highlighting 991 for edge in self.edges: 992 if edge.source is node or edge.target is node: 993 edge.state = "hover" if entered else "normal" 994 edge.apply_style() 995 996 def _scene_clicked(self, ev): 997 # A click on empty space clears the selection. 998 if ev.button() != Qt.LeftButton or ev.double(): 999 return 1000 if not any(isinstance(i, NodeItem) for i in self.plot.scene().items(ev.scenePos())): 1001 self.select(None) 1002 1003 # --------------------------------------------------------------- view 1004 def fit(self): 1005 if self.nodes: 1006 rect = QRectF() 1007 for node in self.nodes.values(): 1008 rect = rect.united(node.scene_rect()) 1009 self.vb.setRange(rect=rect, padding=0.08) 1010 # Don't blow small graphs up beyond 130 %. 1011 px = self.vb.viewPixelSize()[0] 1012 if 0 < px < 1 / 1.3: 1013 factor = 1 / (1.3 * px) 1014 self.vb.scaleBy((factor, factor), center=rect.center()) 1015 1016 def export_image(self, filename: str) -> bool: 1017 """Save the whole diagram as PNG/JPG, or SVG if the name ends in .svg.""" 1018 from pyqtgraph import exporters 1019 1020 x_range, y_range = self.vb.viewRange() 1021 self.fit() 1022 try: 1023 if filename.lower().endswith(".svg"): 1024 # pyqtgraph's SVGExporter chokes on these custom items, so 1025 # render the scene straight into Qt's SVG generator instead. 1026 from qtpy.QtSvg import QSvgGenerator 1027 1028 source = self.vb.sceneBoundingRect() 1029 target = QRectF(0, 0, source.width(), source.height()) 1030 gen = QSvgGenerator() 1031 gen.setFileName(filename) 1032 gen.setSize(source.size().toSize()) 1033 gen.setViewBox(target) 1034 gen.setTitle(self.graph.root if self.graph else "dependencies") 1035 painter = QPainter(gen) 1036 painter.fillRect(target, QColor(self.theme["background"])) 1037 self.plot.scene().render(painter, target, source) 1038 painter.end() 1039 else: 1040 exporter = exporters.ImageExporter(self.plot.scene()) 1041 params = exporter.parameters() 1042 params["width"] = int(self.plot.width() * 2) 1043 params["antialias"] = True 1044 exporter.export(filename) 1045 return os.path.exists(filename) 1046 except Exception: # noqa: BLE001 - report failure to the caller 1047 return False 1048 finally: 1049 self.vb.setRange(xRange=x_range, yRange=y_range, padding=0)
Shows a vibefpmgui.manifest.DependencyGraph. Wheel zooms, drag pans,
nodes can be dragged, click selects (highlighting what the package
depends on and what uses it), double-click opens.
656 def __init__(self, parent=None): 657 super().__init__(parent) 658 self.layout_name = LAYERED_TB 659 self.edge_style = "curved" 660 self.theme_key = AUTO_THEME 661 self.theme = resolve_theme(self.theme_key) 662 self.graph: m.DependencyGraph | None = None 663 self.conflicts: dict[str, list[m.Declaration]] = {} 664 self.nodes: dict[str, NodeItem] = {} 665 self.edges: list[EdgeItem] = [] 666 self.selected: str | None = None 667 668 self.plot = pg.GraphicsLayoutWidget() 669 self.plot.ci.setContentsMargins(0, 0, 0, 0) 670 self.vb = self.plot.addViewBox() 671 self.vb.setAspectLocked(True) 672 self.vb.invertY(True) # y grows downward, like the screen 673 self.vb.setMenuEnabled(False) 674 self.plot.scene().sigMouseClicked.connect(self._scene_clicked) 675 676 # "Find package…": over the graph's top-left corner. 677 self.search = QLineEdit(self.plot) 678 self.search.setPlaceholderText("Find package…") 679 self.search.setClearButtonEnabled(True) 680 self.search.setFixedWidth(220) 681 # Readable on every graph theme (light, dark, blueprint, neon). 682 self.search.setStyleSheet("QLineEdit { background: rgba(128, 128, 128, 55); border: 1px solid " 683 "rgba(128, 128, 128, 140); border-radius: 7px; padding: 4px 8px; }" 684 "QLineEdit:focus { border-color: rgba(77, 148, 255, 220); }") 685 self.search.setToolTip("Type to highlight matching packages; Enter selects the next match, " 686 "Esc clears") 687 self.search_model = QStringListModel(self) 688 completer = QCompleter(self.search_model, self.search) 689 completer.setCaseSensitivity(Qt.CaseInsensitive) 690 completer.setFilterMode(Qt.MatchContains) 691 completer.activated.connect(lambda text: self._jump_to_match(text)) 692 self.search.setCompleter(completer) 693 self.search.textChanged.connect(self._highlight_matches) 694 self.search.returnPressed.connect(lambda: self._jump_to_match(None)) 695 self.search.installEventFilter(self) 696 self.search.move(10, 10) 697 698 self.message = QLabel() 699 self.message.setAlignment(Qt.AlignCenter) 700 self.message.setWordWrap(True) 701 self.stack = QStackedLayout(self) 702 self.stack.addWidget(self.message) 703 self.stack.addWidget(self.plot) 704 705 self._anim_timer = QTimer(self) 706 self._anim_timer.setInterval(16) 707 self._anim_timer.timeout.connect(self._animate_step) 708 self._anim_from: dict[str, QPointF] = {} 709 self._anim_to: dict[str, QPointF] = {} 710 self._anim_frame = 0 711 self._anim_frames = 24 712 713 self._apply_theme_colors() 714 self.clear()
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
731 def refresh_theme(self): 732 """Re-resolve the theme (e.g. after the system switches dark mode).""" 733 self.theme = resolve_theme(self.theme_key) 734 self._apply_theme_colors() 735 for node in self.nodes.values(): 736 node.update() 737 for edge in self.edges: 738 edge.apply_style()
Re-resolve the theme (e.g. after the system switches dark mode).
745 def package_colors(self) -> dict[str, str]: 746 """The color that identifies each package kind in the current theme.""" 747 return {k: (node_border(self.theme, k).name() if self.theme["border"] else self.theme["fill"][k]) 748 for k in KIND_ORDER}
The color that identifies each package kind in the current theme.
750 def refresh_fonts(self): 751 """Resize the package boxes after the text size changed, keeping each 752 box centred where it was, then tidy overlaps by re-running the layout 753 only if boxes now collide.""" 754 for node in self.nodes.values(): 755 center = node.center() 756 node.refresh_fonts() 757 node.set_center(center) 758 rects = [n.scene_rect() for n in self.nodes.values()] 759 if any(a.intersects(b) for i, a in enumerate(rects) for b in rects[i + 1:]): 760 self.relayout(animate=False) 761 else: 762 self._update_edges()
Resize the package boxes after the text size changed, keeping each box centred where it was, then tidy overlaps by re-running the layout only if boxes now collide.
764 def legend_html(self) -> str: 765 theme = self.theme 766 parts = [f'<span style="background:{theme["fill"][k]}; color:{theme["text"]}">' 767 f' <span style="color:{node_border(theme, k).name()}">●</span>' 768 f' {KIND_LABELS[k]} </span>' for k in KIND_ORDER 769 if k != m.DEV_ONLY or k in self._kinds_shown()] 770 text = (" ".join(parts) 771 + f" <span style='color:{theme['down']}'>━</span> depends on" 772 + f" <span style='color:{theme['up']}'>━</span> used by" 773 + " <i>dashed = dev</i>") 774 if any(node.dev for node in self.nodes.values()): 775 text += f" <span style='background:{DEV_COLOR}; color:white'> dev </span> only a dev-dependency" 776 if self.conflicts: 777 text += (f" <span style='color:{CONFLICT_COLOR}'><b>!</b> version conflict" 778 f"{'s' * (len(self.conflicts) != 1)}</span>") 779 return text
800 def set_graph(self, graph: m.DependencyGraph): 801 self._clear_items() 802 self.graph = graph 803 self.stack.setCurrentWidget(self.plot) 804 for name, pkg in graph.packages.items(): 805 node = NodeItem(self, pkg) 806 self.vb.addItem(node) 807 self.nodes[name] = node 808 seen = set() 809 for e in graph.edges: 810 if (e.parent, e.child) in seen: 811 continue 812 seen.add((e.parent, e.child)) 813 edge = EdgeItem(self, self.nodes[e.parent], self.nodes[e.child], e.dev) 814 edge.add_to(self.vb) 815 edge.apply_style() 816 self.edges.append(edge) 817 self._mark_dev() 818 self._mark_conflicts() 819 self.relayout(animate=False)
821 def update_packages(self, graph: m.DependencyGraph): 822 """Swap in refreshed package details without redoing the layout.""" 823 self.graph = graph 824 for name, node in self.nodes.items(): 825 if name in graph.packages: 826 node.set_package(graph.packages[name]) 827 self._mark_conflicts() 828 self._update_edges()
Swap in refreshed package details without redoing the layout.
845 def relayout(self, animate: bool = True): 846 """Place every package with the current layout (undoing any drags).""" 847 if not self.graph or not self.nodes: 848 return 849 sizes = {n: (node.rect.width(), node.rect.height()) for n, node in self.nodes.items()} 850 target = LAYOUT_FUNCTIONS[self.layout_name](self.graph, sizes) 851 if animate and self.isVisible(): 852 self._anim_from = {n: node.center() for n, node in self.nodes.items()} 853 self._anim_to = target 854 self._anim_frame = 0 855 self._anim_timer.start() 856 else: 857 self._stop_animation() 858 for name, point in target.items(): 859 self.nodes[name].set_center(point) 860 self._update_edges() 861 self.fit()
Place every package with the current layout (undoing any drags).
885 def select(self, name: str | None, center: bool = True): 886 """Select a package (None clears) and highlight its dependency chains.""" 887 if name is not None and name not in self.nodes: 888 return 889 self.selected = name 890 down, up = self._reachable(name, forward=True), self._reachable(name, forward=False) 891 for n, node in self.nodes.items(): 892 node.selected = n == name 893 node.dimmed = name is not None and n != name and n not in down and n not in up 894 node.update() 895 for edge in self.edges: 896 p, c = edge.source.package.name, edge.target.package.name 897 if name is None: 898 edge.state = "hover" if edge.source.hovered or edge.target.hovered else "normal" 899 elif p in down | {name} and c in down: 900 edge.state = "down" 901 elif c in up | {name} and p in up: 902 edge.state = "up" 903 else: 904 edge.state = "dim" 905 edge.apply_style() 906 if name is not None: 907 self.package_selected.emit(name) 908 if center: 909 self.vb.translateBy(self.nodes[name].center() - self.vb.viewRect().center())
Select a package (None clears) and highlight its dependency chains.
911 def show_chains(self, name: str, chains: list[list[str]]): 912 """Highlight the dependency chains from the project down to ``name`` 913 (Why Is This Here?); everything else is dimmed. A click on the 914 background clears it, like a selection.""" 915 on_chain = {n for chain in chains for n in chain} 916 links = {(a, b) for chain in chains for a, b in pairwise(chain)} 917 self.selected = name 918 for n, node in self.nodes.items(): 919 node.selected = n == name 920 node.dimmed = n not in on_chain 921 node.update() 922 for edge in self.edges: 923 pair = (edge.source.package.name, edge.target.package.name) 924 edge.state = "down" if pair in links else "dim" 925 edge.apply_style()
Highlight the dependency chains from the project down to name
(Why Is This Here?); everything else is dimmed. A click on the
background clears it, like a selection.
964 def eventFilter(self, obj, event): 965 if obj is self.search and event.type() == QEvent.KeyPress and event.key() == Qt.Key_Escape: 966 self.search.clear() 967 self.plot.setFocus() 968 return True 969 return super().eventFilter(obj, event)
eventFilter(self, watched: PySide6.QtCore.QObject, event: PySide6.QtCore.QEvent, /) -> bool
1004 def fit(self): 1005 if self.nodes: 1006 rect = QRectF() 1007 for node in self.nodes.values(): 1008 rect = rect.united(node.scene_rect()) 1009 self.vb.setRange(rect=rect, padding=0.08) 1010 # Don't blow small graphs up beyond 130 %. 1011 px = self.vb.viewPixelSize()[0] 1012 if 0 < px < 1 / 1.3: 1013 factor = 1 / (1.3 * px) 1014 self.vb.scaleBy((factor, factor), center=rect.center())
1016 def export_image(self, filename: str) -> bool: 1017 """Save the whole diagram as PNG/JPG, or SVG if the name ends in .svg.""" 1018 from pyqtgraph import exporters 1019 1020 x_range, y_range = self.vb.viewRange() 1021 self.fit() 1022 try: 1023 if filename.lower().endswith(".svg"): 1024 # pyqtgraph's SVGExporter chokes on these custom items, so 1025 # render the scene straight into Qt's SVG generator instead. 1026 from qtpy.QtSvg import QSvgGenerator 1027 1028 source = self.vb.sceneBoundingRect() 1029 target = QRectF(0, 0, source.width(), source.height()) 1030 gen = QSvgGenerator() 1031 gen.setFileName(filename) 1032 gen.setSize(source.size().toSize()) 1033 gen.setViewBox(target) 1034 gen.setTitle(self.graph.root if self.graph else "dependencies") 1035 painter = QPainter(gen) 1036 painter.fillRect(target, QColor(self.theme["background"])) 1037 self.plot.scene().render(painter, target, source) 1038 painter.end() 1039 else: 1040 exporter = exporters.ImageExporter(self.plot.scene()) 1041 params = exporter.parameters() 1042 params["width"] = int(self.plot.width() * 2) 1043 params["antialias"] = True 1044 exporter.export(filename) 1045 return os.path.exists(filename) 1046 except Exception: # noqa: BLE001 - report failure to the caller 1047 return False 1048 finally: 1049 self.vb.setRange(xRange=x_range, yRange=y_range, padding=0)
Save the whole diagram as PNG/JPG, or SVG if the name ends in .svg.