diff --git a/res/design.ui b/res/design.ui index 767bb23c..62eb6630 100644 --- a/res/design.ui +++ b/res/design.ui @@ -8,19 +8,19 @@ 0 0 786 - 426 + 598 786 - 426 + 450 786 - 426 + 598 @@ -35,7 +35,22 @@ :/resources/icon.ico:/resources/icon.ico + + QMainWindow::separator { height: 0px; width: 0px; } + + + + 0 + 404 + + + + + 16777215 + 404 + + @@ -930,9 +945,105 @@ + + + View + + + + + + + QStatusBar { border-top: 1px solid palette(mid); } +ClickableLabel { padding: 1px 16px 1px 6px; } +ClickableLabel:hover { background-color: palette(midlight); } + + + false + + + + + 0 + 0 + 100 + 30 + + + + + 1 + 0 + + + + Last log message. Click to show the full log. + + + + + + + + + QDockWidget::title { padding: 6px; } + + + QDockWidget::DockWidgetFeature::NoDockWidgetFeatures + + + 1 (docker header can't be hidden in editor, removed at runtime) +2 (line count height is approximate, making header appear about 2 lines) + + + 8 + + + + + 3 + + + 0 + + + 3 + + + 0 + + + + + + monospace + + + + true + + + 3 +4 +5 +6 +7 +8 +9 +10 +11 + + + Qt::TextInteractionFlag::TextSelectableByKeyboard|Qt::TextInteractionFlag::TextSelectableByMouse + + + + + + View Help @@ -1031,7 +1142,25 @@ QAction::MenuRole::AboutQtRole + + + Toggle Logs + + + Ctrl+L + + + Qt::ShortcutContext::ApplicationShortcut + + + + + ClickableLabel + QLabel +
log_capture
+
+
split_image_folder_input x_spinbox diff --git a/src/AutoSplit.py b/src/AutoSplit.py index b4882c6d..0a6b6b24 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import os +import re import sys import warnings @@ -33,6 +34,13 @@ def do_nothing(*_): ... # os.environ.setdefault("QT_DEBUG_PLUGINS", "1") # ruff: disable[E402] # https://github.com/astral-sh/ruff/issues/21423 + +# Tee stdout/stderr as soon as possible, so import-time warnings and errors are also caught. +# This must run after the platform-specific setup above (which has to happen before any Qt import). +import log_capture + +log_capture.install() + import signal from collections.abc import Callable from copy import deepcopy @@ -43,7 +51,7 @@ def do_nothing(*_): ... import cv2 from PySide6 import QtCore, QtGui from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication, QFileDialog, QLabel, QMainWindow, QMessageBox +from PySide6.QtWidgets import QApplication, QFileDialog, QLabel, QMainWindow, QMessageBox, QWidget import error_messages import user_profile @@ -100,6 +108,12 @@ def do_nothing(*_): ... CHECK_FPS_ITERATIONS = 10 +LOG_STDERR_COLOR = QtGui.QColor("#c0392b") +"""Color for log lines that came from stderr (warnings, errors, tracebacks). +Source: Pomegranate from https://flatuicolors.com/palette/defo""" +LOG_LOCATION_PREFIX_RE = re.compile(r"^\S+:\d+:\s+") +"""Matches a leading `path:lineno:` (e.g. from a warning) to drop it from the footer preview.""" + class AutoSplit(QMainWindow, design.Ui_MainWindow): # Parse command line args @@ -173,6 +187,7 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): f"AutoSplit v{AUTOSPLIT_VERSION}" + (" (externally controlled)" if self.is_auto_controlled else "") ) + self._setup_log_footer() # Hotkeys need to be initialized to be passed as thread arguments in hotkeys.py for hotkey in HOTKEYS: @@ -197,6 +212,7 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.update_auto_control.start() # Connecting menu actions + self.action_toggle_logs.triggered.connect(self._toggle_log_panel) self.action_view_help.triggered.connect(view_help) self.action_about.triggered.connect(lambda: open_about(self)) self.action_about_qt.triggered.connect(about_qt) @@ -283,6 +299,98 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) # FUNCTIONS + # region Log footer panel + _last_log_footer_entry: log_capture.LogLine | None = None + """Last (timestamp, text, is_stderr) shown in the footer, kept so it can be re-rendered.""" + + _collapsed_height = 0 + """ + Window height with the log panel collapsed; captured from the .ui to fix the height per state. + """ + + _log_panel_height = 0 + """Window growth when the panel opens; derived from the .ui (expanded - collapsed height).""" + + def _setup_log_footer(self): + """Wire up the clickable log footer and the expandable log history panel.""" + # Both come from the .ui: collapsed = minimumSize height, panel = designed (expanded) + # geometry height minus that. Captured before setFixedHeight() overwrites minimumHeight(). + self._collapsed_height = self.minimumHeight() + self._log_panel_height = self.height() - self._collapsed_height + # The status bar doesn't add() its .ui child, and stretch isn't expressible there. + self.status_bar.addWidget(self.log_footer_label, 1) + self.status_bar.setContentsMargins(0, 0, 0, 0) # Let the label's padding define the insets. + # An empty title bar (collapses to no height) makes the dock read as an inline panel. + self.log_dock.setTitleBarWidget(QWidget()) + self.log_history_view.setFont( # Use the system's monospace font. + QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.SystemFont.FixedFont) + ) + # Drop the .ui's Designer-only placeholders (kept there as layout/line-count notes). + self.log_history_view.clear() + self.log_dock.setWindowTitle("") + self.log_footer_label.clicked.connect(self._toggle_log_panel) + + # Include logs already emitted before connecting live. + history = log_capture.LOG_EMITTER.history() + for log_line in history: + self._append_log_line(log_line) + if history: + self._update_log_footer(history[-1]) + # Each live line goes into the panel and updates the footer + # (append first, so the footer's single-line preview reflects the line that was just added). + log_capture.LOG_EMITTER.line_logged.connect(self._append_log_line) + log_capture.LOG_EMITTER.line_logged.connect(self._update_log_footer) + + # Restore the panel's last expanded/collapsed state. + self._set_log_panel_visible( + show=cast("bool", user_profile.QT_SETTINGS.value("log_panel_visible", False, type=bool)) + ) + + def _set_log_panel_visible(self, show: bool): # noqa: FBT001 # boolean value setter, not an arbitrary flag + self.log_dock.setVisible(show) + # Fix the height per state so it can't be dragged to over-expand or hide content. + self.setFixedHeight(self._collapsed_height + (self._log_panel_height if show else 0)) + self._refresh_log_footer() # Flip the chevron to match the new state. + + def _toggle_log_panel(self): + self._set_log_panel_visible(not self.log_dock.isVisible()) + user_profile.QT_SETTINGS.setValue("log_panel_visible", self.log_dock.isVisible()) + + def _append_log_line(self, log_line: log_capture.LogLine): + timestamp, text, is_stderr = log_line + cursor = self.log_history_view.textCursor() + cursor.movePosition(QtGui.QTextCursor.MoveOperation.End) + char_format = QtGui.QTextCharFormat() + if is_stderr: + char_format.setForeground(LOG_STDERR_COLOR) + # Newline before each entry (not after) so there is no trailing blank line. + prefix = "" if self.log_history_view.document().isEmpty() else "\n" + # Align a multi-line entry's continuation lines under the text (past the timestamp). + body = text.replace("\n", "\n" + " " * (len(timestamp) + 1)) + cursor.insertText(f"{prefix}{timestamp} {body}", char_format) + scrollbar = self.log_history_view.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def _update_log_footer(self, log_line: log_capture.LogLine): + self._last_log_footer_entry = log_line + self._refresh_log_footer() + + def _refresh_log_footer(self): + if self._last_log_footer_entry is None: + return + timestamp, text, is_stderr = self._last_log_footer_entry + # Footer is single-line and shows the message directly (no path prefix). + # First line of an (already-relativized) entry; drops a leading `path:lineno:` prefix. + first_line = LOG_LOCATION_PREFIX_RE.sub("", text.split("\n", 1)[0]) + # Footer affordances hinting it expands a log panel: a chevron and hover/border styling. + # Not using isVisible()) as it's incorrect during startup restore, before window is shown + chevron = "▶" if self.log_dock.isHidden() else "▲" + stderr_color = f"color: {LOG_STDERR_COLOR.name()};" if is_stderr else "" + self.log_footer_label.setStyleSheet(stderr_color) + self.log_footer_label.set_elided_text(f"{chevron} {timestamp} {first_line}") + + # endregion + def __browse(self): # User selects the file with the split images in it. new_split_image_directory = QFileDialog.getExistingDirectory( diff --git a/src/error_messages.py b/src/error_messages.py index 5a07e2f9..40461308 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -9,7 +9,7 @@ from types import TracebackType from typing import TYPE_CHECKING, NoReturn -from PySide6 import QtCore, QtWidgets +from PySide6 import QtCore, QtGui, QtWidgets from utils import FROZEN, GITHUB_REPOSITORY @@ -41,6 +41,10 @@ def _set_text_message( kill_button: str = "", accept_button: str = "", ): + # Also surface the error message in the logs + plain_message = QtGui.QTextDocumentFragment.fromHtml(message).toPlainText() + sys.stderr.write(f"{plain_message}\n{details}\n" if details else f"{plain_message}\n") + message_box = QtWidgets.QMessageBox() message_box.setWindowTitle("Error") message_box.setTextFormat(QtCore.Qt.TextFormat.RichText) diff --git a/src/log_capture.py b/src/log_capture.py new file mode 100644 index 00000000..56f9ba79 --- /dev/null +++ b/src/log_capture.py @@ -0,0 +1,162 @@ +""" +Capture everything written to the console (`print`, `warnings`, `logging`'s last-resort +handler, uncaught tracebacks) without suppressing it, and re-broadcast each completed line as a Qt +signal so the GUI can surface it in the log footer. + +The real `sys.stdout` / `sys.stderr` are *teed*, never replaced: writes still reach the original +streams as-is (keeping `--auto-controlled` stdout protocol intact), and a formatted +copy of each line is emitted on top. +""" + +from __future__ import annotations + +import os +import sys +import threading +from collections import deque +from datetime import datetime +from typing import TextIO, cast, override + +from PySide6 import QtCore, QtGui, QtWidgets + +LOG_HISTORY_MAX_LINES = 256 * 2**4 +"""Arbitrary scrollback cap. Memory-bounded, but more than enough for a log session.""" +TIMESTAMP_FORMAT = "%H:%M:%S.%f" +"""Time-only (no date) timestamp prefixed to each displayed log line. `%f` is microseconds; the last +3 digits are trimmed off at format time to leave milliseconds.""" + +EXCLUDED_SUBSTRINGS = ( + # False-positive: `keyboard` prints this on first import even when the user IS in the `input` + # group. A proper check is already done and shown through `error_messages.linux_groups()`. + "You must be in the 'input' group to access global events", +) +"""Substrings of entries to hide from the log history because they would mislead users. +These are still written to the real console: only the in-app log is filtered""" + +LogLine = tuple[str, str, bool] +"""A logged line: `(timestamp, text, is_stderr)`. `is_stderr` marks warnings/errors.""" + + +class LogEmitter(QtCore.QObject): + """Thread-safe fan-out of logged lines to the GUI, with a bounded scrollback buffer.""" + + line_logged = QtCore.Signal(tuple) + """Emitted once per completed line, carrying a `LogLine`: `(timestamp, text, is_stderr)`.""" + + def __init__(self): + super().__init__() + self._lock = threading.Lock() + self._history: deque[LogLine] = deque(maxlen=LOG_HISTORY_MAX_LINES) + + def emit_line(self, text: str, *, is_stderr: bool): + # The console already received this (the tee writes before emitting); only the in-app log + # filters out blank lines (e.g. a lone "\n" write) and known-benign noise. + if not text or any(excluded in text for excluded in EXCLUDED_SUBSTRINGS): + return + # Store paths relative to the working dir by stripping the working dir path + text = text.replace(f"{os.getcwd()}{os.sep}", "") + # Stamp at capture time (on the writing thread) so the time reflects when it was logged. + # Naive local wall-clock time is intentional for a log footer (DTZ005: no tz wanted). + timestamp = datetime.now().strftime(TIMESTAMP_FORMAT)[:-3] + ":" # noqa: DTZ005 + log_line: LogLine = (timestamp, text, is_stderr) + with self._lock: + self._history.append(log_line) + self.line_logged.emit(log_line) + + def history(self): + """Snapshot of the lines logged so far, oldest first.""" + with self._lock: + return list(self._history) + + +LOG_EMITTER = LogEmitter() +"""Process-wide singleton. The GUI connects to this; the tee streams write to it.""" + + +class _TeeStream: + """ + A text stream that forwards to a real stream (if any) and mirrors completed output to the + emitter. `real` may be `None` in frozen `--noconsole` builds, in which case there is no + console to write to but the footer still receives the output. + + A single `write` is treated as one log entry, even when it spans several lines (e.g. a + multi-line warning), so it gets a single timestamp and renders as one multi-line block. + Separate `write` calls (e.g. successive `print` calls) stay separate entries. + """ + + def __init__(self, real: TextIO | None, emitter: LogEmitter, *, is_stderr: bool): + self._real = real + self._emitter = emitter + self._is_stderr = is_stderr + self._partial = "" + + def write(self, text: str): + if self._real is not None: + self._real.write(text) + self._partial += text + # Emit everything up to the last newline as one entry (keeping internal newlines), and hold + # any trailing partial line until it's completed by a later write. + newline_index = self._partial.rfind("\n") + if newline_index != -1: + self._emitter.emit_line(self._partial[:newline_index], is_stderr=self._is_stderr) + self._partial = self._partial[newline_index + 1 :] + return len(text) + + def flush(self): + if self._real is not None: + self._real.flush() + + def __getattr__(self, name: str): + # Delegate everything else (encoding, fileno, isatty, errors, ...) to the real stream so the + # tee is indistinguishable from it. `_real` is always set in __init__, so no recursion. + real = self.__dict__["_real"] + if real is None: + raise AttributeError(name) + return getattr(real, name) + + +def install(): + """ + Wrap `sys.stdout` and `sys.stderr` so console output is mirrored to `LOG_EMITTER`. + + Call once, as early as possible, so startup output is captured. + """ + sys.stdout = cast("TextIO", _TeeStream(sys.stdout, LOG_EMITTER, is_stderr=False)) + sys.stderr = cast("TextIO", _TeeStream(sys.stderr, LOG_EMITTER, is_stderr=True)) + + +class ClickableLabel(QtWidgets.QLabel): + """ + A `QLabel` that emits `clicked` when pressed and right-elides its text to fit its current width. + Used as the log footer; promoted from `QLabel` in `design.ui`. + """ + + clicked = QtCore.Signal() + + def __init__(self, parent: QtWidgets.QWidget | None = None): + super().__init__(parent) + self._full_text = "" + self.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.PointingHandCursor)) + + def set_elided_text(self, text: str): + self._full_text = text + self._update_elision() + + def _update_elision(self): + metrics = self.fontMetrics() + # Elide to the content rect (excludes padding) so left and right padding are kept. + width = self.contentsRect().width() + super().setText( + metrics.elidedText(self._full_text, QtCore.Qt.TextElideMode.ElideRight, width) + ) + + @override + def resizeEvent(self, event: QtGui.QResizeEvent): + # The label re-elides in its own resizeEvent, when its width is already up to date. + super().resizeEvent(event) + self._update_elision() + + @override + def mousePressEvent(self, ev: QtGui.QMouseEvent): + self.clicked.emit() + super().mousePressEvent(ev)