Coverage for vibefpmgui/graph_view.py: 89%

743 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

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: 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true

209 tip.append(package.description) 

210 if package.error: 

211 tip.append(f"<i>{package.error}</i>") 

212 if self.dev and package.kind != m.DEV_ONLY: 

213 tip.append("<i>Only a dev-dependency: fpm fetches it to build the project's tests and " 

214 "examples.</i>") 

215 if self.conflict: 

216 tip.append("<b style='color:#e8590c'>⚠ Version conflict</b> — asked for differently:") 

217 used = m.spec_key(self.conflict[0]) 

218 tip += [f"&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: 413 ↛ 414line 413 didn't jump to line 414 because the condition on line 413 was never true

414 return c 

415 hw, hh = rect.width() / 2 + 1, rect.height() / 2 + 1 

416 t = min(hw / abs(dx) if dx else math.inf, hh / abs(dy) if dy else math.inf) 

417 return QPointF(c.x() + dx * t, c.y() + dy * t) 

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]: 458 ↛ 457line 458 didn't jump to line 457 because the condition on line 458 was always true

459 children[e.parent].append(e.child) 

460 parents[e.child].append(e.parent) 

461 return children, parents 

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)): 470 ↛ 479line 470 didn't jump to line 479 because the loop on line 470 didn't complete

471 changed = False 

472 for e in graph.edges: 

473 new_depth = depth[e.parent] + 1 if e.parent in depth else None 

474 if new_depth is not None and depth.get(e.child, -1) < new_depth < len(graph.packages): 

475 depth[e.child] = new_depth 

476 changed = True 

477 if not changed: 

478 break 

479 for name in graph.packages: 

480 depth.setdefault(name, 1) 

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: 542 ↛ 543line 542 didn't jump to line 543 because the condition on line 542 was never true

543 depth[n] = 1 

544 tree[graph.root].append(n) 

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: 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true

592 return start 

593 pos = np.array([[start[k].x(), start[k].y()] for k in names], dtype=float) 

594 index = {k: i for i, k in enumerate(names)} 

595 edges = np.array([(index[e.parent], index[e.child]) for e in graph.edges], dtype=int).reshape(-1, 2) 

596 k = 190.0 

597 temperature = 120.0 

598 for _ in range(400): 

599 delta = pos[:, None, :] - pos[None, :, :] 

600 dist2 = (delta ** 2).sum(-1) + np.eye(n) 

601 disp = (delta * (k * k / dist2)[:, :, None]).sum(1) # repulsion 

602 if len(edges): # attraction along edges 602 ↛ 607line 602 didn't jump to line 607 because the condition on line 602 was always true

603 d = pos[edges[:, 1]] - pos[edges[:, 0]] 

604 pull = d * (np.sqrt((d ** 2).sum(-1))[:, None] / k) 

605 np.add.at(disp, edges[:, 0], pull) 

606 np.add.at(disp, edges[:, 1], -pull) 

607 disp -= (pos - pos.mean(0)) * 0.01 # gentle gravity 

608 length = np.sqrt((disp ** 2).sum(-1))[:, None] + 1e-9 

609 pos += disp / length * np.minimum(length, temperature) 

610 temperature = max(1.0, temperature * 0.985) 

611 result = {name: QPointF(*pos[i]) for i, name in enumerate(names)} 

612 return _remove_overlaps(result, sizes) 

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): 619 ↛ 633line 619 didn't jump to line 633 because the loop on line 619 didn't complete

620 moved = False 

621 for i, a in enumerate(names): 

622 for b in names[i + 1:]: 

623 ox = (sizes[a][0] + sizes[b][0]) / 2 + margin - abs(p[a][0] - p[b][0]) 

624 oy = (sizes[a][1] + sizes[b][1]) / 2 + margin - abs(p[a][1] - p[b][1]) 

625 if ox > 0 and oy > 0: 625 ↛ 626line 625 didn't jump to line 626 because the condition on line 625 was never true

626 moved = True 

627 axis, amount = (0, ox) if ox < oy else (1, oy) 

628 s = 1 if p[a][axis] >= p[b][axis] else -1 

629 p[a][axis] += s * amount / 2 

630 p[b][axis] -= s * amount / 2 

