Coverage for vibefpmgui/cfg_view.py: 89%

325 statements  

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

1"""Drawing a procedure's control-flow graph (`vibefpmgui.control_flow`) with 

2pyqtgraph: a box per block (its first lines of code), a hexagon per decision, 

3a rounded box per loop, pills for start, end and stop; arrows labelled with 

4the way taken ("true", "case (1)", "loop"…), a loop's way back curving round 

5its right side; a node whose lines are in an included file has a dashed 

6border. Wheel zooms, drag pans, clicking a node opens its lines, 

7double-clicking the background fits the graph to the view; right-click for 

8a menu (open or copy a node's code, center on it, fit, zoom, actual size, 

9edge labels on or off, copy or save the graph as an image). 

10""" 

11 

12from __future__ import annotations 

13 

14import math 

15from itertools import pairwise 

16from pathlib import Path 

17 

18import numpy as np 

19import pyqtgraph as pg 

20from qtpy.QtCore import QPointF, QRectF, Qt, Signal 

21from qtpy.QtGui import ( 

22 QBrush, 

23 QColor, 

24 QFont, 

25 QFontMetricsF, 

26 QImage, 

27 QPainter, 

28 QPainterPath, 

29 QPen, 

30 QPolygonF, 

31) 

32from qtpy.QtWidgets import ( 

33 QApplication, 

34 QFileDialog, 

35 QLabel, 

36 QMenu, 

37 QMessageBox, 

38 QStackedLayout, 

39 QWidget, 

40) 

41 

42from . import control_flow as cf 

43from .graph_view import LAYERED_TB, _bezier, edge_geometry 

44from .source_text import read_source 

45 

46PADDING = 8 

47MAX_FIT_SCALE = 1.3 # (fitting doesn't blow a small graph up beyond 130 %) 

48IMAGE_SCALE = 2 # (an image's pixels per unit of the graph) 

49COLORS = { 

50 "light": {"background": "#ffffff", "text": "#1f2328", "edge": "#8b949e", "label": "#57606a", 

51 cf.BLOCK: ("#f6f8fa", "#d0d7de"), cf.DECISION: ("#fff4e5", "#e8a317"), cf.LOOP: ("#e7f5ff", "#4c6ef5"), 

52 cf.START: ("#ebfbee", "#2f9e44"), cf.END: ("#ebfbee", "#2f9e44"), cf.STOP: ("#fff5f5", "#e03131")}, 

53 "dark": {"background": "#1e1f22", "text": "#e6e8eb", "edge": "#7d8590", "label": "#9da7b3", 

54 cf.BLOCK: ("#2a2d31", "#454a51"), cf.DECISION: ("#3a2f1a", "#e8a317"), cf.LOOP: ("#1d2a45", "#748ffc"), 

55 cf.START: ("#1f3326", "#40c057"), cf.END: ("#1f3326", "#40c057"), cf.STOP: ("#3d1f22", "#fa5252")}, 

56} 

57 

58 

59class CfgNode(pg.GraphicsObject): 

60 """One node: its shape and text, centered on its place.""" 

61 

62 def __init__(self, view: CfgView, node: cf.Node): 

63 super().__init__() 

64 self.view, self.node = view, node 

65 self.hovered = False 

66 metrics = QFontMetricsF(view.code_font) 

67 width = max((metrics.horizontalAdvance(line) for line in node.text), default=20) + 2 * PADDING 

68 if node.kind == cf.DECISION: 

69 width += 24 # (the hexagon's points) 

70 height = metrics.height() * max(len(node.text), 1) + 2 * PADDING 

71 self.size = (max(width, 56), height) 

72 self.rect = QRectF(-self.size[0] / 2, -self.size[1] / 2, *self.size) 

73 self.setZValue(10) 

74 self.setAcceptHoverEvents(True) 

75 self.included = node.path is not None and view.graph is not None and node.path != view.graph.path 

76 if node.first: 

77 self.setCursor(Qt.PointingHandCursor) 

78 lines = f"line {node.first}" if node.first == node.last else f"lines {node.first}–{node.last}" 

79 where = f"{node.path.name} (included), " if self.included else "" 

80 self.setToolTip(f"{where}{lines}: click to open") 

81 

82 def boundingRect(self): 

83 return self.rect.adjusted(-3, -3, 3, 3) 

84 

85 def scene_rect(self) -> QRectF: 

86 return self.rect.translated(self.pos()) 

87 

88 def shape_path(self) -> QPainterPath: 

