Edit on GitHub

starplast.console

A console pane, and the stdout capture that feeds it.

The build and the search both report progress by printing, and in a GUI that output goes nowhere -- the user watches a frozen window while the terminal they cannot see fills up. This tees stdout and stderr into a widget without taking them away from the terminal, so running from a shell still behaves normally and running from a launcher is no longer silent.

Capture is deliberately a tee rather than a redirect. Swallowing stderr would hide tracebacks from anyone debugging from a terminal, which is where they are actually read.

Lines that came through logging_util carry their level as a [LEVEL] prefix, and the pane reads it: it colors by level and filters by minimum level, so a warning is findable in a walk that printed four thousand progress lines. Ordinary prints are left levelless rather than being called INFO -- most output here is print, and folding it into a level would make filtering by that level useless.

Lines are appended as they arrive rather than by re-rendering the whole pane. Re-rendering was quadratic in the number of lines, on the one screen whose entire purpose is watching something long run: 5,000 lines meant 5,000 full rebuilds of a 5,000-line document.

MAX_LINES = 5000
LEVEL_COLOR = {'DEBUG': '#7f8c9b', 'INFO': '#5aa9e6', 'WARNING': '#d79a2b', 'ERROR': '#e05561'}
class Tee(PyQt6.QtCore.QObject):

Forwards writes to the original stream and emits them as a signal.

A file-like object handed to sys.stdout is written to from worker threads, so the text is emitted as a queued signal rather than touching a widget directly. Writing to a QTextEdit off the GUI thread is a crash that shows up as an unrelated stack days later.

Tee(stream, is_error: bool = False, parent=None)
def text(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

stream
is_error
def write(self, chunk):

Write to the original stream and emit the text for the pane.

def flush(self):

Flush the original stream, tolerating one that has been closed.

def close(self):

Do NOT close the wrapped stream -- this is a tee, not an owner.

Python's logging shutdown calls close() on whatever it finds at sys.stderr. Without this the interpreter reported "'Tee' object has no attribute 'close'" at exit, and closing the real stream instead would take stderr down for everything still running.

def isatty(self):

False: the pane is not a terminal, and claiming otherwise fills it with escapes.

class ConsolePanel(PyQt6.QtWidgets.QWidget):

Read-only log with a timestamp column, a filter, and copy/clear.

ConsolePanel(parent=None)
filter
errors_only
level
view
def install(self):

Tee stdout and stderr into this pane. Returns the pair for later removal.

def remove(self):

Put the original streams back. Called on close, so a crash does not leave a dead tee.

def append(self, chunk: str, is_error: bool = False):

Add text, splitting on newlines and holding an unterminated tail.

print() arrives as two writes -- the text, then the newline -- so appending each write as its own line would double every line in the pane.

def text(self) -> str:

The currently visible log text.

def copy_all(self):

Copy the visible lines to the clipboard.

def clear(self):

Discard the log, both the pane and the buffer behind it.

def closeEvent(self, ev):

closeEvent(self, a0: QCloseEvent|None)