vibefpmgui.cfg_view

Drawing a procedure's control-flow graph (vibefpmgui.control_flow) with pyqtgraph: a box per block (its first lines of code), a hexagon per decision, a rounded box per loop, pills for start, end and stop; arrows labelled with the way taken ("true", "case (1)", "loop"…), a loop's way back curving round its right side; a node whose lines are in an included file has a dashed border. Wheel zooms, drag pans, clicking a node opens its lines, double-clicking the background fits the graph to the view; right-click for a menu (open or copy a node's code, center on it, fit, zoom, actual size, edge labels on or off, copy or save the graph as an image).

  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:
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:
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:
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:
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:
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:
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)
PADDING = 8
MAX_FIT_SCALE = 1.3
IMAGE_SCALE = 2
COLORS = {'light': {'background': '#ffffff', 'text': '#1f2328', 'edge': '#8b949e', 'label': '#57606a', 'block': ('#f6f8fa', '#d0d7de'), 'decision': ('#fff4e5', '#e8a317'), 'loop': ('#e7f5ff', '#4c6ef5'), 'start': ('#ebfbee', '#2f9e44'), 'end': ('#ebfbee', '#2f9e44'), 'stop': ('#fff5f5', '#e03131')}, 'dark': {'background': '#1e1f22', 'text': '#e6e8eb', 'edge': '#7d8590', 'label': '#9da7b3', 'block': ('#2a2d31', '#454a51'), 'decision': ('#3a2f1a', '#e8a317'), 'loop': ('#1d2a45', '#748ffc'), 'start': ('#1f3326', '#40c057'), 'end': ('#1f3326', '#40c057'), 'stop': ('#3d1f22', '#fa5252')}}
class CfgNode(pyqtgraph.graphicsItems.GraphicsObject.GraphicsObject):
 60class CfgNode(pg.GraphicsObject):
 61    """One node: its shape and text, centered on its place."""
 62
 63    def __init__(self, view: CfgView, node: cf.Node):
 64        super().__init__()
 65        self.view, self.node = view, node
 66        self.hovered = False
 67        metrics = QFontMetricsF(view.code_font)
 68        width = max((metrics.horizontalAdvance(line) for line in node.text), default=20) + 2 * PADDING
 69        if node.kind == cf.DECISION:
 70            width += 24  # (the hexagon's points)
 71        height = metrics.height() * max(len(node.text), 1) + 2 * PADDING
 72        self.size = (max(width, 56), height)
 73        self.rect = QRectF(-self.size[0] / 2, -self.size[1] / 2, *self.size)
 74        self.setZValue(10)
 75        self.setAcceptHoverEvents(True)
 76        self.included = node.path is not None and view.graph is not None and node.path != view.graph.path
 77        if node.first:
 78            self.setCursor(Qt.PointingHandCursor)
 79            lines = f"line {node.first}" if node.first == node.last else f"lines {node.first}–{node.last}"
 80            where = f"{node.path.name} (included), " if self.included else ""
 81            self.setToolTip(f"{where}{lines}: click to open")
 82
 83    def boundingRect(self):
 84        return self.rect.adjusted(-3, -3, 3, 3)
 85
 86    def scene_rect(self) -> QRectF:
 87        return self.rect.translated(self.pos())
 88
 89    def shape_path(self) -> QPainterPath:
 90        r = self.rect
 91        path = QPainterPath()
 92        kind = self.node.kind
 93        if kind == cf.DECISION:
 94            d = min(12, r.height() / 2)
 95            path.addPolygon(QPolygonF([QPointF(r.left() + d, r.top()), QPointF(r.right() - d, r.top()),
 96                                       QPointF(r.right(), r.center().y()), QPointF(r.right() - d, r.bottom()),
 97                                       QPointF(r.left() + d, r.bottom()), QPointF(r.left(), r.center().y())]))
 98            path.closeSubpath()
 99        elif kind in (cf.START, cf.END, cf.STOP):
