Coverage for vibefpmgui/window/output.py: 80%

157 statements  

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

1"""The Output panel: a command's output (limited in size), or one item's.""" 

2 

3from __future__ import annotations 

4 

5import html 

6import re 

7 

8from qtpy.QtCore import Qt 

9from qtpy.QtGui import QFontDatabase, QTextCursor 

10from qtpy.QtWidgets import ( 

11 QHBoxLayout, 

12 QLabel, 

13 QPushButton, 

14 QSizePolicy, 

15 QStackedWidget, 

16 QVBoxLayout, 

17 QWidget, 

18) 

19 

20from .. import ansi 

21from ..compat import reveal_label 

22from ..console_view import ConsoleView 

23from ..editor import EditorTab 

24from ..output_links import OutputLinks 

25from ..project_tree import reveal_in_file_manager 

26 

27# A command's output beyond this many characters isn't added to the Output 

28# panel (it would freeze the app: an lfortran crash, for one, prints the whole 

29# compiled program, 100+ MB); the last lines are shown when it finishes and 

30# everything is saved to a file. 

31OUTPUT_SHOW_LIMIT = 2_000_000 

32 

33 

34OUTPUT_TAIL_LINES = 200 

35 

36 

37MAX_LINE_SHOWN = 20_000 # longer lines are shortened 

38 

39 

40def _breakable(text: str) -> str: 

41 """Let long names wrap: add zero-width spaces after _ . / - (word wrap 

42 otherwise only breaks at spaces). For display only: text copied from the 

43 label may include them.""" 

44 return re.sub(r"([_./\\-])(?=\w)", "\\1\u200b", text) 

45 

46 

47class OutputMixin: 

48 """The Output panel (a part of `vibefpmgui.main_window.MainWindow`).""" 

49 

50 def _build_output_panel(self): 

51 """The Output panel: all of a command's output, or one item's.""" 

52 mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) 

53 self.console = ConsoleView() # (fpm's "[ 25%]" progress drawn as badges) 

54 self.console.setReadOnly(True) 

55 self.console.setFont(mono) 

56 self.console.setMaximumBlockCount(20000) 

57 # Output of a single test, shown when a test row is clicked. 

58 self.test_output = ConsoleView() 

59 self.test_output.setReadOnly(True) 

60 self.test_output.setFont(mono) 

61 # Compiler messages may be colored (lfortran always colors them). 

62 self.console_writer = ansi.AnsiWriter(self.console) 

63 self.single_writer = ansi.AnsiWriter(self.test_output) 

64 # File locations in the output ("At line 12 of file src/x.f90", "src/x.f90:12:5:") open on a click. 

65 self.output_links = [] 

66 for view in (self.console, self.test_output): 

67 links = OutputLinks(view, lambda: self.manifest_path.parent if self.manifest_path else None) 

68 links.open_requested.connect(lambda path, line, column: self.navigate_to(path, line, column, 

69 preview=True)) 

70 self.output_links.append(links) 

71 self.output_stack = QStackedWidget() 

72 self.output_stack.addWidget(self.console) 

73 self.output_stack.addWidget(self.test_output) 

74 self._set_output_wrap(EditorTab.word_wrap) 

75 console_box = QWidget() 

76 cl = QVBoxLayout(console_box) 

77 cl.setContentsMargins(0, 0, 0, 0) 

78 # (No spacing: macOS buttons already keep some room around themselves, 

79 # for the focus ring, which the layout counts as part of the button.) 

80 cl.setSpacing(0) 

81 # What's shown, when it's one item's output (a test, a file's compile, 

82 # an AI review); hidden for all the output ("Output" is the panel's title). 

83 self.output_title = QLabel() 

84 self.output_title.hide() 

85 # Titles like "AI review of a_very_long_file_name.f90 (model)" wrap 

86 # instead of forcing the panel wider. 

87 self.output_title.setWordWrap(True) 

88 self.output_title.setMinimumWidth(40) 

89 self.output_title.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) 

90 self.output_title.setTextInteractionFlags(Qt.TextSelectableByMouse) 

91 # Title alone at the top; the buttons sit below the output. 

92 footer = QHBoxLayout() 

93 footer.setContentsMargins(0, 0, 0, 0) 

94 footer.setSpacing(6) 

95 footer.addStretch(1) 

96 self.show_all_button = QPushButton("Show all output") 

97 self.show_all_button.clicked.connect(self.show_all_output) 

98 self.show_all_button.hide() 

99 footer.addWidget(self.show_all_button) 

100 self.full_output_button = QPushButton("Full Output…") 

101 self.full_output_button.setToolTip("The last command's output was too long to show in full; " 

102 f"show the file it was saved to in {reveal_label()}") 

103 self.full_output_button.clicked.connect(lambda: reveal_in_file_manager(str(self._full_output_path))) 

104 self.full_output_button.hide() 

105 footer.addWidget(self.full_output_button) 

106 clear = QPushButton("Clear") 

107 clear.setToolTip("Clear the output") 

108 clear.clicked.connect(self.console.clear) 

109 footer.addWidget(clear) 

110 cl.addWidget(self.output_title) 

111 cl.addWidget(self.output_stack, 1) 

112 cl.addLayout(footer) 

113 return console_box 

114 