89 r = self.rect 

90 path = QPainterPath() 

91 kind = self.node.kind 

92 if kind == cf.DECISION: 

93 d = min(12, r.height() / 2) 

94 path.addPolygon(QPolygonF([QPointF(r.left() + d, r.top()), QPointF(r.right() - d, r.top()), 

95 QPointF(r.right(), r.center().y()), QPointF(r.right() - d, r.bottom()), 

96 QPointF(r.left() + d, r.bottom()), QPointF(r.left(), r.center().y())])) 

97 path.closeSubpath() 

98 elif kind in (cf.START, cf.END, cf.STOP): 

99 path.addRoundedRect(r, r.height() / 2, r.height() / 2) 

100 elif kind == cf.LOOP: 

101 path.addRoundedRect(r, 9, 9) 

102 else: 

103 path.addRoundedRect(r, 3, 3) 

104 return path 

105 

106 def paint(self, painter: QPainter, option, widget=None): 

107 colors = self.view.colors 

108 fill, border = colors[self.node.kind] 

109 painter.setRenderHint(QPainter.Antialiasing) 

110 pen = QPen(QColor(border), 2.2 if self.hovered else 1.3) 

111 if self.included: 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true

112 pen.setStyle(Qt.DashLine) 

113 painter.setPen(pen) 

114 painter.setBrush(QBrush(QColor(fill))) 

115 painter.drawPath(self.shape_path()) 

116 painter.setPen(QColor(colors["text"])) 

117 painter.setFont(self.view.code_font) 

118 metrics = QFontMetricsF(self.view.code_font) 

119 y = self.rect.top() + PADDING 

120 centered = self.node.kind in (cf.START, cf.END, cf.STOP, cf.DECISION) 

121 for line in self.node.text: 

122 box = QRectF(self.rect.left() + PADDING, y, self.rect.width() - 2 * PADDING, metrics.height()) 

123 painter.drawText(box, (Qt.AlignHCenter if centered else Qt.AlignLeft) | Qt.AlignVCenter, line) 

124 y += metrics.height() 

125 

126 def hoverEnterEvent(self, event): 

127 self.hovered = True 

128 self.update() 

129 

130 def hoverLeaveEvent(self, event): 

131 self.hovered = False 

132 self.update() 

133 

134 def mouseClickEvent(self, event): 

135 if event.button() == Qt.LeftButton and self.node.first: 

136 event.accept() 

137 self.view.open_node(self.node) 

138 

139 

140class EdgeLabel(pg.GraphicsObject): 

141 """An edge's label, in the graph's units (so it zooms and exports with 

142 the graph); ``right``: its right edge at its place, not its left.""" 

143 

144 def __init__(self, text: str, font: QFont, color: str, right: bool): 

145 super().__init__() 

146 self.text, self.font, self.color = text, font, color 

147 metrics = QFontMetricsF(font) 

148 width = metrics.horizontalAdvance(text) 

149 self.rect = QRectF(-width if right else 0, 0, width, metrics.height()) 

150 self.setZValue(3) 

151 

152 def boundingRect(self): 

153 return self.rect 

154 

155 def paint(self, painter: QPainter, option, widget=None): 

156 painter.setPen(QColor(self.color)) 

157 painter.setFont(self.font) 

158 painter.drawText(self.rect, Qt.AlignLeft | Qt.AlignVCenter, self.text) 

159 

160 

161class CfgView(QWidget): 

162 """A control-flow graph, or a message instead of one.""" 

163 

164 line_clicked = Signal(str, int) # the clicked node's file and first line (1-based) 

165 

166 def __init__(self, parent=None): 

167 super().__init__(parent) 

168 self.graph: cf.Graph | None = None 

169 self.sources: dict[Path, list[str]] = {} # (files' lines, for Copy Code) 

170 self.show_labels = True 

171 self.labels: list[EdgeLabel] = [] 

172 self.menu: QMenu | None = None 

173 self.code_font = QFont() 

174 self.colors = COLORS["light"] 

175 self.nodes: dict[int, CfgNode] = {} 

176 self.plot = pg.GraphicsLayoutWidget() 

177 self.plot.ci.setContentsMargins(0, 0, 0, 0) 

178 self.vb = self.plot.addViewBox() 

179 self.vb.setAspectLocked(True) 

180 self.vb.invertY(True) 

181 self.vb.setMenuEnabled(False) 

182 self.plot.setBackground(self.colors["background"]) 

