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

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. 

4 

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""" 

11 

12from __future__ import annotations 

13 

14from collections.abc import Callable, Iterable 

15from dataclasses import dataclass 

16from pathlib import Path 

17 

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) 

42 

43from . import highlighter as hl 

44from .editor_shared import BREAKPOINT_COLOR, BREAKPOINT_UNPLACED, EXECUTION_COLOR 

45from .result_tree import paint_code, syntax_formats 

46 

47ENABLED, DISABLED, UNPLACED = "enabled", "disabled", "unplaced" 

48KEY_ROLE = Qt.UserRole # an item's (path, line) 

49 

50 

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 

59 

60 

61_icons: dict[tuple[str, bool], QIcon] = {} 

62 

63 

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)] 

91 

92 

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).""" 

96 

97 def __init__(self, tree: BreakpointsList): 

98 super().__init__(tree) 

99 self.tree = tree 

100 

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() 

123 

124 

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" 

131 

132 

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 

137 

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)) 

160 

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) 

182 

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) 

189 

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] 

198 

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() 

205 

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 

212 

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 

216 

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) 

226 

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) 

236 

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) 

241 

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 

255 

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() 

263 

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)