100            path.addRoundedRect(r, r.height() / 2, r.height() / 2)
101        elif kind == cf.LOOP:
102            path.addRoundedRect(r, 9, 9)
103        else:
104            path.addRoundedRect(r, 3, 3)
105        return path
106
107    def paint(self, painter: QPainter, option, widget=None):
108        colors = self.view.colors
109        fill, border = colors[self.node.kind]
110        painter.setRenderHint(QPainter.Antialiasing)
111        pen = QPen(QColor(border), 2.2 if self.hovered else 1.3)
112        if self.included:
113            pen.setStyle(Qt.DashLine)
114        painter.setPen(pen)
115        painter.setBrush(QBrush(QColor(fill)))
116        painter.drawPath(self.shape_path())
117        painter.setPen(QColor(colors["text"]))
118        painter.setFont(self.view.code_font)
119        metrics = QFontMetricsF(self.view.code_font)
120        y = self.rect.top() + PADDING
121        centered = self.node.kind in (cf.START, cf.END, cf.STOP, cf.DECISION)
122        for line in self.node.text:
123            box = QRectF(self.rect.left() + PADDING, y, self.rect.width() - 2 * PADDING, metrics.height())
124            painter.drawText(box, (Qt.AlignHCenter if centered else Qt.AlignLeft) | Qt.AlignVCenter, line)
125            y += metrics.height()
126
127    def hoverEnterEvent(self, event):
128        self.hovered = True
129        self.update()
130
131    def hoverLeaveEvent(self, event):
132        self.hovered = False
133        self.update()
134
135    def mouseClickEvent(self, event):
136        if event.button() == Qt.LeftButton and self.node.first:
137            event.accept()
138            self.view.open_node(self.node)

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

CfgNode( view: CfgView, node: vibefpmgui.control_flow.Node)
63    def __init__(self, view: CfgView, node: cf.Node):
64        super().__init__()
65        self.view, self.node = view, node
66        self.hovered = False
67        metrics = QFontMetricsF(view.code_font)
68        width = max((metrics.horizontalAdvance(line) for line in node.text), default=20) + 2 * PADDING
69        if node.kind == cf.DECISION:
70            width += 24  # (the hexagon's points)
71        height = metrics.height() * max(len(node.text), 1) + 2 * PADDING
72        self.size = (max(width, 56), height)
73        self.rect = QRectF(-self.size[0] / 2, -self.size[1] / 2, *self.size)
74        self.setZValue(10)
75        self.setAcceptHoverEvents(True)
76        self.included = node.path is not None and view.graph is not None and node.path != view.graph.path
77        if node.first:
78            self.setCursor(Qt.PointingHandCursor)
79            lines = f"line {node.first}" if node.first == node.last else f"lines {node.first}–{node.last}"
80            where = f"{node.path.name} (included), " if self.included else ""
81            self.setToolTip(f"{where}{lines}: click to open")

__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.

hovered
size
rect
included
def boundingRect(self):
83    def boundingRect(self):
84        return self.rect.adjusted(-3, -3, 3, 3)

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

def scene_rect(self) -> PySide6.QtCore.QRectF:
86    def scene_rect(self) -> QRectF:
87        return self.rect.translated(self.pos())
def shape_path(self) -> PySide6.QtGui.QPainterPath:
 89    def shape_path(self) -> QPainterPath:
 90        r = self.rect
 91        path = QPainterPath()
 92        kind = self.node.kind
 93        if kind == cf.DECISION:
 94            d = min(12, r.height() / 2)
 95            path.addPolygon(QPolygonF([QPointF(r.left() + d, r.top()), QPointF(r.right() - d, r.top()),
 96                                       QPointF(r.right(), r.center().y()), QPointF(r.right() - d, r.bottom()),
 97                                       QPointF(r.left() + d, r.bottom()), QPointF(r.left(), r.center().y())]))
 98            path.closeSubpath()
 99        elif kind in (cf.START, cf.END, cf.STOP):
100            path.addRoundedRect(r, r.height() / 2, r.height() / 2)
101        elif kind == cf.LOOP:
102            path.addRoundedRect(r, 9, 9)
103        else:
104            path.addRoundedRect(r, 3, 3)
105        return path
def paint(self, painter: PySide6.QtGui.QPainter, option, widget=None):
107    def paint(self, painter: QPainter, option, widget=None):
108        colors = self.view.colors
109        fill, border = colors[self.node.kind]
110        painter.setRenderHint(QPainter.Antialiasing)
111        pen = QPen(QColor(border), 2.2 if self.hovered else 1.3)
112        if self.included:
113            pen.setStyle(Qt.DashLine)
114        painter.setPen(pen)
115        painter.setBrush(QBrush(QColor(fill)))
116        painter.drawPath(self.shape_path())
117        painter.setPen(QColor(colors["text"]))
118        painter.setFont(self.view.code_font)
119        metrics = QFontMetricsF(self.view.code_font)
120        y = self.rect.top() + PADDING
121        centered = self.node.kind in (cf.START, cf.END, cf.STOP, cf.DECISION)
122        for line in self.node.text:
123            box = QRectF(self.rect.left() + PADDING, y, self.rect.width() - 2 * PADDING, metrics.height())
124            painter.drawText(box, (Qt.AlignHCenter if centered else Qt.AlignLeft) | Qt.AlignVCenter, line)
125            y += metrics.height()