183 self.plot.scene().sigMouseClicked.connect(self._scene_clicked) 

184 self.message = QLabel() 

185 self.message.setAlignment(Qt.AlignCenter) 

186 self.message.setWordWrap(True) 

187 self.stack = QStackedLayout(self) 

188 self.stack.addWidget(self.message) 

189 self.stack.addWidget(self.plot) 

190 self.show_message("Select a procedure to see its control flow.") 

191 

192 def set_code_font(self, font: QFont): 

193 self.code_font = QFont(font) 

194 self.code_font.setPointSizeF(max(font.pointSizeF() - 1, 7)) 

195 if self.graph is not None: 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true

196 self.show_graph(self.graph) 

197 

198 def set_dark(self, dark: bool): 

199 self.colors = COLORS["dark" if dark else "light"] 

200 self.plot.setBackground(self.colors["background"]) 

201 if self.graph is not None: 

202 self.show_graph(self.graph, fit=False) 

203 

204 def show_message(self, text: str): 

205 self.graph = None 

206 self.vb.clear() 

207 self.nodes = {} 

208 self.message.setText(text) 

209 self.stack.setCurrentWidget(self.message) 

210 

211 def show_graph(self, graph: cf.Graph, source: str | None = None, fit: bool = True): 

212 """Draw ``graph`` (``source``: its file's text, for Copy Code; kept if 

213 not given); ``fit``: fit it to the view, else keep the view's range.""" 

214 if source is not None or self.graph is None or graph.path != self.graph.path: 

215 self.sources = {graph.path: source.splitlines()} if source is not None and graph.path else {} 

216 view_range = self.vb.viewRange() 

217 self.graph = graph 

218 self.vb.clear() 

219 self.labels = [] 

220 self.nodes = {n.id: CfgNode(self, n) for n in graph.nodes} 

221 places = cf.layout(graph, {i: item.size for i, item in self.nodes.items()}) 

222 for i, item in self.nodes.items(): 

223 x, y = places.positions[i] 

224 item.setPos(x, y) 

225 self.vb.addItem(item) 

226 right_edge = max((item.scene_rect().right() for item in self.nodes.values()), default=0) 

227 backs = 0 

228 for index, edge in enumerate(graph.edges): 

229 source, target = self.nodes[edge.source], self.nodes[edge.target] 

230 if edge.back: 

231 backs += 1 

232 points, direction = _back_edge(source.scene_rect(), target.scene_rect(), right_edge + 18 * backs, 

233 8 + 4 * (backs % 3)) 

234 elif index in places.routes: 

235 points, direction = _routed(source.scene_rect(), target.scene_rect(), places.routes[index]) 

236 else: 

237 points, direction = edge_geometry(source.scene_rect(), target.scene_rect(), LAYERED_TB, True) 

238 self._add_edge(points, direction, edge.label) 

239 self.stack.setCurrentWidget(self.plot) 

240 if fit: 

241 self.fit() 

242 else: 

243 self.vb.setRange(xRange=view_range[0], yRange=view_range[1], padding=0) 

244 

245 # ------------------------------------------------------------ the view 

246 def bounds(self) -> QRectF: 

247 """The graph's extent, in its own units.""" 

248 rect = QRectF() 

249 for item in self.nodes.values(): 

250 rect = rect.united(item.mapRectToView(item.boundingRect())) 

251 for item in self.vb.addedItems: 

252 if isinstance(item, pg.PlotCurveItem) or (isinstance(item, EdgeLabel) and item.isVisible()): 

253 rect = rect.united(item.mapRectToView(item.boundingRect())) 

254 return rect 

255 

256 def fit(self): 

257 """The whole graph in view (a small one at most 130 %).""" 

258 if not self.nodes: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true

259 return 

260 rect = self.bounds() 

261 self.vb.setRange(rect=rect, padding=0.04) 

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

263 if 0 < px < 1 / MAX_FIT_SCALE: 

264 factor = 1 / (MAX_FIT_SCALE * px) 

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

266 

267 def zoom(self, factor: float, center: QPointF | None = None): 

268 """Zoom in (``factor`` > 1) or out about ``center`` (the view's).""" 

269 self.vb.scaleBy((1 / factor, 1 / factor), center=center if center is not None else self.vb.viewRect().center()) 

270 

271 def actual_size(self, center: QPointF | None = None): 

272 """100 %: a unit of the graph a pixel (its text the code's size).""" 

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

274 if px > 0: 274 ↛ exitline 274 didn't return from function 'actual_size' because the condition on line 274 was always true

