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)
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.
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.
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
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
hoverEnterEvent(self, event: PySide6.QtWidgets.QGraphicsSceneHoverEvent, /) -> None
hoverLeaveEvent(self, event: PySide6.QtWidgets.QGraphicsSceneHoverEvent, /) -> None
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.
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.
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
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.
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.
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)
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.
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 %).
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).
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).
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.
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).
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.
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}")