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
« 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."""
3from __future__ import annotations
5import html
6import re
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)
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
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
34OUTPUT_TAIL_LINES = 200
37MAX_LINE_SHOWN = 20_000 # longer lines are shortened
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)
47class OutputMixin:
48 """The Output panel (a part of `vibefpmgui.main_window.MainWindow`)."""
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
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)
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
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
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)")
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)
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))
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()
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()