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"&nbsp;&nbsp;{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'&nbsp;<span style="color:{node_border(theme, k).name()}">●</span>'
 767                 f'&nbsp;{KIND_LABELS[k]}&nbsp;</span>' for k in KIND_ORDER
 768                 if k != m.DEV_ONLY or k in self._kinds_shown()]
 769        text = (" ".join(parts)
 770                + f" &nbsp;<span style='color:{theme['down']}'>━</span> depends on"
 771                + f" &nbsp;<span style='color:{theme['up']}'>━</span> used by"
 772                + " &nbsp;<i>dashed = dev</i>")
 773        if any(node.dev for node in self.nodes.values()):
 774            text += f" &nbsp;<span style='background:{DEV_COLOR}; color:white'>&nbsp;dev&nbsp;</span> only a dev-dependency"
 775        if self.conflicts:
 776            text += (f" &nbsp;<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)
KIND_LABELS = {'root': 'Project', 'git': 'Git', 'path': 'Local path', 'registry': 'Registry', 'metapackage': 'Metapackage', 'missing': 'Not found', 'dev-only': 'Not downloaded'}
KIND_ORDER = ('root', 'git', 'path', 'registry', 'metapackage', 'missing', 'dev-only')
DEV_COLOR = '#7048e8'
LAYOUTS = {'layered-tb': 'Layered (top → bottom)', 'layered-lr': 'Layered (left → right)', 'radial': 'Radial tree', 'circular': 'Circular', 'force': 'Force-directed'}
EDGE_STYLES = {'curved': 'Curved', 'straight': 'Straight'}
THEMES = {'light': {'name': 'Light', 'background': '#f8f9fa', 'text': '#1b1b1b', 'subtext': '#495057', 'edge': '#8a8f98', 'down': '#e8590c', 'up': '#1c7ed6', 'shadow': 40, 'glow': False, 'fill': {'root': '#f6c85f', 'git': '#9dd0f5', 'path': '#a8e6a1', 'registry': '#d7b8f3', 'metapackage': '#f3c6a5', 'missing': '#e9ecef', 'dev-only': '#f1f3f5'}, 'border': None}, 'dark': {'name': 'Dark', 'background': '#1e1f22', 'text': '#f1f3f5', 'subtext': '#ced4da', 'edge': '#6c717a', 'down': '#ff922b', 'up': '#4dabf7', 'shadow': 90, 'glow': False, 'fill': {'root': '#b8860b', 'git': '#2f6aa3', 'path': '#2f7d43', 'registry': '#6a45a0', 'metapackage': '#a35a2a', 'missing': '#3d4046', 'dev-only': '#2b2d31'}, 'border': None}, 'blueprint': {'name': 'Blueprint', 'background': '#0b3d6b', 'text': '#ffffff', 'subtext': '#cfe3f7', 'edge': '#9cc3e6', 'down': '#ffd43b', 'up': '#63e6be', 'shadow': 0, 'glow': False, 'fill': {'root': '#0e4f8a', 'git': '#0e4f8a', 'path': '#0e4f8a', 'registry': '#0e4f8a', 'metapackage': '#0e4f8a', 'missing': '#0e4f8a', 'dev-only': '#0e4f8a'}, 'border': {'root': '#ffffff', 'git': '#a5d8ff', 'path': '#b2f2bb', 'registry': '#e5dbff', 'metapackage': '#ffd8a8', 'missing': '#748ba3', 'dev-only': '#5c7a96'}}, 'neon': {'name': 'Neon', 'background': '#0d0221', 'text': '#ffffff', 'subtext': '#c9c2e6', 'edge': '#4a3d7a', 'down': '#ff2a6d', 'up': '#05d9e8', 'shadow': 0, 'glow': True, 'fill': {'root': '#160a3a', 'git': '#160a3a', 'path': '#160a3a', 'registry': '#160a3a', 'metapackage': '#160a3a', 'missing': '#160a3a', 'dev-only': '#160a3a'}, 'border': {'root': '#f9f871', 'git': '#00f5ff', 'path': '#39ff14', 'registry': '#ff00e6', 'metapackage': '#ff9f1c', 'missing': '#6c6c8a', 'dev-only': '#4f4f6e'}}}
AUTO_THEME = 'auto'
THEME_CHOICES = {'auto': 'Auto (follow system)', 'light': 'Light', 'dark': 'Dark', 'blueprint': 'Blueprint', 'neon': 'Neon'}
H_GAP = 40
V_GAP = 90
PAD_X = 16
PAD_Y = 9
def resolve_theme(key: str) -> dict:
127def resolve_theme(key: str) -> dict:
128    if key == AUTO_THEME:
129        dark = QApplication.palette().color(QPalette.Base).lightness() < 128
130        key = "dark" if dark else "light"
131    return THEMES.get(key, THEMES["light"])
CONFLICT_COLOR = '#f08c00'
def node_border(theme: dict, kind: str) -> PySide6.QtGui.QColor:
137def node_border(theme: dict, kind: str) -> QColor:
138    if theme["border"]:
139        return QColor(theme["border"][kind])
140    return QColor(theme["fill"][kind]).darker(160)
class NodeItem(pyqtgraph.graphicsItems.GraphicsObject.GraphicsObject):
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"&nbsp;&nbsp;{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.

NodeItem( view: DependencyGraphView, package: vibefpmgui.manifest.Package)
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.

view
hovered
selected
dimmed
dev
def refresh_fonts(self):
195    def refresh_fonts(self):
196        self._make_fonts()
197        self.set_package(self.package)  # recomputes the box size
def set_package(self, package: vibefpmgui.manifest.Package):
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"&nbsp;&nbsp;{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()
def set_dev(self, dev: bool):
225    def set_dev(self, dev: bool):
226        self.dev = dev
227        self.set_package(self.package)  # (the tooltip)
def set_conflict(self, declarations: list[vibefpmgui.manifest.Declaration] | None):
229    def set_conflict(self, declarations: list[m.Declaration] | None):
230        self.conflict = list(declarations or [])
231        self.set_package(self.package)  # (the tooltip)
def center(self) -> PySide6.QtCore.QPointF:
234    def center(self) -> QPointF:
235        return self.pos() + self.rect.center()
def scene_rect(self) -> PySide6.QtCore.QRectF:
237    def scene_rect(self) -> QRectF:
238        return self.rect.translated(self.pos())
def set_center(self, point: PySide6.QtCore.QPointF):
240    def set_center(self, point: QPointF):
241        self.setPos(point - self.rect.center())
def boundingRect(self):
243    def boundingRect(self):
244        return self.rect.adjusted(-12, -12, 12, 12)

boundingRect(self, /) -> PySide6.QtCore.QRectF

def paint(self, painter: PySide6.QtGui.QPainter, option, widget=None):
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

def hoverEvent(self, ev):
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)
def mouseClickEvent(self, ev):
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)
def mouseDragEvent(self, ev):
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)
staticMetaObject = PySide6.QtCore.QMetaObject("NodeItem" inherits "GraphicsObject": )
class EdgeItem:
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.

