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 @@
+
+
+
+
+ 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
+
+
+
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)