Coverage for vibefpmgui/debug_panel.py: 97%

173 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""The Debug panel: the session's state and controls (the Debug menu's 

2actions), the call stack, the variables (expanded as asked for: an array's 

3elements, a type's components) and the console: the program's output and the 

4debugger's messages, with a field to evaluate an expression in the frame 

5shown. It shows what `vibefpmgui.debug_session.DebugSession` says; the window 

6(`vibefpmgui.window.debugging`) connects them. 

7""" 

8 

9from __future__ import annotations 

10 

11from qtpy.QtCore import QSize, Qt, Signal 

12from qtpy.QtGui import QAction, QTextCursor 

13from qtpy.QtWidgets import ( 

14 QHBoxLayout, 

15 QLabel, 

16 QListWidget, 

17 QListWidgetItem, 

18 QPlainTextEdit, 

19 QSplitter, 

20 QToolButton, 

21 QTreeWidget, 

22 QTreeWidgetItem, 

23 QVBoxLayout, 

24 QWidget, 

25) 

26 

27from .breakpoints_list import BreakpointsList 

28from .build_monitor import PALETTES, TitleIcon, app_is_light 

29from .debug_session import ( 

30 IDLE, 

31 RUNNING, 

32 STARTING, 

33 STOPPED, 

34 DebugSession, 

35 Frame, 

36 Scope, 

37 Variable, 

38) 

39from .widgets import HistoryLineEdit 

40 

41REFERENCE_ROLE = Qt.UserRole + 1 # a variable's item: its variablesReference (0: no parts) 

42LOADED_ROLE = Qt.UserRole + 2 # its parts asked for already 

43PLACEHOLDER = "…" # (an item's child until its parts are asked for) 

44 

45STATE_TEXT = {IDLE: "Not debugging", STARTING: "Starting…", RUNNING: "Running", STOPPED: "Paused"} 

46 

47 

48class DebugPanel(QWidget): 

49 frame_chosen = Signal(int) # a call stack row clicked (its index) 

50 

51 def __init__(self, session: DebugSession, actions: list[QAction], breakpoint_actions: list[QAction] = (), 

52 parent=None): 

53 """``actions``: the buttons (the Debug menu's); ``breakpoint_actions``: in 

54 the breakpoints list's right-click menu (Disable All, Remove All).""" 

55 super().__init__(parent) 

56 self.session = session 

57 self.title_icon = TitleIcon("bug") # (as the other panels': see refresh_palette) 

58 self.title = QLabel() 

59 self.title.setTextInteractionFlags(Qt.TextSelectableByMouse) 

60 header = QHBoxLayout() 

61 header.setContentsMargins(8, 4, 6, 2) 

62 header.addWidget(self.title_icon) 

63 header.addSpacing(2) 

64 header.addWidget(self.title, 1) 

65 for action in actions: # (the Debug menu's: Continue, Step Over…; their icons, tooltips saying which) 

66 button = QToolButton() 

67 button.setDefaultAction(action) 

68 button.setToolButtonStyle(Qt.ToolButtonIconOnly) 

69 button.setIconSize(QSize(18, 18)) 

70 button.setAutoRaise(True) 

71 header.addWidget(button) 

72 

73 self.stack = QListWidget() 

74 self.stack.currentRowChanged.connect(self._frame_clicked) 

75 self.variables = QTreeWidget() 

76 self.variables.setHeaderLabels(["Name", "Value"]) 

77 self.variables.setColumnWidth(0, 160) 

78 self.variables.itemExpanded.connect(self._expanded) 

79 self.console = QPlainTextEdit() 

80 self.console.setReadOnly(True) 

81 self.console.setMaximumBlockCount(5000) 

82 self.evaluate_edit = HistoryLineEdit() # (Up, Down: the expressions evaluated before) 

83 self.evaluate_edit.setPlaceholderText("Evaluate in the frame shown (e.g. a(2:3), p%x), Enter; ↑↓ earlier ones") 

84 self.evaluate_edit.submitted.connect(self._evaluate) 

85 

86 self.breakpoints = BreakpointsList(breakpoint_actions) # (filled by the window: see window.debugging) 

87 left = QSplitter(Qt.Vertical) 

88 left.addWidget(_titled("Call Stack", self.stack)) 

89 left.addWidget(_titled("Breakpoints", self.breakpoints)) 

90 middle = _titled("Variables", self.variables) 

91 console_box = QWidget() 

92 console_layout = QVBoxLayout(console_box) 

93 console_layout.setContentsMargins(0, 0, 0, 0) 

94 console_layout.setSpacing(2) 

95 console_layout.addWidget(_titled("Console", self.console), 1) 

96 console_layout.addWidget(self.evaluate_edit) 

97 split = QSplitter(Qt.Horizontal) 

98 split.addWidget(left) 

99 split.addWidget(middle) 

100 split.addWidget(console_box) 

101 split.setStretchFactor(0, 2) 

102 split.setStretchFactor(1, 3) 

103 split.setStretchFactor(2, 3) 

104 layout = QVBoxLayout(self) 

105 layout.setContentsMargins(0, 0, 0, 0) 

106 layout.setSpacing(2) 

107 layout.addLayout(header) 

108 layout.addWidget(split, 1) 

109 

110 session.state_changed.connect(self._state_changed) 

111 session.stopped_at.connect(self._stopped) 

112 session.scopes_ready.connect(self._scopes) 

113 session.output.connect(self.append) 

114 session.ended.connect(lambda why: self.append("console", f"[{why}]\n")) 

115 self._state_changed(session.state) 

116 self.refresh_palette() 

117 

118 def refresh_palette(self): 

119 """The icon in the theme's text color (again when the theme changes).""" 

120 self.title_icon.draw(PALETTES["light" if app_is_light() else "dark"]["text"], self.title) 

121 

122 # ---------------------------------------------------------- state 

123 def _state_changed(self, state: str): 

124 adapter = self.session.adapter 

125 debugger = f" · {adapter.title}" if adapter is not None and state != IDLE else "" 

126 self.title.setText(f"<b>{STATE_TEXT.get(state, state)}</b>{debugger}") 

127 self.evaluate_edit.setEnabled(state == STOPPED) 

128 if state != STOPPED: 

129 self.stack.clear() 

130 self.variables.clear() 

131 

132 def begin(self, what: str): 

133 """A new session (``what``: the program): the console cleared.""" 

134 self.console.clear() 

135 self.append("console", f"[debugging {what}]\n") 

136 

137 def append(self, category: str, text: str): 

138 cursor = self.console.textCursor() 

139 cursor.movePosition(QTextCursor.End) 

140 cursor.insertText(text) 

141 self.console.setTextCursor(cursor) 

142 self.console.ensureCursorVisible() 

143 

144 # ---------------------------------------------------------- the stack 

145 def _stopped(self, frames: list[Frame], _why: str): 

146 self.stack.blockSignals(True) 

147 self.stack.clear() 

148 for frame in frames: 

149 where = f"{frame.path.rsplit('/', 1)[-1]}:{frame.line}" if frame.path else "(no source)" 

150 item = QListWidgetItem(f"{frame_name(frame.name)} · {where}") 

151 item.setToolTip(f"{frame.path}:{frame.line}" if frame.path else frame.name) 

152 if not frame.path: 

153 item.setForeground(self.palette().placeholderText()) 

154 self.stack.addItem(item) 

155 self.stack.setCurrentRow(self.session.frame_index) 

156 self.stack.blockSignals(False) 

157 

158 def _frame_clicked(self, row: int): 

159 if row >= 0: 

160 self.frame_chosen.emit(row) 

161 

162 # ---------------------------------------------------------- the variables 

163 def _scopes(self, _index: int, scopes: list[Scope]): 

164 self.variables.clear() 

165 for number, scope in enumerate(scopes): 

166 item = QTreeWidgetItem([scope.name, ""]) 

167 item.setData(0, REFERENCE_ROLE, scope.reference) 

168 font = item.font(0) 

169 font.setBold(True) 

170 item.setFont(0, font) 

171 self.variables.addTopLevelItem(item) 

172 item.addChild(QTreeWidgetItem([PLACEHOLDER, ""])) 

173 if number == 0 or not scope.expensive and scope.name.lower() in ("locals", "arguments"): 

174 item.setExpanded(True) 

175 

176 def _expanded(self, item: QTreeWidgetItem): 

177 if item.data(0, LOADED_ROLE): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true

178 return 

179 item.setData(0, LOADED_ROLE, True) 

180 reference = int(item.data(0, REFERENCE_ROLE) or 0) 

181 self.session.variables(reference, lambda variables, more, it=item: self._fill(it, variables, more)) 

182 

183 def _fill(self, item: QTreeWidgetItem, variables: list[Variable], more: bool): 

184 try: 

185 item.takeChildren() 

186 except RuntimeError: 

187 return # (cleared meanwhile: the program moved on) 

188 for variable in variables: 

189 child = QTreeWidgetItem([variable.name, variable.value]) 

190 child.setToolTip(1, variable.value) 

191 if variable.type: 

192 child.setToolTip(0, variable.type) 

193 child.setData(0, REFERENCE_ROLE, variable.reference) 

194 if variable.reference: 

195 child.addChild(QTreeWidgetItem([PLACEHOLDER, ""])) 

196 item.addChild(child) 

197 if more: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true

198 item.addChild(QTreeWidgetItem(["…", "more: evaluate a section, e.g. a(1001:1100)"])) 

199 if not variables: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

200 item.addChild(QTreeWidgetItem(["(none)", ""])) 

201 

202 def variable_rows(self) -> list[tuple[str, str]]: 

203 """(name, value) of the variables shown, depth first (for the tests).""" 

204 rows = [] 

205 

206 def visit(item: QTreeWidgetItem): 

207 for i in range(item.childCount()): 

208 child = item.child(i) 

209 if child.text(0) != PLACEHOLDER: 

210 rows.append((child.text(0), child.text(1))) 

211 visit(child) 

212 

213 for i in range(self.variables.topLevelItemCount()): 

214 visit(self.variables.topLevelItem(i)) 

215 return rows 

216 

217 # ---------------------------------------------------------- evaluate 

218 def _evaluate(self, expression: str): 

219 self.append("console", f"> {expression}\n") 

220 self.session.evaluate(expression, lambda ok, text, _ref: self.append("console", f"{text}\n")) 

221 

222 

223def frame_name(name: str) -> str: 

224 """A frame's procedure as a Fortran programmer knows it (gfortran calls 

225 the main program MAIN__).""" 

226 return "main program" if name == "MAIN__" else name 

227 

228 

229def _titled(title: str, widget: QWidget) -> QWidget: 

230 box = QWidget() 

231 layout = QVBoxLayout(box) 

232 layout.setContentsMargins(0, 0, 0, 0) 

233 layout.setSpacing(0) 

234 label = QLabel(title) 

235 label.setContentsMargins(6, 2, 6, 2) 

236 font = label.font() 

237 font.setBold(True) 

238 label.setFont(font) 

239 layout.addWidget(label) 

240 layout.addWidget(widget, 1) 

241 return box