EdgeItem( view: DependencyGraphView, source: NodeItem, target: NodeItem, dev: bool)
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
curve
arrow
state
def add_to(self, viewbox):
378    def add_to(self, viewbox):
379        viewbox.addItem(self.curve)
380        viewbox.addItem(self.arrow)
def remove_from(self, viewbox):
382    def remove_from(self, viewbox):
383        viewbox.removeItem(self.curve)
384        viewbox.removeItem(self.arrow)
def apply_style(self):
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)
def update_geometry(self):
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])
def edge_geometry( s: PySide6.QtCore.QRectF, t: PySide6.QtCore.QRectF, layout: str, curved: bool):
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.

def compute_layers(graph: vibefpmgui.manifest.DependencyGraph) -> list[list[str]]:
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.

def layout_layered(graph, sizes, horizontal=False) -> dict[str, PySide6.QtCore.QPointF]:
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
def layout_radial(graph, sizes) -> dict[str, PySide6.QtCore.QPointF]:
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.

def layout_circular(graph, sizes) -> dict[str, PySide6.QtCore.QPointF]:
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)}
def layout_force(graph, sizes) -> dict[str, PySide6.QtCore.QPointF]:
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).

LAYOUT_FUNCTIONS = {'layered-tb': <function <lambda>>, 'layered-lr': <function <lambda>>, 'radial': <function layout_radial>, 'circular': <function layout_circular>, 'force': <function layout_force>}
class DependencyGraphView(PySide6.QtWidgets.QWidget):
 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'&nbsp;<span style="color:{node_border(theme, k).name()}">●</span>'
 768                 f'&nbsp;{KIND_LABELS[k]}&nbsp;</span>' for k in KIND_ORDER
 769                 if k != m.DEV_ONLY or k in self._kinds_shown()]
 770        text = (" ".join(parts)
 771                + f" &nbsp;<span style='color:{theme['down']}'>━</span> depends on"
 772                + f" &nbsp;<span style='color:{theme['up']}'>━</span> used by"
 773                + " &nbsp;<i>dashed = dev</i>")
 774        if any(node.dev for node in self.nodes.values()):
 775            text += f" &nbsp;<span style='background:{DEV_COLOR}; color:white'>&nbsp;dev&nbsp;</span> only a dev-dependency"
 776        if self.conflicts:
 777            text += (f" &nbsp;<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.

DependencyGraphView(parent=None)
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.

def package_selected(unknown):

The type of the None singleton.

def package_activated(unknown):

The type of the None singleton.

def package_context_menu(unknown):

The type of the None singleton.

layout_name
edge_style
theme_key
theme
conflicts: dict[str, list[vibefpmgui.manifest.Declaration]]
nodes: dict[str, NodeItem]
edges: list[EdgeItem]
selected: str | None
plot
vb
search
search_model
message
stack
def set_layout(self, name: str, animate: bool = True):
717    def set_layout(self, name: str, animate: bool = True):
718        if name in LAYOUTS:
719            self.layout_name = name
720            self.relayout(animate)
def set_edge_style(self, style: str):
722    def set_edge_style(self, style: str):
723        if style in EDGE_STYLES:
724            self.edge_style = style
725            self._update_edges()
def set_theme(self, key: str):
727    def set_theme(self, key: str):
728        self.theme_key = key if key in THEME_CHOICES else AUTO_THEME
729        self.refresh_theme()
def refresh_theme(self):
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).