631 if not moved: 631 ↛ 619line 631 didn't jump to line 619 because the condition on line 631 was always true

632 break 

633 return {k: QPointF(*v) for k, v in p.items()} 

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: 717 ↛ exitline 717 didn't return from function 'set_layout' because the condition on line 717 was always true

718 self.layout_name = name 

719 self.relayout(animate) 

720 

721 def set_edge_style(self, style: str): 

722 if style in EDGE_STYLES: 722 ↛ exitline 722 didn't return from function 'set_edge_style' because the condition on line 722 was always true

723 self.edge_style = style 

724 self._update_edges() 

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:]): 758 ↛ 759line 758 didn't jump to line 759 because the condition on line 758 was never true

759 self.relayout(animate=False) 

760 else: 

761 self._update_edges() 

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: 809 ↛ 810line 809 didn't jump to line 810 because the condition on line 809 was never true

810 continue 

811 seen.add((e.parent, e.child)) 

812 edge = EdgeItem(self, self.nodes[e.parent], self.nodes[e.child], e.dev) 

813 edge.add_to(self.vb) 

814 edge.apply_style() 

815 self.edges.append(edge) 

816 self._mark_dev() 

817 self._mark_conflicts() 

818 self.relayout(animate=False) 

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: 824 ↛ 823line 824 didn't jump to line 823 because the condition on line 824 was always true

825 node.set_package(graph.packages[name]) 

826 self._mark_conflicts() 

827 self._update_edges() 

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: 868 ↛ 866line 868 didn't jump to line 866 because the condition on line 868 was always true

869 node.set_center(a + (b - a) * ease) 

870 self._update_edges() 

871 if t >= 1.0: 

872 self._anim_timer.stop() 

873 self.fit() 

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: 880 ↛ 879line 880 didn't jump to line 879 because the condition on line 880 was always true

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: 886 ↛ 887line 886 didn't jump to line 887 because the condition on line 886 was never true

887 return 

888 self.selected = name 

889 down, up = self._reachable(name, forward=True), self._reachable(name, forward=False) 

890 for n, node in self.nodes.items(): 

891 node.selected = n == name 

892 node.dimmed = name is not None and n != name and n not in down and n not in up 

893 node.update() 

894 for edge in self.edges: 

895 p, c = edge.source.package.name, edge.target.package.name 

896 if name is None: 

897 edge.state = "hover" if edge.source.hovered or edge.target.hovered else "normal" 

898 elif p in down | {name} and c in down: 

899 edge.state = "down" 

900 elif c in up | {name} and p in up: 

901 edge.state = "up" 

902 else: 

903 edge.state = "dim" 

904 edge.apply_style() 

905 if name is not None: 

906 self.package_selected.emit(name) 

907 if center: 

908 self.vb.translateBy(self.nodes[name].center() - self.vb.viewRect().center()) 

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: 934 ↛ 935line 934 didn't jump to line 935 because the condition on line 934 was never true

935 return [] 

936 names = sorted(self.nodes, key=str.lower) 

937 return ([n for n in names if n.lower().startswith(text)] 

938 + [n for n in names if text in n.lower() and not n.lower().startswith(text)]) 

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: 958 ↛ 959line 958 didn't jump to line 959 because the condition on line 958 was never true

959 return 

960 index = (matches.index(self.selected) + 1) % len(matches) if self.selected in matches else 0 

961 self.select(matches[index], center=True) 

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: 980 ↛ 979line 980 didn't jump to line 979 because the condition on line 980 was always true

981 seen.add(n) 

982 stack.append(n) 

983 seen.discard(name) 

984 return seen 

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: 1004 ↛ exitline 1004 didn't return from function 'fit' because the condition on line 1004 was always true

1005 rect = QRectF() 

1006 for node in self.nodes.values(): 

1007 rect = rect.united(node.scene_rect()) 

1008 self.vb.setRange(rect=rect, padding=0.08) 

1009 # Don't blow small graphs up beyond 130 %. 

1010 px = self.vb.viewPixelSize()[0] 

1011 if 0 < px < 1 / 1.3: 

1012 factor = 1 / (1.3 * px) 

1013 self.vb.scaleBy((factor, factor), center=rect.center()) 

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)