paint(self, painter: PySide6.QtGui.QPainter, option: PySide6.QtWidgets.QStyleOptionGraphicsItem, /, widget: PySide6.QtWidgets.QWidget | None = None) -> None

def hoverEnterEvent(self, event):
127    def hoverEnterEvent(self, event):
128        self.hovered = True
129        self.update()

hoverEnterEvent(self, event: PySide6.QtWidgets.QGraphicsSceneHoverEvent, /) -> None

def hoverLeaveEvent(self, event):
131    def hoverLeaveEvent(self, event):
132        self.hovered = False
133        self.update()

hoverLeaveEvent(self, event: PySide6.QtWidgets.QGraphicsSceneHoverEvent, /) -> None

def mouseClickEvent(self, event):
135    def mouseClickEvent(self, event):
136        if event.button() == Qt.LeftButton and self.node.first:
137            event.accept()
138            self.view.open_node(self.node)
staticMetaObject = PySide6.QtCore.QMetaObject("CfgNode" inherits "GraphicsObject": )
class EdgeLabel(pyqtgraph.graphicsItems.GraphicsObject.GraphicsObject):
141class EdgeLabel(pg.GraphicsObject):
142    """An edge's label, in the graph's units (so it zooms and exports with
143    the graph); ``right``: its right edge at its place, not its left."""
144
145    def __init__(self, text: str, font: QFont, color: str, right: bool):
146        super().__init__()
147        self.text, self.font, self.color = text, font, color
148        metrics = QFontMetricsF(font)
149        width = metrics.horizontalAdvance(text)
150        self.rect = QRectF(-width if right else 0, 0, width, metrics.height())
151        self.setZValue(3)
152
153    def boundingRect(self):
154        return self.rect
155
156    def paint(self, painter: QPainter, option, widget=None):
157        painter.setPen(QColor(self.color))
158        painter.setFont(self.font)
159        painter.drawText(self.rect, Qt.AlignLeft | Qt.AlignVCenter, self.text)

An edge's label, in the graph's units (so it zooms and exports with the graph); right: its right edge at its place, not its left.

EdgeLabel(text: str, font: PySide6.QtGui.QFont, color: str, right: bool)
145    def __init__(self, text: str, font: QFont, color: str, right: bool):
146        super().__init__()
147        self.text, self.font, self.color = text, font, color
148        metrics = QFontMetricsF(font)
149        width = metrics.horizontalAdvance(text)
150        self.rect = QRectF(-width if right else 0, 0, width, metrics.height())
151        self.setZValue(3)

__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.

rect
def boundingRect(self):
153    def boundingRect(self):
154        return self.rect

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

def paint(self, painter: PySide6.QtGui.QPainter, option, widget=None):
156    def paint(self, painter: QPainter, option, widget=None):
157        painter.setPen(QColor(self.color))
158        painter.setFont(self.font)
159        painter.drawText(self.rect, Qt.AlignLeft | Qt.AlignVCenter, self.text)

paint(self, painter: PySide6.QtGui.QPainter, option: PySide6.QtWidgets.QStyleOptionGraphicsItem, /, widget: PySide6.QtWidgets.QWidget | None = None) -> None

