Coverage for vibefpmgui/breakpoints_list.py: 93%
190 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The Debug panel's list of breakpoints: every one in the project, each
2with its state drawn as in the editor's gutter (a red dot; hollow: disabled;
3grey: the debugger couldn't place it), its place and its line of code.
5A click opens the line; the right-click menu enables, disables or removes
6one (and, from the Debug menu, all of them); Delete removes the selected
7one. Where the program stopped at a breakpoint, it's selected, and the
8gutter's yellow arrow is drawn over its dot. The code is in the editor's
9font and syntax colors.
10"""
12from __future__ import annotations
14from collections.abc import Callable, Iterable
15from dataclasses import dataclass
16from pathlib import Path
18from pygments.styles import get_style_by_name
19from qtpy.QtCore import QPointF, QRectF, Qt, Signal
20from qtpy.QtGui import (
21 QAction,
22 QColor,
23 QFont,
24 QIcon,
25 QPainter,
26 QPalette,
27 QPen,
28 QPixmap,
29 QPolygonF,
30)
31from qtpy.QtWidgets import (
32 QAbstractItemView,
33 QApplication,
34 QHeaderView,
35 QMenu,
36 QStyle,
37 QStyledItemDelegate,
38 QStyleOptionViewItem,
39 QTreeWidget,
40 QTreeWidgetItem,
41)
43from . import highlighter as hl
44from .editor_shared import BREAKPOINT_COLOR, BREAKPOINT_UNPLACED, EXECUTION_COLOR
45from .result_tree import paint_code, syntax_formats
47ENABLED, DISABLED, UNPLACED = "enabled", "disabled", "unplaced"
48KEY_ROLE = Qt.UserRole # an item's (path, line)
51@dataclass(frozen=True)
52class BreakpointEntry:
53 path: str # (absolute: as the breakpoints are kept)
54 line: int
55 state: str # ENABLED, DISABLED or UNPLACED (enabled, but the debugger couldn't place it)
56 shown: str # its place as shown: the path in the project, and the line ("src/x.f90:12")
57 code: str = "" # the line's code (as in the file: its indentation not shown)
58 why: str = "" # why it wasn't placed
61_icons: dict[tuple[str, bool], QIcon] = {}
64def breakpoint_icon(state: str, here: bool = False) -> QIcon:
65 """The gutter's dot for a state; ``here``: the program is stopped at it
66 (the gutter's yellow arrow over the dot)."""
67 if (state, here) not in _icons:
68 icon = QIcon()
69 for ratio in (1, 2, 3):
70 pixmap = QPixmap(12 * ratio, 12 * ratio)
71 pixmap.setDevicePixelRatio(ratio)
72 pixmap.fill(Qt.transparent)
73 painter = QPainter(pixmap)
74 painter.setRenderHint(QPainter.Antialiasing)
75 if state == DISABLED:
76 painter.setPen(QPen(QColor(BREAKPOINT_COLOR), 1.5))
77 painter.setBrush(Qt.NoBrush)
78 else:
79 painter.setPen(Qt.NoPen)
80 painter.setBrush(QColor(BREAKPOINT_UNPLACED if state == UNPLACED else BREAKPOINT_COLOR))
81 painter.drawEllipse(QRectF(1.5, 1.5, 9, 9))
82 if here: # (as editor_gutter draws it, in 12 pixels)
83 painter.setPen(QPen(QColor(EXECUTION_COLOR).darker(140), 1))
84 painter.setBrush(QColor(EXECUTION_COLOR))
85 painter.drawPolygon(QPolygonF([QPointF(0.5, 1.5), QPointF(5.5, 1.5), QPointF(11.5, 6),
86 QPointF(5.5, 10.5), QPointF(0.5, 10.5)]))
87 painter.end()
88 icon.addPixmap(pixmap)
89 _icons[(state, here)] = icon
90 return _icons[(state, here)]
93class _CodeDelegate(QStyledItemDelegate):
94 """The code column: the line in the editor's font and syntax colors
95 (plain on the selected row: its text color reads best there)."""
97 def __init__(self, tree: BreakpointsList):
98 super().__init__(tree)
99 self.tree = tree
101 def paint(self, painter, option, index):
102 opt = QStyleOptionViewItem(option)
103 self.initStyleOption(opt, index)
104 entry = self.tree.entry_at(index.row())
105 if entry is None: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 super().paint(painter, option, index)
107 return
108 opt.text = ""
109 style = opt.widget.style() if opt.widget else QApplication.style()
110 style.drawControl(QStyle.CE_ItemViewItem, opt, painter, opt.widget)
111 selected = bool(opt.state & QStyle.State_Selected)
112 group = QPalette.Active if opt.state & QStyle.State_Active else QPalette.Inactive
113 plain = opt.palette.color(group, QPalette.HighlightedText if selected else QPalette.Text)
114 if entry.state == DISABLED and not selected:
115 plain = opt.palette.color(group, QPalette.PlaceholderText)
116 code = entry.code.lstrip()
117 column = len(entry.code) - len(code) # (where the code shown starts in the line)
118 formats = None if selected or entry.state == DISABLED else self.tree.line_formats(entry)
119 painter.save()
120 painter.setClipRect(opt.rect)
121 paint_code(painter, opt.rect.adjusted(4, 0, 0, 0), code, formats, column, self.tree.mono, plain)
122 painter.restore()
125def state_text(entry: BreakpointEntry) -> str:
126 if entry.state == DISABLED:
127 return "Disabled: the debugger doesn't stop here"
128 if entry.state == UNPLACED:
129 return f"Not placed: {entry.why}" if entry.why else "Not placed by the debugger"
130 return "Enabled"
133class BreakpointsList(QTreeWidget):
134 open_requested = Signal(str, int) # path, line: a breakpoint clicked
135 enabled_toggled = Signal(str, int) # path, line: enable it if disabled, else disable it
136 remove_requested = Signal(str, int) # path, line
138 def __init__(self, more_actions: Iterable[QAction] = (), parent=None):
139 """``more_actions``: added to every right-click menu (the Debug menu's
140 Disable All and Remove All)."""
141 super().__init__(parent)
142 self.more_actions = list(more_actions)
143 self.entries: list[BreakpointEntry] = []
144 self.setHeaderLabels(["Breakpoint", "Code"])
145 self.setRootIsDecorated(False)
146 self.setUniformRowHeights(True)
147 self.setSelectionMode(QAbstractItemView.SingleSelection)
148 self.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) # (the code: the rest)
149 self.setContextMenuPolicy(Qt.CustomContextMenu)
150 self.customContextMenuRequested.connect(self._menu_at)
151 self.itemClicked.connect(self._open)
152 self.itemActivated.connect(self._open) # (Enter, a double-click)
153 self._hit: tuple[str, int] | None = None # where the program stopped (selected, the arrow on it)
154 self.mono = QFont() # the code's font: the editor's (set by the window)
155 self.lines_for: Callable[[str], list[str]] | None = None # a file's lines (set by the window)
156 self._style = get_style_by_name(hl.style_name(QApplication.palette()))
157 self._formats: dict = {} # token type -> format
158 self._line_formats: dict[tuple[str, int, str], list | None] = {}
159 self.setItemDelegateForColumn(1, _CodeDelegate(self))
161 # ------------------------------------------------------------ contents
162 def set_entries(self, entries: list[BreakpointEntry]):
163 """The breakpoints now (the selection kept, if its breakpoint still is)."""
164 if entries == self.entries:
165 return
166 current = self.current_key()
167 self.entries = list(entries)
168 self._line_formats.clear()
169 self.clear()
170 for entry in entries:
171 item = QTreeWidgetItem([entry.shown, entry.code.strip()])
172 item.setIcon(0, breakpoint_icon(entry.state, (entry.path, entry.line) == self._hit))
173 item.setData(0, KEY_ROLE, (entry.path, entry.line))
174 tip = f"{entry.path}:{entry.line}\n{state_text(entry)}"
175 item.setToolTip(0, tip)
176 item.setToolTip(1, tip)
177 if entry.state == DISABLED:
178 item.setForeground(0, self.palette().placeholderText())
179 item.setForeground(1, self.palette().placeholderText())
180 self.addTopLevelItem(item)
181 self._select(self._hit or current)
183 def entry_at(self, row: int) -> BreakpointEntry | None:
184 item = self.topLevelItem(row)
185 if item is None: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 return None
187 key = tuple(item.data(0, KEY_ROLE))
188 return next((e for e in self.entries if (e.path, e.line) == key), None)
190 def line_formats(self, entry: BreakpointEntry) -> list | None:
191 """The syntax format of each column of a breakpoint's line (None: plain)."""
192 key = (entry.path, entry.line, entry.code)
193 if key not in self._line_formats:
194 lines = self.lines_for(entry.path) if self.lines_for is not None else [entry.code]
195 line = entry.line if self.lines_for is not None else 1
196 self._line_formats[key] = syntax_formats(lines, line, Path(entry.path), self._style, self._formats)
197 return self._line_formats[key]
199 def refresh_style(self):
200 """The editor's syntax colors (View → Syntax Colors; light or dark)."""
201 self._style = get_style_by_name(hl.style_name(QApplication.palette()))
202 self._formats.clear()
203 self._line_formats.clear()
204 self.viewport().update()
206 def item_for(self, path: str, line: int) -> QTreeWidgetItem | None:
207 for i in range(self.topLevelItemCount()):
208 item = self.topLevelItem(i)
209 if tuple(item.data(0, KEY_ROLE)) == (path, line):
210 return item
211 return None
213 def current_key(self) -> tuple[str, int] | None:
214 item = self.currentItem()
215 return tuple(item.data(0, KEY_ROLE)) if item is not None and item.isSelected() else None
217 def show_hit(self, where: tuple[str, int] | None):
218 """The breakpoint the program stopped at, selected (None: it's not
219 stopped at one: nothing selected)."""
220 for entry in self.entries: # (the arrow: off the one it was on, onto this one)
221 if (entry.path, entry.line) in (self._hit, where):
222 self.item_for(entry.path, entry.line).setIcon(
223 0, breakpoint_icon(entry.state, (entry.path, entry.line) == where))
224 self._hit = where
225 self._select(where)
227 def _select(self, key: tuple[str, int] | None):
228 item = self.item_for(*key) if key else None
229 if item is None:
230 self.clearSelection()
231 self.setCurrentItem(None)
232 return
233 self.setCurrentItem(item)
234 item.setSelected(True)
235 self.scrollToItem(item)
237 # ------------------------------------------------------------ using it
238 def _open(self, item: QTreeWidgetItem, _column: int = 0):
239 path, line = item.data(0, KEY_ROLE)
240 self.open_requested.emit(path, line)
242 def menu_for(self, item: QTreeWidgetItem | None) -> QMenu:
243 menu = QMenu(self)
244 if item is not None: 244 ↛ 253line 244 didn't jump to line 253 because the condition on line 244 was always true
245 path, line = item.data(0, KEY_ROLE)
246 entry = next(e for e in self.entries if (e.path, e.line) == (path, line))
247 menu.addAction("Go to Line", lambda: self.open_requested.emit(path, line))
248 menu.addAction("Enable Breakpoint" if entry.state == DISABLED else "Disable Breakpoint",
249 lambda: self.enabled_toggled.emit(path, line))
250 menu.addAction("Remove Breakpoint", lambda: self.remove_requested.emit(path, line))
251 if self.more_actions: 251 ↛ 253line 251 didn't jump to line 253 because the condition on line 251 was always true
252 menu.addSeparator()
253 menu.addActions(self.more_actions)
254 return menu
256 def _menu_at(self, pos):
257 item = self.itemAt(pos)
258 if item is None and not self.entries:
259 return
260 menu = self.menu_for(item)
261 menu.exec(self.viewport().mapToGlobal(pos))
262 menu.deleteLater()
264 def keyPressEvent(self, event):
265 key = self.current_key()
266 if event.key() in (Qt.Key_Delete, Qt.Key_Backspace) and key is not None: 266 ↛ 269line 266 didn't jump to line 269 because the condition on line 266 was always true
267 self.remove_requested.emit(*key)
268 return
269 super().keyPressEvent(event)