vibefpmgui.window.output

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

  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:
128            self._shown_chars += len(text)
129            if len(text) > MAX_LINE_SHOWN and max(map(len, text.split("\n"))) > MAX_LINE_SHOWN:
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:
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:
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:
198            self.show_all_output()
199            return
200        for monitor in (self.test_monitor, self.run_monitor):
201            if monitor.selected:
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:
224                monitor.selected = ""
225                monitor.table.viewport().update()
OUTPUT_SHOW_LIMIT = 2000000
OUTPUT_TAIL_LINES = 200
MAX_LINE_SHOWN = 20000
class OutputMixin:
 48class OutputMixin:
 49    """The Output panel (a part of `vibefpmgui.main_window.MainWindow`)."""
 50
 51    def _build_output_panel(self):
 52        """The Output panel: all of a command's output, or one item's."""
 53        mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
 54        self.console = ConsoleView()  # (fpm's "[ 25%]" progress drawn as badges)
 55        self.console.setReadOnly(True)
 56        self.console.setFont(mono)
 57        self.console.setMaximumBlockCount(20000)
 58        # Output of a single test, shown when a test row is clicked.
 59        self.test_output = ConsoleView()
 60        self.test_output.setReadOnly(True)
 61        self.test_output.setFont(mono)
 62        # Compiler messages may be colored (lfortran always colors them).
 63        self.console_writer = ansi.AnsiWriter(self.console)
 64        self.single_writer = ansi.AnsiWriter(self.test_output)
 65        # File locations in the output ("At line 12 of file src/x.f90", "src/x.f90:12:5:") open on a click.
 66        self.output_links = []
 67        for view in (self.console, self.test_output):
 68            links = OutputLinks(view, lambda: self.manifest_path.parent if self.manifest_path else None)
 69            links.open_requested.connect(lambda path, line, column: self.navigate_to(path, line, column,
 70                                                                                    preview=True))
 71            self.output_links.append(links)
 72        self.output_stack = QStackedWidget()
 73        self.output_stack.addWidget(self.console)
 74        self.output_stack.addWidget(self.test_output)
 75        self._set_output_wrap(EditorTab.word_wrap)
 76        console_box = QWidget()
 77        cl = QVBoxLayout(console_box)
 78        cl.setContentsMargins(0, 0, 0, 0)
 79        # (No spacing: macOS buttons already keep some room around themselves,
 80        # for the focus ring, which the layout counts as part of the button.)
 81        cl.setSpacing(0)
 82        # What's shown, when it's one item's output (a test, a file's compile,
 83        # an AI review); hidden for all the output ("Output" is the panel's title).
 84        self.output_title = QLabel()
 85        self.output_title.hide()
 86        # Titles like "AI review of a_very_long_file_name.f90 (model)" wrap
 87        # instead of forcing the panel wider.
 88        self.output_title.setWordWrap(True)
 89        self.output_title.setMinimumWidth(40)
 90        self.output_title.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
 91        self.output_title.setTextInteractionFlags(Qt.TextSelectableByMouse)
 92        # Title alone at the top; the buttons sit below the output.
 93        footer = QHBoxLayout()
 94        footer.setContentsMargins(0, 0, 0, 0)
 95        footer.setSpacing(6)
 96        footer.addStretch(1)
 97        self.show_all_button = QPushButton("Show all output")
 98        self.show_all_button.clicked.connect(self.show_all_output)
 99        self.show_all_button.hide()
