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
« 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"""
12from __future__ import annotations
14import math
15from itertools import pairwise
16from pathlib import Path
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)
42from . import control_flow as cf
43from .graph_view import LAYERED_TB, _bezier, edge_geometry
44from .source_text import read_source
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}
59class CfgNode(pg.GraphicsObject):
60 """One node: its shape and text, centered on its place."""
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")
82 def boundingRect(self):
83 return self.rect.adjusted(-3, -3, 3, 3)
85 def scene_rect(self) -> QRectF:
86 return self.rect.translated(self.pos())
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
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()
126 def hoverEnterEvent(self, event):
127 self.hovered = True
128 self.update()
130 def hoverLeaveEvent(self, event):
131 self.hovered = False
132 self.update()
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)
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."""
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)
152 def boundingRect(self):
153 return self.rect
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)
161class CfgView(QWidget):
162 """A control-flow graph, or a message instead of one."""
164 line_clicked = Signal(str, int) # the clicked node's file and first line (1-based)
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.")
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)
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)
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)
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)
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
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())
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())
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)
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())
281 def set_show_labels(self, on: bool):
282 self.show_labels = on
283 for label in self.labels:
284 label.setVisible(on)
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)
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)
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)
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()
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
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)
367 def _image_size(self):
368 return self.bounds().adjusted(-6, -6, 6, 6).size() * IMAGE_SCALE
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
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
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
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}")
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)
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])
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)