275 self.zoom(px, center) 

276 

277 def center_on(self, point: QPointF): 

278 here = self.vb.viewRect().center() 

279 self.vb.translateBy(x=point.x() - here.x(), y=point.y() - here.y()) 

280 

281 def set_show_labels(self, on: bool): 

282 self.show_labels = on 

283 for label in self.labels: 

284 label.setVisible(on) 

285 

286 def node_at(self, scene_pos: QPointF) -> CfgNode | None: 

287 point = self.vb.mapSceneToView(scene_pos) 

288 return next((item for item in self.nodes.values() if item.scene_rect().contains(point)), None) 

289 

290 def open_node(self, node: cf.Node): 

291 path = node.path or (self.graph.path if self.graph else None) 

292 self.line_clicked.emit(str(path or ""), node.first) 

293 

294 def node_code(self, node: cf.Node) -> str: 

295 """The node's lines of code, as they are in the file.""" 

296 path = node.path or (self.graph.path if self.graph else None) 

297 if path is not None and path not in self.sources: 

298 try: 

299 self.sources[path] = read_source(path).text.splitlines() 

300 except OSError: 

301 self.sources[path] = [] 

302 source = self.sources.get(path, []) if path is not None else [] 

303 if not node.first or not source: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true

304 return "\n".join(node.text) 

305 lines = source[node.first - 1:node.last] 

306 indent = min((len(line) - len(line.lstrip()) for line in lines if line.strip()), default=0) 

307 return "\n".join(line[indent:] for line in lines) 

308 

309 # ------------------------------------------------------------ the menu 

310 def _scene_clicked(self, event): 

311 if self.graph is None: 

312 return 

313 if event.button() == Qt.RightButton: 

314 event.accept() 

315 self.menu = self.context_menu(event.scenePos()) 

316 self.menu.popup(event.screenPos().toPoint()) 

317 elif event.button() == Qt.LeftButton and event.double() and self.node_at(event.scenePos()) is None: 

318 self.fit() 

319 

320 def context_menu(self, scene_pos: QPointF) -> QMenu: 

321 """The menu for a right-click at ``scene_pos``: a node's actions 

322 first, if it's on one, then the view's.""" 

323 menu = QMenu(self) 

324 point = self.vb.mapSceneToView(scene_pos) 

325 item = self.node_at(scene_pos) 

326 if item is not None: 

327 node = item.node 

328 if node.first: 328 ↛ 332line 328 didn't jump to line 332 because the condition on line 328 was always true

329 lines = f"Line {node.first}" if node.first == node.last else f"Lines {node.first}–{node.last}" 

330 menu.addAction(f"Open {lines}", lambda: self.open_node(node)) 

331 menu.addAction("Copy Code", lambda: QApplication.clipboard().setText(self.node_code(node))) 

332 center = item.pos() 

333 menu.addAction("Center on This", lambda: self.center_on(center)) 

334 menu.addSeparator() 

335 menu.addAction("Fit to View", self.fit) 

336 menu.addAction("Zoom In", lambda: self.zoom(1.25, point)) 

337 menu.addAction("Zoom Out", lambda: self.zoom(0.8, point)) 

338 menu.addAction("Actual Size", lambda: self.actual_size(point)) 

339 menu.addSeparator() 

340 labels = menu.addAction("Show Edge Labels") 

341 labels.setCheckable(True) 

342 labels.setChecked(self.show_labels) 

343 labels.toggled.connect(self.set_show_labels) 

344 menu.addSeparator() 

345 menu.addAction("Copy Image", lambda: QApplication.clipboard().setImage(self.image())) 

346 menu.addAction("Save Image…", self.save_image) 

347 return menu 

348 

349 # ------------------------------------------------------------ images 

350 def _render(self, paint_device, size) -> None: 

351 """Draw the whole graph (fitted, the view put back after) onto 

352 ``paint_device`` of ``size``.""" 

353 view_range = self.vb.viewRange() 

354 self.vb.setRange(rect=self.bounds(), padding=0) 

355 try: 

356 bounds = self.bounds().adjusted(-6, -6, 6, 6) 

357 source = self.vb.mapViewToScene(bounds).boundingRect() 

358 target = QRectF(0, 0, size.width(), size.height()) 

359 painter = QPainter(paint_device) 

360 painter.setRenderHint(QPainter.Antialiasing) 

361 painter.fillRect(target, QColor(self.colors["background"])) 

362 self.plot.scene().render(painter, target, source) 