staticMetaObject = PySide6.QtCore.QMetaObject("EdgeLabel" inherits "GraphicsObject": )
class CfgView(PySide6.QtWidgets.QWidget):
162class CfgView(QWidget):
163    """A control-flow graph, or a message instead of one."""
164
165    line_clicked = Signal(str, int)  # the clicked node's file and first line (1-based)
166
167    def __init__(self, parent=None):
168        super().__init__(parent)
169        self.graph: cf.Graph | None = None
170        self.sources: dict[Path, list[str]] = {}  # (files' lines, for Copy Code)
171        self.show_labels = True
172        self.labels: list[EdgeLabel] = []
173        self.menu: QMenu | None = None
174        self.code_font = QFont()
175        self.colors = COLORS["light"]
176        self.nodes: dict[int, CfgNode] = {}
177        self.plot = pg.GraphicsLayoutWidget()
178        self.plot.ci.setContentsMargins(0, 0, 0, 0)
179        self.vb = self.plot.addViewBox()
180        self.vb.setAspectLocked(True)
181        self.vb.invertY(True)
182        self.vb.setMenuEnabled(False)
183        self.plot.setBackground(self.colors["background"])
184        self.plot.scene().sigMouseClicked.connect(self._scene_clicked)
185        self.message = QLabel()
186        self.message.setAlignment(Qt.AlignCenter)
187        self.message.setWordWrap(True)
188        self.stack = QStackedLayout(self)
189        self.stack.addWidget(self.message)
190        self.stack.addWidget(self.plot)
191        self.show_message("Select a procedure to see its control flow.")
192
193    def set_code_font(self, font: QFont):
194        self.code_font = QFont(font)
195        self.code_font.setPointSizeF(max(font.pointSizeF() - 1, 7))
196        if self.graph is not None:
197            self.show_graph(self.graph)
198
199    def set_dark(self, dark: bool):
200        self.colors = COLORS["dark" if dark else "light"]
201        self.plot.setBackground(self.colors["background"])
202        if self.graph is not None:
203            self.show_graph(self.graph, fit=False)
204
205    def show_message(self, text: str):
206        self.graph = None
207        self.vb.clear()
208        self.nodes = {}
209        self.message.setText(text)
210        self.stack.setCurrentWidget(self.message)
211
212    def show_graph(self, graph: cf.Graph, source: str | None = None, fit: bool = True):
213        """Draw ``graph`` (``source``: its file's text, for Copy Code; kept if
214        not given); ``fit``: fit it to the view, else keep the view's range."""
215        if source is not None or self.graph is None or graph.path != self.graph.path:
216            self.sources = {graph.path: source.splitlines()} if source is not None and graph.path else {}
217        view_range = self.vb.viewRange()
218        self.graph = graph
219        self.vb.clear()
220        self.labels = []
221        self.nodes = {n.id: CfgNode(self, n) for n in graph.nodes}
222        places = cf.layout(graph, {i: item.size for i, item in self.nodes.items()})
223        for i, item in self.nodes.items():
224            x, y = places.positions[i]
225            item.setPos(x, y)
226            self.vb.addItem(item)
227        right_edge = max((item.scene_rect().right() for item in self.nodes.values()), default=0)
228        backs = 0
229        for index, edge in enumerate(graph.edges):
230            source, target = self.nodes[edge.source], self.nodes[edge.target]
231            if edge.back:
232                backs += 1
233                points, direction = _back_edge(source.scene_rect(), target.scene_rect(), right_edge + 18 * backs,
234                                               8 + 4 * (backs % 3))
235            elif index in places.routes:
236                points, direction = _routed(source.scene_rect(), target.scene_rect(), places.routes[index])
237            else:
238                points, direction = edge_geometry(source.scene_rect(), target.scene_rect(), LAYERED_TB, True)
239            self._add_edge(points, direction, edge.label)
240        self.stack.setCurrentWidget(self.plot)
241        if fit:
242            self.fit()
243        else:
244            self.vb.setRange(xRange=view_range[0], yRange=view_range[1], padding=0)
245
246    # ------------------------------------------------------------ the view
247    def bounds(self) -> QRectF:
248        """The graph's extent, in its own units."""
249        rect = QRectF()
250        for item in self.nodes.values():
251            rect = rect.united(item.mapRectToView(item.boundingRect()))
252        for item in self.vb.addedItems:
253            if isinstance(item, pg.PlotCurveItem) or (isinstance(item, EdgeLabel) and item.isVisible()):
254                rect = rect.united(item.mapRectToView(item.boundingRect()))
255        return rect
256
257    def fit(self):
258        """The whole graph in view (a small one at most 130 %)."""
259        if not self.nodes:
260            return
261        rect = self.bounds()
262        self.vb.setRange(rect=rect, padding=0.04)
263        px = self.vb.viewPixelSize()[0]
264        if 0 < px < 1 / MAX_FIT_SCALE:
265            factor = 1 / (MAX_FIT_SCALE * px)
266            self.vb.scaleBy((factor, factor), center=rect.center())
267
268    def zoom(self, factor: float, center: QPointF | None = None):
269        """Zoom in (``factor`` > 1) or out about ``center`` (the view's)."""
270        self.vb.scaleBy((1 / factor, 1 / factor), center=center if center is not None else self.vb.viewRect().center())
271
272    def actual_size(self, center: QPointF | None = None):
273        """100 %: a unit of the graph a pixel (its text the code's size)."""
274        px = self.vb.viewPixelSize()[0]
275        if px > 0:
276            self.zoom(px, center)
277
278    def center_on(self, point: QPointF):
279        here = self.vb.viewRect().center()
280        self.vb.translateBy(x=point.x() - here.x(), y=point.y() - here.y())
281
282    def set_show_labels(self, on: bool):
283        self.show_labels = on
284        for label in self.labels:
285            label.setVisible(on)
286
287    def node_at(self, scene_pos: QPointF) -> CfgNode | None:
288        point = self.vb.mapSceneToView(scene_pos)
289        return next((item for item in self.nodes.values() if item.scene_rect().contains(point)), None)
290
291    def open_node(self, node: cf.Node):
292        path = node.path or (self.graph.path if self.graph else None)
293        self.line_clicked.emit(str(path or ""), node.first)
294
295    def node_code(self, node: cf.Node) -> str:
296        """The node's lines of code, as they are in the file."""
297        path = node.path or (self.graph.path if self.graph else None)
298        if path is not None and path not in self.sources:
299            try:
300                self.sources[path] = read_source(path).text.splitlines()
301            except OSError:
302                self.sources[path] = []
303        source = self.sources.get(path, []) if path is not None else []
304        if not node.first or not source:
305            return "\n".join(node.text)
306        lines = source[node.first - 1:node.last]
307        indent = min((len(line) - len(line.lstrip()) for line in lines if line.strip()), default=0)
308        return "\n".join(line[indent:] for line in lines)
309
310    # ------------------------------------------------------------ the menu
311    def _scene_clicked(self, event):
312        if self.graph is None:
313            return
314        if event.button() == Qt.RightButton:
315            event.accept()
316            self.menu = self.context_menu(event.scenePos())
317            self.menu.popup(event.screenPos().toPoint())
318        elif event.button() == Qt.LeftButton and event.double() and self.node_at(event.scenePos()) is None:
319            self.fit()
320
321    def context_menu(self, scene_pos: QPointF) -> QMenu:
322        """The menu for a right-click at ``scene_pos``: a node's actions
323        first, if it's on one, then the view's."""
324        menu = QMenu(self)
325        point = self.vb.mapSceneToView(scene_pos)
326        item = self.node_at(scene_pos)
327        if item is not None:
328            node = item.node
329            if node.first:
330                lines = f"Line {node.first}" if node.first == node.last else f"Lines {node.first}–{node.last}"
331                menu.addAction(f"Open {lines}", lambda: self.open_node(node))
332                menu.addAction("Copy Code", lambda: QApplication.clipboard().setText(self.node_code(node)))
333            center = item.pos()
334            menu.addAction("Center on This", lambda: self.center_on(center))
335            menu.addSeparator()
336        menu.addAction("Fit to View", self.fit)
337        menu.addAction("Zoom In", lambda: self.zoom(1.25, point))
338        menu.addAction("Zoom Out", lambda: self.zoom(0.8, point))
339        menu.addAction("Actual Size", lambda: self.actual_size(point))
340        menu.addSeparator()
341        labels = menu.addAction("Show Edge Labels")
342        labels.setCheckable(True)
343        labels.setChecked(self.show_labels)
344        labels.toggled.connect(self.set_show_labels)
345        menu.addSeparator()
346        menu.addAction("Copy Image", lambda: QApplication.clipboard().setImage(self.image()))
347        menu.addAction("Save Image…", self.save_image)
348        return menu
349
350    # ------------------------------------------------------------ images
351    def _render(self, paint_device, size) -> None:
352        """Draw the whole graph (fitted, the view put back after) onto
353        ``paint_device`` of ``size``."""
354        view_range = self.vb.viewRange()
355        self.vb.setRange(rect=self.bounds(), padding=0)
356        try:
357            bounds = self.bounds().adjusted(-6, -6, 6, 6)
358            source = self.vb.mapViewToScene(bounds).boundingRect()
359            target = QRectF(0, 0, size.width(), size.height())
360            painter = QPainter(paint_device)
361            painter.setRenderHint(QPainter.Antialiasing)
362            painter.fillRect(target, QColor(self.colors["background"]))
363            self.plot.scene().render(painter, target, source)
364            painter.end()
365        finally:
366            self.vb.setRange(xRange=view_range[0], yRange=view_range[1], padding=0)
367
368    def _image_size(self):
369        return self.bounds().adjusted(-6, -6, 6, 6).size() * IMAGE_SCALE
370
371    def image(self) -> QImage:
372        """The whole graph as an image (2 pixels per unit)."""
373        size = self._image_size().toSize()
374        image = QImage(size, QImage.Format_ARGB32)
375        self._render(image, size)
376        return image
377
378    def export_image(self, filename: str) -> bool:
379        """Save the whole graph as PNG/JPG, or SVG if the name ends in .svg."""
380        try:
381            if filename.lower().endswith(".svg"):
382                from qtpy.QtSvg import QSvgGenerator
383
384                size = self._image_size() / IMAGE_SCALE
385                svg = QSvgGenerator()
386                svg.setFileName(filename)
387                svg.setSize(size.toSize())
388                svg.setViewBox(QRectF(0, 0, size.width(), size.height()))
389                svg.setTitle(self.graph.name if self.graph else "control flow")
390                self._render(svg, size)
391                return Path(filename).exists()
392            return self.image().save(filename)
393        except Exception:  # noqa: BLE001 - report failure to the caller
394            return False
395
396    def save_image(self):
397        name = (self.graph.name if self.graph else "control-flow") + ".png"
398        filename, _ = QFileDialog.getSaveFileName(self, "Save control-flow graph", name,
399                                                  "PNG image (*.png);;SVG image (*.svg)")
400        if filename and not self.export_image(filename):
401            QMessageBox.warning(self, "Save failed", f"Could not write {filename}")
402
403    def _add_edge(self, points: np.ndarray, direction, label: str):
404        color = QColor(self.colors["edge"])
405        curve = pg.PlotCurveItem(points[:, 0], points[:, 1], pen=pg.mkPen(color, width=1.4))
406        curve.setZValue(1)
407        arrow = pg.ArrowItem(headLen=9, tipAngle=34, tailLen=None, pen=pg.mkPen(color), brush=pg.mkBrush(color),
408                             angle=math.degrees(math.atan2(-direction[1], -direction[0])))
409        arrow.setPos(points[-1, 0], points[-1, 1])
410        arrow.setZValue(2)
411        self.vb.addItem(curve)
412        self.vb.addItem(arrow)
413        if label:
414            font = QFont(self.code_font)
415            font.setPointSizeF(max(font.pointSizeF() - 1, 6))
416            at = points[min(len(points) - 1, max(2, len(points) // 4))]
417            left = points[-1, 0] < points[0, 0] - 1  # (heading left: the label on its left)
418            text = EdgeLabel(label if len(label) <= 28 else label[:27] + "…", font, self.colors["label"], left)
419            text.setPos(at[0] + (-4 if left else 4), at[1])
420            text.setVisible(self.show_labels)
421            self.labels.append(text)
422            self.vb.addItem(text)

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

CfgView(parent=None)
167    def __init__(self, parent=None):
168        super().__init__(parent)
169        self.graph: cf.Graph | None = None
170        self.sources: dict[Path, list[str]] = {}  # (files' lines, for Copy Code)
171        self.show_labels = True
172        self.labels: list[EdgeLabel] = []
173        self.menu: QMenu | None = None
174        self.code_font = QFont()
175        self.colors = COLORS["light"]
176        self.nodes: dict[int, CfgNode] = {}
177        self.plot = pg.GraphicsLayoutWidget()
178        self.plot.ci.setContentsMargins(0, 0, 0, 0)
179        self.vb = self.plot.addViewBox()
180        self.vb.setAspectLocked(True)
181        self.vb.invertY(True)
182        self.vb.setMenuEnabled(False)
183        self.plot.setBackground(self.colors["background"])
184        self.plot.scene().sigMouseClicked.connect(self._scene_clicked)
185        self.message = QLabel()
186        self.message.setAlignment(Qt.AlignCenter)
187        self.message.setWordWrap(True)
188        self.stack = QStackedLayout(self)
189        self.stack.addWidget(self.message)
190        self.stack.addWidget(self.plot)
191        self.show_message("Select a procedure to see its control flow.")

__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 line_clicked(unknown):

The type of the None singleton.

sources: dict[pathlib.Path, list[str]]
show_labels
labels: list[EdgeLabel]
menu: PySide6.QtWidgets.QMenu | None
code_font
colors
nodes: dict[int, CfgNode]
plot
vb
message
stack
def set_code_font(self, font: PySide6.QtGui.QFont):
193    def set_code_font(self, font: QFont):
194        self.code_font = QFont(font)
195        self.code_font.setPointSizeF(max(font.pointSizeF() - 1, 7))
196        if self.graph is not None:
197            self.show_graph(self.graph)
def set_dark(self, dark: bool):
199    def set_dark(self, dark: bool):
200        self.colors = COLORS["dark" if dark else "light"]
201        self.plot.setBackground(self.colors["background"])
202        if self.graph is not None:
203            self.show_graph(self.graph, fit=False)
def show_message(self, text: str):
205    def show_message(self, text: str):
206        self.graph = None
207        self.vb.clear()
208        self.nodes = {}
209        self.message.setText(text)
210        self.stack.setCurrentWidget(self.message)
def show_graph( self, graph: vibefpmgui.control_flow.Graph, source: str | None = None, fit: bool = True):
212    def show_graph(self, graph: cf.Graph, source: str | None = None, fit: bool = True):
213        """Draw ``graph`` (``source``: its file's text, for Copy Code; kept if
214        not given); ``fit``: fit it to the view, else keep the view's range."""
215        if source is not None or self.graph is None or graph.path != self.graph.path:
216            self.sources = {graph.path: source.splitlines()} if source is not None and graph.path else {}
217        view_range = self.vb.viewRange()
218        self.graph = graph
219        self.vb.clear()
220        self.labels = []
221        self.nodes = {n.id: CfgNode(self, n) for n in graph.nodes}
222        places = cf.layout(graph, {i: item.size for i, item in self.nodes.items()})
223        for i, item in self.nodes.items():
224            x, y = places.positions[i]
225            item.setPos(x, y)
226            self.vb.addItem(item)
227        right_edge = max((item.scene_rect().right() for item in self.nodes.values()), default=0)
228        backs = 0
229        for index, edge in enumerate(graph.edges):
230            source, target = self.nodes[edge.source], self.nodes[edge.target]
231            if edge.back:
232                backs += 1
233                points, direction = _back_edge(source.scene_rect(), target.scene_rect(), right_edge + 18 * backs,
234                                               8 + 4 * (backs % 3))
235            elif index in places.routes:
236                points, direction = _routed(source.scene_rect(), target.scene_rect(), places.routes[index])
237            else:
238                points, direction = edge_geometry(source.scene_rect(), target.scene_rect(), LAYERED_TB, True)
239            self._add_edge(points, direction, edge.label)
240        self.stack.setCurrentWidget(self.plot)
241        if fit:
242            self.fit()
243        else:
244            self.vb.setRange(xRange=view_range[0], yRange=view_range[1], padding=0)

Draw graph (source: its file's text, for Copy Code; kept if not given); fit: fit it to the view, else keep the view's range.

def bounds(self) -> PySide6.QtCore.QRectF:
247    def bounds(self) -> QRectF:
248        """The graph's extent, in its own units."""
249        rect = QRectF()
250        for item in self.nodes.values():
251            rect = rect.united(item.mapRectToView(item.boundingRect()))
252        for item in self.vb.addedItems:
253            if isinstance(item, pg.PlotCurveItem) or (isinstance(item, EdgeLabel) and item.isVisible()):
254                rect = rect.united(item.mapRectToView(item.boundingRect()))
255        return rect

The graph's extent, in its own units.

def fit(self):
257    def fit(self):
258        """The whole graph in view (a small one at most 130 %)."""
259        if not self.nodes:
260            return
261        rect = self.bounds()
262        self.vb.setRange(rect=rect, padding=0.04)
263        px = self.vb.viewPixelSize()[0]
264        if 0 < px < 1 / MAX_FIT_SCALE:
265            factor = 1 / (MAX_FIT_SCALE * px)
266            self.vb.scaleBy((factor, factor), center=rect.center())

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

def zoom(self, factor: float, center: PySide6.QtCore.QPointF | None = None):
268    def zoom(self, factor: float, center: QPointF | None = None):
269        """Zoom in (``factor`` > 1) or out about ``center`` (the view's)."""
270        self.vb.scaleBy((1 / factor, 1 / factor), center=center if center is not None else self.vb.viewRect().center())

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

def actual_size(self, center: PySide6.QtCore.QPointF | None = None):
272    def actual_size(self, center: QPointF | None = None):
273        """100 %: a unit of the graph a pixel (its text the code's size)."""
274        px = self.vb.viewPixelSize()[0]
275        if px > 0:
276            self.zoom(px, center)

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

def center_on(self, point: PySide6.QtCore.QPointF):
278    def center_on(self, point: QPointF):
279        here = self.vb.viewRect().center()
280        self.vb.translateBy(x=point.x() - here.x(), y=point.y() - here.y())
def set_show_labels(self, on: bool):
282    def set_show_labels(self, on: bool):
283        self.show_labels = on
284        for label in self.labels:
285            label.setVisible(on)
def node_at( self, scene_pos: PySide6.QtCore.QPointF) -> CfgNode | None:
287    def node_at(self, scene_pos: QPointF) -> CfgNode | None:
288        point = self.vb.mapSceneToView(scene_pos)
289        return next((item for item in self.nodes.values() if item.scene_rect().contains(point)), None)
def open_node(self, node: vibefpmgui.control_flow.Node):
291    def open_node(self, node: cf.Node):
292        path = node.path or (self.graph.path if self.graph else None)
293        self.line_clicked.emit(str(path or ""), node.first)
def node_code(self, node: vibefpmgui.control_flow.Node) -> str:
295    def node_code(self, node: cf.Node) -> str:
296        """The node's lines of code, as they are in the file."""
297        path = node.path or (self.graph.path if self.graph else None)
298        if path is not None and path not in self.sources:
299            try:
300                self.sources[path] = read_source(path).text.splitlines()
301            except OSError:
302                self.sources[path] = []
303        source = self.sources.get(path, []) if path is not None else []
304        if not node.first or not source:
305            return "\n".join(node.text)
306        lines = source[node.first - 1:node.last]
307        indent = min((len(line) - len(line.lstrip()) for line in lines if line.strip()), default=0)
308        return "\n".join(line[indent:] for line in lines)

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

def context_menu(self, scene_pos: PySide6.QtCore.QPointF) -> PySide6.QtWidgets.QMenu:
321    def context_menu(self, scene_pos: QPointF) -> QMenu:
322        """The menu for a right-click at ``scene_pos``: a node's actions
323        first, if it's on one, then the view's."""
324        menu = QMenu(self)
325        point = self.vb.mapSceneToView(scene_pos)
326        item = self.node_at(scene_pos)
327        if item is not None:
328            node = item.node
329            if node.first:
330                lines = f"Line {node.first}" if node.first == node.last else f"Lines {node.first}–{node.last}"
331                menu.addAction(f"Open {lines}", lambda: self.open_node(node))
332                menu.addAction("Copy Code", lambda: QApplication.clipboard().setText(self.node_code(node)))
333            center = item.pos()
334            menu.addAction("Center on This", lambda: self.center_on(center))
335            menu.addSeparator()
336        menu.addAction("Fit to View", self.fit)
337        menu.addAction("Zoom In", lambda: self.zoom(1.25, point))
338        menu.addAction("Zoom Out", lambda: self.zoom(0.8, point))
339        menu.addAction("Actual Size", lambda: self.actual_size(point))
340        menu.addSeparator()
341        labels = menu.addAction("Show Edge Labels")
342        labels.setCheckable(True)
343        labels.setChecked(self.show_labels)
344        labels.toggled.connect(self.set_show_labels)
345        menu.addSeparator()
346        menu.addAction("Copy Image", lambda: QApplication.clipboard().setImage(self.image()))
347        menu.addAction("Save Image…", self.save_image)
348        return menu

The menu for a right-click at scene_pos: a node's actions first, if it's on one, then the view's.

def image(self) -> PySide6.QtGui.QImage:
371    def image(self) -> QImage:
372        """The whole graph as an image (2 pixels per unit)."""
373        size = self._image_size().toSize()
374        image = QImage(size, QImage.Format_ARGB32)
375        self._render(image, size)
376        return image

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

def export_image(self, filename: str) -> bool:
378    def export_image(self, filename: str) -> bool:
379        """Save the whole graph as PNG/JPG, or SVG if the name ends in .svg."""
380        try:
381            if filename.lower().endswith(".svg"):
382                from qtpy.QtSvg import QSvgGenerator
383
384                size = self._image_size() / IMAGE_SCALE
385                svg = QSvgGenerator()
386                svg.setFileName(filename)
387                svg.setSize(size.toSize())
388                svg.setViewBox(QRectF(0, 0, size.width(), size.height()))
389                svg.setTitle(self.graph.name if self.graph else "control flow")
390                self._render(svg, size)
391                return Path(filename).exists()
392            return self.image().save(filename)
393        except Exception:  # noqa: BLE001 - report failure to the caller
394            return False

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

def save_image(self):
396    def save_image(self):
397        name = (self.graph.name if self.graph else "control-flow") + ".png"
398        filename, _ = QFileDialog.getSaveFileName(self, "Save control-flow graph", name,
399                                                  "PNG image (*.png);;SVG image (*.svg)")
400        if filename and not self.export_image(filename):
401            QMessageBox.warning(self, "Save failed", f"Could not write {filename}")
staticMetaObject = PySide6.QtCore.QMetaObject("CfgView" inherits "QWidget": Methods: #33 type=Signal, signature=line_clicked(QString,int), parameters=QString, int )