def package_colors(self) -> dict[str, str]:
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.

def refresh_fonts(self):
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.

def legend_html(self) -> str:
764    def legend_html(self) -> str:
765        theme = self.theme
766        parts = [f'<span style="background:{theme["fill"][k]}; color:{theme["text"]}">'
767                 f'&nbsp;<span style="color:{node_border(theme, k).name()}">●</span>'
768                 f'&nbsp;{KIND_LABELS[k]}&nbsp;</span>' for k in KIND_ORDER
769                 if k != m.DEV_ONLY or k in self._kinds_shown()]
770        text = (" ".join(parts)
771                + f" &nbsp;<span style='color:{theme['down']}'>━</span> depends on"
772                + f" &nbsp;<span style='color:{theme['up']}'>━</span> used by"
773                + " &nbsp;<i>dashed = dev</i>")
774        if any(node.dev for node in self.nodes.values()):
775            text += f" &nbsp;<span style='background:{DEV_COLOR}; color:white'>&nbsp;dev&nbsp;</span> only a dev-dependency"
776        if self.conflicts:
777            text += (f" &nbsp;<span style='color:{CONFLICT_COLOR}'><b>!</b> version conflict"
778                     f"{'s' * (len(self.conflicts) != 1)}</span>")
779        return text
def clear(self, message='Open an fpm.toml file to see its dependency 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)
def set_graph(self, graph: vibefpmgui.manifest.DependencyGraph):
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)
def update_packages(self, graph: vibefpmgui.manifest.DependencyGraph):
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.

def relayout(self, animate: bool = True):
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).

def select(self, name: str | None, center: bool = True):
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.

def show_chains(self, name: str, chains: list[list[str]]):
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.

def eventFilter(self, obj, event):
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

def fit(self):
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())
def export_image(self, filename: str) -> bool:
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.

staticMetaObject = PySide6.QtCore.QMetaObject("DependencyGraphView" inherits "QWidget": Methods: #33 type=Signal, signature=package_selected(QString), parameters=QString #34 type=Signal, signature=package_activated(QString), parameters=QString #35 type=Signal, signature=package_context_menu(QString,QPoint), parameters=QString, QPoint )