363 painter.end() 

364 finally: 

365 self.vb.setRange(xRange=view_range[0], yRange=view_range[1], padding=0) 

366 

367 def _image_size(self): 

368 return self.bounds().adjusted(-6, -6, 6, 6).size() * IMAGE_SCALE 

369 

370 def image(self) -> QImage: 

371 """The whole graph as an image (2 pixels per unit).""" 

372 size = self._image_size().toSize() 

373 image = QImage(size, QImage.Format_ARGB32) 

374 self._render(image, size) 

375 return image 

376 

377 def export_image(self, filename: str) -> bool: 

378 """Save the whole graph as PNG/JPG, or SVG if the name ends in .svg.""" 

379 try: 

380 if filename.lower().endswith(".svg"): 

381 from qtpy.QtSvg import QSvgGenerator 

382 

383 size = self._image_size() / IMAGE_SCALE 

384 svg = QSvgGenerator() 

385 svg.setFileName(filename) 

386 svg.setSize(size.toSize()) 

387 svg.setViewBox(QRectF(0, 0, size.width(), size.height())) 

388 svg.setTitle(self.graph.name if self.graph else "control flow") 

389 self._render(svg, size) 

390 return Path(filename).exists() 

391 return self.image().save(filename) 

392 except Exception: # noqa: BLE001 - report failure to the caller 

393 return False 

394 

395 def save_image(self): 

396 name = (self.graph.name if self.graph else "control-flow") + ".png" 

397 filename, _ = QFileDialog.getSaveFileName(self, "Save control-flow graph", name, 

398 "PNG image (*.png);;SVG image (*.svg)") 

399 if filename and not self.export_image(filename): 

400 QMessageBox.warning(self, "Save failed", f"Could not write {filename}") 

401 

402 def _add_edge(self, points: np.ndarray, direction, label: str): 

403 color = QColor(self.colors["edge"]) 

404 curve = pg.PlotCurveItem(points[:, 0], points[:, 1], pen=pg.mkPen(color, width=1.4)) 

405 curve.setZValue(1) 

406 arrow = pg.ArrowItem(headLen=9, tipAngle=34, tailLen=None, pen=pg.mkPen(color), brush=pg.mkBrush(color), 

407 angle=math.degrees(math.atan2(-direction[1], -direction[0]))) 

408 arrow.setPos(points[-1, 0], points[-1, 1]) 

409 arrow.setZValue(2) 

410 self.vb.addItem(curve) 

411 self.vb.addItem(arrow) 

412 if label: 

413 font = QFont(self.code_font) 

414 font.setPointSizeF(max(font.pointSizeF() - 1, 6)) 

415 at = points[min(len(points) - 1, max(2, len(points) // 4))] 

416 left = points[-1, 0] < points[0, 0] - 1 # (heading left: the label on its left) 

417 text = EdgeLabel(label if len(label) <= 28 else label[:27] + "…", font, self.colors["label"], left) 

418 text.setPos(at[0] + (-4 if left else 4), at[1]) 

419 text.setVisible(self.show_labels) 

420 self.labels.append(text) 

421 self.vb.addItem(text) 

422 

423 

424def _routed(source: QRectF, target: QRectF, waypoints: list[tuple[float, float]]): 

425 """An edge across layers: from the source's bottom through its waypoints 

426 to the target's top, an S-curve between each two.""" 

427 stops = [QPointF(source.center().x(), source.bottom())] + [QPointF(x, y) for x, y in waypoints] \ 

428 + [QPointF(target.center().x(), target.top())] 

429 parts = [] 

430 for a, b in pairwise(stops): 

431 dy = (b.y() - a.y()) / 2 

432 parts.append(_bezier(a, QPointF(a.x(), a.y() + dy), QPointF(b.x(), b.y() - dy), b)) 

433 points = np.vstack(parts) 

434 d = points[-1] - points[-3] 

435 return points, (d[0], d[1]) 

436 

437 

438def _back_edge(source: QRectF, target: QRectF, x: float, drop: float): 

439 """A loop's way back: down out of the source's bottom by ``drop`` (into 

440 the gap below its layer, clear of the nodes beside it), right to ``x``, 

441 up, and into the target's right side.""" 

442 cx, below = source.center().x(), source.bottom() + drop 

443 ty = target.center().y() 

444 points = np.array([[cx, source.bottom()], [cx, below], [x, below], [x, ty], [target.right(), ty]]) 

445 return points, (target.right() - x, 0.0)