115 def _append(self, text: str, bold: bool = False, colored: bool = False): 

116 """Add text to the Output panel; ``colored``: program output, whose 

117 terminal color codes are shown as colors (and which is limited to 

118 OUTPUT_SHOW_LIMIT characters per command).""" 

119 if not colored: 

120 self.console_writer.write(text, bold, ansi=False) 

121 return 

122 if self._full_output is not None: 

123 try: 

124 self._full_output.write(ansi.strip(text)) 

125 except (OSError, ValueError): 

126 self._full_output = None 

127 if not self._hidden_chars and self._shown_chars + len(text) <= OUTPUT_SHOW_LIMIT: 127 ↛ 135line 127 didn't jump to line 135 because the condition on line 127 was always true

128 self._shown_chars += len(text) 

129 if len(text) > MAX_LINE_SHOWN and max(map(len, text.split("\n"))) > MAX_LINE_SHOWN: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true

130 text = "\n".join(line if len(line) <= MAX_LINE_SHOWN 

131 else line[:MAX_LINE_SHOWN] + f" … [{len(line) - MAX_LINE_SHOWN} more characters]" 

132 for line in text.split("\n")) 

133 self.console_writer.write(text, bold, ansi=True) 

134 return 

135 if not self._hidden_chars: 

136 self.console_writer.write( 

137 f"\n[The output is very long, so the rest isn't shown here (that would freeze the app). " 

138 f"The last {OUTPUT_TAIL_LINES} lines are shown when the command finishes, and all of " 

139 f"it is saved to {self._full_output_path} (Full Output… below).]\n", True, ansi=False) 

140 self.full_output_button.show() 

141 self._hidden_chars += len(text) 

142 lines = (self._tail_partial + text).split("\n") 

143 self._tail_partial = lines.pop() 

144 self._hidden_lines += len(lines) 

145 self._tail.extend(line[:MAX_LINE_SHOWN] for line in lines) 

146 

147 def _begin_output(self): 

148 """A command is starting: reset the output limit and the saved copy.""" 

149 self._end_output(show_tail=False) 

150 self._decoder.reset() 

151 for links in self.output_links: 

152 links.forget() # (files may have come or gone) 

153 self._shown_chars = self._hidden_chars = self._hidden_lines = 0 

154 self._tail.clear() 

155 self._tail_partial = "" 

156 self.full_output_button.hide() 

157 try: 

158 self._full_output = open(self._full_output_path, "w", encoding="utf-8", errors="replace") # noqa: SIM115 

159 except OSError: 

160 self._full_output = None 

161 

162 def _end_output(self, show_tail: bool = True): 

163 """The command has finished: show the end of any output that wasn't shown.""" 

164 if self._full_output is not None: 

165 try: 

166 self._full_output.close() 

167 except OSError: 

168 pass 

169 self._full_output = None 

170 if show_tail and self._hidden_chars: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true

171 tail = list(self._tail) + ([self._tail_partial] if self._tail_partial else []) 

172 skipped = self._hidden_lines + bool(self._tail_partial) - len(tail) 

173 self.console_writer.write( 

174 f"\n[… {skipped:,} lines ({self._hidden_chars / 1e6:.1f} MB) not shown — " 

175 f"full output: {self._full_output_path} …]\n", True, ansi=False) 

176 self.console_writer.write("\n".join(tail) + "\n", ansi=True) 

177 self._hidden_chars = 0 

178 

179 def _show_marked_output(self, monitor, name: str): 

180 """Show one test's or program's output (or everything when empty).""" 

181 if not name: 

182 self.show_all_output() 

183 return 

184 self.build_monitor.clear_selection() 

185 other = self.test_monitor if monitor is self.run_monitor else self.run_monitor 

186 if other.selected: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 other.selected = "" 

188 other.table.viewport().update() 

189 self._show_single_output(name, monitor.output_for(name) or "(no output)") 

190 

191 def show_test_output(self, name: str): 

192 """Show one test's output (or everything when name is empty).""" 

193 self._show_marked_output(self.test_monitor, name) 

194 

195 def show_build_details(self, row: int): 

196 """Show one file's compiler messages (or everything when row is -1).""" 

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

198 self.show_all_output() 

199 return 

200 for monitor in (self.test_monitor, self.run_monitor): 

201 if monitor.selected: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true

202 monitor.selected = "" 

203 monitor.table.viewport().update() 

204 self._show_single_output(self.build_monitor.details_title(row), 

205 self.build_monitor.details_text(row)) 

206 

207 def _show_single_output(self, title: str, text: str): 

208 self.test_output.clear() 

209 self.single_writer.reset() 

210 self.single_writer.write(text) 

211 self.test_output.moveCursor(QTextCursor.Start) 

212 self.output_title.setText(f"<b>{_breakable(html.escape(title))}</b>") 

213 self.output_title.show() 

214 self.output_stack.setCurrentWidget(self.test_output) 

215 self.show_all_button.show() 

216 

217 def show_all_output(self): 

218 self.output_title.hide() 

219 self.output_stack.setCurrentWidget(self.console) 

220 self.show_all_button.hide() 

221 self.build_monitor.clear_selection() 

222 for monitor in (self.test_monitor, self.run_monitor): 

223 if monitor.selected: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true

224 monitor.selected = "" 

225 monitor.table.viewport().update()