100        footer.addWidget(self.show_all_button)
101        self.full_output_button = QPushButton("Full Output…")
102        self.full_output_button.setToolTip("The last command's output was too long to show in full; "
103                                           f"show the file it was saved to in {reveal_label()}")
104        self.full_output_button.clicked.connect(lambda: reveal_in_file_manager(str(self._full_output_path)))
105        self.full_output_button.hide()
106        footer.addWidget(self.full_output_button)
107        clear = QPushButton("Clear")
108        clear.setToolTip("Clear the output")
109        clear.clicked.connect(self.console.clear)
110        footer.addWidget(clear)
111        cl.addWidget(self.output_title)
112        cl.addWidget(self.output_stack, 1)
113        cl.addLayout(footer)
114        return console_box
115
116    def _append(self, text: str, bold: bool = False, colored: bool = False):
117        """Add text to the Output panel; ``colored``: program output, whose
118        terminal color codes are shown as colors (and which is limited to
119        OUTPUT_SHOW_LIMIT characters per command)."""
120        if not colored:
121            self.console_writer.write(text, bold, ansi=False)
122            return
123        if self._full_output is not None:
124            try:
125                self._full_output.write(ansi.strip(text))
126            except (OSError, ValueError):
127                self._full_output = None
128        if not self._hidden_chars and self._shown_chars + len(text) <= OUTPUT_SHOW_LIMIT:
129            self._shown_chars += len(text)
130            if len(text) > MAX_LINE_SHOWN and max(map(len, text.split("\n"))) > MAX_LINE_SHOWN:
131                text = "\n".join(line if len(line) <= MAX_LINE_SHOWN
132                                 else line[:MAX_LINE_SHOWN] + f" … [{len(line) - MAX_LINE_SHOWN} more characters]"
133                                 for line in text.split("\n"))
134            self.console_writer.write(text, bold, ansi=True)
135            return
136        if not self._hidden_chars:
137            self.console_writer.write(
138                f"\n[The output is very long, so the rest isn't shown here (that would freeze the app). "
139                f"The last {OUTPUT_TAIL_LINES} lines are shown when the command finishes, and all of "
140                f"it is saved to {self._full_output_path} (Full Output… below).]\n", True, ansi=False)
141            self.full_output_button.show()
142        self._hidden_chars += len(text)
143        lines = (self._tail_partial + text).split("\n")
144        self._tail_partial = lines.pop()
145        self._hidden_lines += len(lines)
146        self._tail.extend(line[:MAX_LINE_SHOWN] for line in lines)
147
148    def _begin_output(self):
149        """A command is starting: reset the output limit and the saved copy."""
150        self._end_output(show_tail=False)
151        self._decoder.reset()
152        for links in self.output_links:
153            links.forget()  # (files may have come or gone)
154        self._shown_chars = self._hidden_chars = self._hidden_lines = 0
155        self._tail.clear()
156        self._tail_partial = ""
157        self.full_output_button.hide()
158        try:
159            self._full_output = open(self._full_output_path, "w", encoding="utf-8", errors="replace")  # noqa: SIM115
160        except OSError:
161            self._full_output = None
162
163    def _end_output(self, show_tail: bool = True):
164        """The command has finished: show the end of any output that wasn't shown."""
165        if self._full_output is not None:
166            try:
167                self._full_output.close()
168            except OSError:
169                pass
170            self._full_output = None
171        if show_tail and self._hidden_chars:
172            tail = list(self._tail) + ([self._tail_partial] if self._tail_partial else [])
173            skipped = self._hidden_lines + bool(self._tail_partial) - len(tail)
174            self.console_writer.write(
175                f"\n[… {skipped:,} lines ({self._hidden_chars / 1e6:.1f} MB) not shown — "
176                f"full output: {self._full_output_path} …]\n", True, ansi=False)
177            self.console_writer.write("\n".join(tail) + "\n", ansi=True)
178            self._hidden_chars = 0
179
180    def _show_marked_output(self, monitor, name: str):
181        """Show one test's or program's output (or everything when empty)."""
182        if not name:
183            self.show_all_output()
184            return
185        self.build_monitor.clear_selection()
186        other = self.test_monitor if monitor is self.run_monitor else self.run_monitor
187        if other.selected:
188            other.selected = ""
189            other.table.viewport().update()
190        self._show_single_output(name, monitor.output_for(name) or "(no output)")
191
192    def show_test_output(self, name: str):
193        """Show one test's output (or everything when name is empty)."""
194        self._show_marked_output(self.test_monitor, name)
195
196    def show_build_details(self, row: int):
197        """Show one file's compiler messages (or everything when row is -1)."""
198        if row < 0:
199            self.show_all_output()
200            return
201        for monitor in (self.test_monitor, self.run_monitor):
202            if monitor.selected:
203                monitor.selected = ""
204                monitor.table.viewport().update()
205        self._show_single_output(self.build_monitor.details_title(row),
206                                 self.build_monitor.details_text(row))
207
208    def _show_single_output(self, title: str, text: str):
209        self.test_output.clear()
210        self.single_writer.reset()
211        self.single_writer.write(text)
212        self.test_output.moveCursor(QTextCursor.Start)
213        self.output_title.setText(f"<b>{_breakable(html.escape(title))}</b>")
214        self.output_title.show()
215        self.output_stack.setCurrentWidget(self.test_output)
216        self.show_all_button.show()
217
218    def show_all_output(self):
219        self.output_title.hide()
220        self.output_stack.setCurrentWidget(self.console)
221        self.show_all_button.hide()
222        self.build_monitor.clear_selection()
223        for monitor in (self.test_monitor, self.run_monitor):
224            if monitor.selected:
225                monitor.selected = ""
226                monitor.table.viewport().update()

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

def show_test_output(self, name: str):
192    def show_test_output(self, name: str):
193        """Show one test's output (or everything when name is empty)."""
194        self._show_marked_output(self.test_monitor, name)

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

def show_build_details(self, row: int):
196    def show_build_details(self, row: int):
197        """Show one file's compiler messages (or everything when row is -1)."""
198        if row < 0:
199            self.show_all_output()
200            return
201        for monitor in (self.test_monitor, self.run_monitor):
202            if monitor.selected:
203                monitor.selected = ""
204                monitor.table.viewport().update()
205        self._show_single_output(self.build_monitor.details_title(row),
206                                 self.build_monitor.details_text(row))

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

def show_all_output(self):
218    def show_all_output(self):
219        self.output_title.hide()
220        self.output_stack.setCurrentWidget(self.console)
221        self.show_all_button.hide()
222        self.build_monitor.clear_selection()
223        for monitor in (self.test_monitor, self.run_monitor):
224            if monitor.selected:
225                monitor.selected = ""
226                monitor.table.viewport().update()