From 2e63e3bd4225bebedb05ff51d8a75e0778178022 Mon Sep 17 00:00:00 2001 From: Avasam Date: Mon, 29 Jun 2026 19:31:49 -0400 Subject: [PATCH 01/13] First implementation: live logging window TODO: - persist open//closed state as QT setting - Add View > Expand/Shrink/Toggle logs (with shortcut CRTL+L) - What could be moved directly to .ui file? - Look for code simplifications - The user can weirdly change the window height - Add useful logs from the app itself / search for previous fixes where custom logs would've been useful --- res/design.ui | 66 +++++++++++++++++- src/AutoSplit.py | 92 +++++++++++++++++++++++- src/error_messages.py | 136 ++++++++++++++++++------------------ src/log_capture.py | 159 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 382 insertions(+), 71 deletions(-) create mode 100644 src/log_capture.py diff --git a/res/design.ui b/res/design.ui index 0ddab8d1..6b0782eb 100644 --- a/res/design.ui +++ b/res/design.ui @@ -8,19 +8,19 @@ 0 0 786 - 426 + 450 786 - 426 + 450 786 - 426 + 720 @@ -930,6 +930,59 @@ + + + + + 1 + 0 + + + + Last log message. Click to show the full log. + + + + + + + + + QDockWidget::DockWidgetFeature::NoDockWidgetFeatures + + + Log + + + 8 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + Qt::TextInteractionFlag::TextSelectableByKeyboard|Qt::TextInteractionFlag::TextSelectableByMouse + + + + + + View Help @@ -1029,6 +1082,13 @@ + + + ClickableLabel + QLabel +
log_capture
+
+
split_image_folder_input x_spinbox diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 975f8065..966049c4 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -33,6 +33,14 @@ def do_nothing(*_): ... # os.environ.setdefault("QT_DEBUG_PLUGINS", "1") # ruff: disable[E402] # https://github.com/astral-sh/ruff/issues/21423 +# Tee stdout/stderr before the heavy imports below, so import-time warnings and errors +# (e.g. Xlib ResourceWarnings) are mirrored to the log footer too. This must run after the +# platform-specific setup above (which has to happen before any Qt import) but before +# cv2/PySide6/capture_method are imported. +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,19 @@ def do_nothing(*_): ... CHECK_FPS_ITERATIONS = 10 +MAIN_CONTENT_HEIGHT = 404 +"""Fixed height of the main (absolutely-laid-out) content, so the expandable log panel grows the +window downward instead of squashing it. Matches the window's collapsed minimum minus the +menu and status bars.""" +LOG_PANEL_HEIGHT = 250 +"""How tall the expandable log history panel is when shown.""" +LOG_STDERR_COLOR = QtGui.QColor("#c0392b") +"""Color for log lines that came from stderr (warnings, errors, tracebacks).""" +LOG_FOOTER_STYLE = """ +ClickableLabel {{ padding: 1px 16px 1px 6px; color: {color}; }} +ClickableLabel:hover {{ background-color: palette(midlight); }} +""" + class AutoSplit(QMainWindow, design.Ui_MainWindow): # Parse command line args @@ -130,6 +151,9 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): CheckForUpdatesThread: QtCore.QThread | None = None SettingsWidget: settings.Ui_SettingsWidget | None = None + # Last (timestamp, text, is_stderr) shown in the footer, kept so it can be re-rendered. + _last_footer_entry: tuple[str, str, bool] | None = None + def __init__(self): # noqa: PLR0915 super().__init__() @@ -173,6 +197,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: @@ -286,6 +311,71 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) # FUNCTIONS + def _setup_log_footer(self): + """Wire up the clickable log footer and the expandable log history panel.""" + # Pin the main content so the log panel grows the window downward rather than squashing it. + self.central_widget.setFixedHeight(MAIN_CONTENT_HEIGHT) + # Place the footer label so it fills the status bar and is the click target. A top border + # and dropped size grip make the footer read as its own clearly-defined, clickable strip. + self.status_bar.addWidget(self.log_footer_label, 1) + self.status_bar.setSizeGripEnabled(False) + self.status_bar.setContentsMargins(0, 0, 0, 0) # Let the label's padding define the insets. + self.status_bar.setStyleSheet("QStatusBar { border-top: 1px solid palette(mid); }") + # Make the dock read as an inline panel: no title bar, hidden until the footer is clicked. + title_spacer = QWidget() + title_spacer.setFixedHeight(0) # Hide draggable separator + self.log_dock.setTitleBarWidget(title_spacer) + self.setStyleSheet("QMainWindow::separator { height: 0px; width: 0px; }") + self.log_dock.hide() + self.log_footer_label.clicked.connect(self._toggle_log_panel) + + # Surface anything already logged (e.g. the version/PID line) before connecting live. + history = log_capture.LOG_EMITTER.history() + for timestamp, text, is_stderr in history: + self._append_log_line(timestamp, text, is_stderr=is_stderr) + if history: + self._update_log_footer(*history[-1]) + log_capture.LOG_EMITTER.line_logged.connect(self._on_log_line) + + def _toggle_log_panel(self): + show = not self.log_dock.isVisible() + self.log_dock.setVisible(show) + self.resize(self.width(), self.minimumHeight() + (LOG_PANEL_HEIGHT if show else 0)) + self._refresh_log_footer() # Flip the chevron to match the new state. + + def _append_log_line(self, timestamp: str, text: str, *, is_stderr: bool): + 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" + cursor.insertText(f"{prefix}{timestamp} {text}", char_format) + scrollbar = self.log_history_view.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def _update_log_footer(self, timestamp: str, text: str, is_stderr: bool): # noqa: FBT001 + self._last_footer_entry = (timestamp, text, is_stderr) + self._refresh_log_footer() + + def _refresh_log_footer(self): + if self._last_footer_entry is None: + return + timestamp, text, is_stderr = self._last_footer_entry + # The footer is a single line; show the timestamp and first line of (multi-line) entries. + first_line = text.split("\n", 1)[0] + # Footer affordances hinting it expands a log panel: a chevron and hover/border styling. + chevron = "▲" if self.log_dock.isVisible() else "▶" + color = LOG_STDERR_COLOR.name() if is_stderr else "palette(text)" + self.log_footer_label.setStyleSheet(LOG_FOOTER_STYLE.format(color=color)) + self.log_footer_label.set_elided_text(f"{chevron} {timestamp} {first_line}") + + @QtCore.Slot(str, str, bool) + def _on_log_line(self, timestamp: str, text: str, is_stderr: bool): # noqa: FBT001 + self._append_log_line(timestamp, text, is_stderr=is_stderr) + self._update_log_footer(timestamp, text, is_stderr) + 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 3c41479c..0dfb0df1 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -9,13 +9,25 @@ 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 if TYPE_CHECKING: from AutoSplit import AutoSplit +# Keep in sync with README.md#DOWNLOAD_AND_OPEN +WAYLAND_WARNING = """\ +All screen capture method are incompatible with Wayland. Follow this guide to disable it: + \ +https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop""" + +CREATE_NEW_ISSUE_MESSAGE = ( + f"Please create a New Issue at " + + f"github.com/{GITHUB_REPOSITORY}/issues, describe what happened, " + + "and copy & paste the entire error message below" +) + def __exit_program(): # stop main thread (which is probably blocked reading input) via an interrupt signal @@ -23,12 +35,16 @@ def __exit_program(): sys.exit(1) -def set_text_message( +def _set_text_message( message: str, details: str = "", kill_button: str = "", accept_button: str = "", ): + # Also surface the error in the log (console + footer/history) as a single stderr entry. + 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) @@ -53,66 +69,66 @@ def set_text_message( def split_image_directory(): - set_text_message("No Split Image Folder is selected.") + _set_text_message("No Split Image Folder is selected.") def invalid_directory(directory: str): - set_text_message(f"Folder {directory!r} is invalid or does not exist.") + _set_text_message(f"Folder {directory!r} is invalid or does not exist.") def no_split_image(): - set_text_message("Your Split Image Folder should contain at least one Split Image.") + _set_text_message("Your Split Image Folder should contain at least one Split Image.") def image_type(image: str): - set_text_message( + _set_text_message( f"{image!r} is not a valid image file, does not exist, " + "or the full image file path contains a special character." ) def region(): - set_text_message( + _set_text_message( "No region is selected or the Capture Region window is not open. " + "Select a region or load settings while the Capture Region window is open." ) def split_hotkey(): - set_text_message("No split hotkey has been set.") + _set_text_message("No split hotkey has been set.") def pause_hotkey(): - set_text_message( + _set_text_message( "Your Split Image Folder contains an image filename with a pause flag {p}, " + "but no pause hotkey is set." ) def image_validity(image: str = "File"): - set_text_message(f"{image} not a valid image file") + _set_text_message(f"{image} not a valid image file") def alignment_not_matched(): - set_text_message("No area in capture region matched reference image. Alignment failed.") + _set_text_message("No area in capture region matched reference image. Alignment failed.") def no_keyword_image(keyword: str): - set_text_message( + _set_text_message( f"Your Split Image Folder does not contain an image with the keyword {keyword!r}." ) def multiple_keyword_images(keyword: str): - set_text_message(f"Only one image with the keyword {keyword!r} is allowed.") + _set_text_message(f"Only one image with the keyword {keyword!r} is allowed.") def reset_hotkey(): - set_text_message("Your Split Image Folder contains a Reset Image, but no reset hotkey is set.") + _set_text_message("Your Split Image Folder contains a Reset Image, but no reset hotkey is set.") def old_version_settings_file(): - set_text_message( + _set_text_message( "Old version settings file detected. " + "This version allows settings files in .toml format. " + "Starting from v2.0." @@ -120,48 +136,48 @@ def old_version_settings_file(): def invalid_settings(): - set_text_message("Invalid settings file.") + _set_text_message("Invalid settings file.") def invalid_hotkey(hotkey_name: str): - set_text_message(f"Invalid hotkey {hotkey_name!r}") + _set_text_message(f"Invalid hotkey {hotkey_name!r}") def no_settings_file_on_open(): - set_text_message( + _set_text_message( "No settings file found. " + "One can be loaded on open if placed in the same folder as the AutoSplit executable." ) def too_many_settings_files_on_open(): - set_text_message( + _set_text_message( "Too many settings files found. " + "Only one can be loaded on open if placed in the same folder as the AutoSplit executable." ) def check_for_updates(): - set_text_message( + _set_text_message( "An error occurred while attempting to check for updates. Please check your connection." ) def load_start_image(): - set_text_message( + _set_text_message( "Start Image found, but cannot be loaded unless Start hotkey is set. " + "Please set the hotkey, and then click the Reload Start Image button." ) def stdin_lost(): - set_text_message( + _set_text_message( "stdin not supported or lost, external control like LiveSplit integration will not work." ) def already_open(): - set_text_message( + _set_text_message( "An instance of AutoSplit is already running." + "
Are you sure you want to open a another one?", "", @@ -171,7 +187,7 @@ def already_open(): def linux_groups(): - set_text_message( + _set_text_message( "Linux users must ensure they are in the 'tty' and 'input' groups " + "and have write access to '/dev/uinput'. You can run the following commands to do so:", # Keep in sync with README.md and scripts/install.ps1 @@ -188,22 +204,15 @@ def linux_groups(): def linux_uinput(): - set_text_message( + _set_text_message( "Failed to create a device file using `uinput` module. " + "This can happen when running Linux under WSL. " + "Keyboard events have been disabled." ) -# Keep in sync with README.md#DOWNLOAD_AND_OPEN -WAYLAND_WARNING = """\ -All screen capture method are incompatible with Wayland. Follow this guide to disable it: - \ -https://linuxconfig.org/how-to-enable-disable-wayland-on-ubuntu-22-04-desktop""" - - def linux_wayland(): - set_text_message(WAYLAND_WARNING) + _set_text_message(WAYLAND_WARNING) def exception_traceback(exception: BaseException, message: str = ""): @@ -213,18 +222,39 @@ def exception_traceback(exception: BaseException, message: str = ""): + "however, there is no guarantee it will keep working properly. " + CREATE_NEW_ISSUE_MESSAGE ) - set_text_message( + _set_text_message( message, "\n".join(traceback.format_exception(None, exception, exception.__traceback__)), "Close AutoSplit", ) -CREATE_NEW_ISSUE_MESSAGE = ( - f"Please create a New Issue at " - + f"github.com/{GITHUB_REPOSITORY}/issues, describe what happened, " - + "and copy & paste the entire error message below" -) +def tesseract_missing(ocr_split_file_path: str): + _set_text_message( + f"{ocr_split_file_path!r} is an Optical Character Recognition split file " + + "but tesseract couldn't be found." + + f'\nPlease read ' + + f"github.com/{GITHUB_REPOSITORY}#install-tesseract for installation instructions." + ) + + +def ocr_missing_key(ocr_split_file_path: str, missing_key: str): + _set_text_message(f"{ocr_split_file_path!r} is missing an entry for {missing_key!r}") + + +def wrong_ocr_values(ocr_split_file_path: str): + _set_text_message( + f"{ocr_split_file_path!r} has invalid values." + + "\nPlease make sure that `left < right` and `top < bottom`. " + + "Also check for negative values in the 'methods' or 'fps_limit' settings" + ) + + +def invalid_filename_delimiters(filename: str, delimiters: str): + _set_text_message( + f"Split '{filename}' contains invalid parameters. " + + f"'{delimiters}' must not appear more than once." + ) def make_excepthook(autosplit: AutoSplit): @@ -258,31 +288,3 @@ def handle_top_level_exceptions(exception: Exception) -> NoReturn: else: traceback.print_exception(type(exception), exception, exception.__traceback__) sys.exit(1) - - -def tesseract_missing(ocr_split_file_path: str): - set_text_message( - f"{ocr_split_file_path!r} is an Optical Character Recognition split file " - + "but tesseract couldn't be found." - + f'\nPlease read ' - + f"github.com/{GITHUB_REPOSITORY}#install-tesseract for installation instructions." - ) - - -def ocr_missing_key(ocr_split_file_path: str, missing_key: str): - set_text_message(f"{ocr_split_file_path!r} is missing an entry for {missing_key!r}") - - -def wrong_ocr_values(ocr_split_file_path: str): - set_text_message( - f"{ocr_split_file_path!r} has invalid values." - + "\nPlease make sure that `left < right` and `top < bottom`. " - + "Also check for negative values in the 'methods' or 'fps_limit' settings" - ) - - -def invalid_filename_delimiters(filename: str, delimiters: str): - set_text_message( - f"Split '{filename}' contains invalid parameters. " - + f"'{delimiters}' must not appear more than once." - ) diff --git a/src/log_capture.py b/src/log_capture.py new file mode 100644 index 00000000..f121c6da --- /dev/null +++ b/src/log_capture.py @@ -0,0 +1,159 @@ +""" +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 byte-for-byte (this keeps the ``--auto-controlled`` LiveSplit stdout protocol intact), and a +copy of each line is emitted on top. +""" + +from __future__ import annotations + +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 = 5000 +"""A line is more than enough context for a footer, but keep a generous scrollback for the panel.""" +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 = ( + # The `keyboard` library prints this on Linux when AutoSplit isn't in the `input` group. + # AutoSplit already surfaces this properly via `error_messages.linux_groups()`. + "You must be in the 'input' group to access global events", +) +"""Substrings of known-benign dependency messages to hide from the footer/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(str, str, bool) + """Emitted once per completed line: ``(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 + # 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 + with self._lock: + self._history.append((timestamp, text, is_stderr)) + # Queued across threads thanks to Qt's auto-connection: safe to call from any thread. + self.line_logged.emit(timestamp, text, is_stderr) + + def history(self) -> list[LogLine]: + """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) -> int: + 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) -> object: + # 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) From d700646f101e62dc64ee72d25ed24779eec51b18 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 30 Jun 2026 21:01:11 -0400 Subject: [PATCH 02/13] Remeber log footer toggle state --- src/AutoSplit.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 966049c4..9dd03a60 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -326,7 +326,6 @@ def _setup_log_footer(self): title_spacer.setFixedHeight(0) # Hide draggable separator self.log_dock.setTitleBarWidget(title_spacer) self.setStyleSheet("QMainWindow::separator { height: 0px; width: 0px; }") - self.log_dock.hide() self.log_footer_label.clicked.connect(self._toggle_log_panel) # Surface anything already logged (e.g. the version/PID line) before connecting live. @@ -334,16 +333,25 @@ def _setup_log_footer(self): for timestamp, text, is_stderr in history: self._append_log_line(timestamp, text, is_stderr=is_stderr) if history: - self._update_log_footer(*history[-1]) + last_timestamp, last_text, last_is_stderr = history[-1] + self._update_log_footer(last_timestamp, last_text, is_stderr=last_is_stderr) log_capture.LOG_EMITTER.line_logged.connect(self._on_log_line) - def _toggle_log_panel(self): - show = not self.log_dock.isVisible() + # 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) self.resize(self.width(), self.minimumHeight() + (LOG_PANEL_HEIGHT if show else 0)) self._refresh_log_footer() # Flip the chevron to match the new state. - def _append_log_line(self, timestamp: str, text: str, *, is_stderr: bool): + 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, timestamp: str, text: str, is_stderr: bool): # noqa: FBT001 # is_stderr is intrinsic line data, not an arbitrary boolean flag cursor = self.log_history_view.textCursor() cursor.movePosition(QtGui.QTextCursor.MoveOperation.End) char_format = QtGui.QTextCharFormat() @@ -355,7 +363,7 @@ def _append_log_line(self, timestamp: str, text: str, *, is_stderr: bool): scrollbar = self.log_history_view.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) - def _update_log_footer(self, timestamp: str, text: str, is_stderr: bool): # noqa: FBT001 + def _update_log_footer(self, timestamp: str, text: str, is_stderr: bool): # noqa: FBT001 # is_stderr is intrinsic line data, not an arbitrary boolean flag self._last_footer_entry = (timestamp, text, is_stderr) self._refresh_log_footer() @@ -372,8 +380,8 @@ def _refresh_log_footer(self): self.log_footer_label.set_elided_text(f"{chevron} {timestamp} {first_line}") @QtCore.Slot(str, str, bool) - def _on_log_line(self, timestamp: str, text: str, is_stderr: bool): # noqa: FBT001 - self._append_log_line(timestamp, text, is_stderr=is_stderr) + def _on_log_line(self, timestamp: str, text: str, is_stderr: bool): # noqa: FBT001 # is_stderr is intrinsic line data, not an arbitrary boolean flag + self._append_log_line(timestamp, text, is_stderr) self._update_log_footer(timestamp, text, is_stderr) def __browse(self): From 4f1f21801d5a7c3c4347f7827da91d2086460466 Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 30 Jun 2026 21:26:18 -0400 Subject: [PATCH 03/13] Capture relative path, and don't show path in footer preview --- src/AutoSplit.py | 32 ++++++++++++++++++++------------ src/log_capture.py | 6 ++++++ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 9dd03a60..b9272a59 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 @@ -120,6 +121,13 @@ def do_nothing(*_): ... ClickableLabel {{ padding: 1px 16px 1px 6px; color: {color}; }} ClickableLabel:hover {{ background-color: palette(midlight); }} """ +_LOG_LOCATION_PREFIX = re.compile(r"^\S+:\d+:\s+") +"""Matches a leading ``path:lineno:`` (e.g. from a warning) to drop it from the footer preview.""" + + +def _footer_preview(text: str) -> str: + """First line of an (already-relativized) entry; drops a leading ``path:lineno:`` prefix.""" + return _LOG_LOCATION_PREFIX.sub("", text.split("\n", 1)[0]) class AutoSplit(QMainWindow, design.Ui_MainWindow): @@ -330,11 +338,10 @@ def _setup_log_footer(self): # Surface anything already logged (e.g. the version/PID line) before connecting live. history = log_capture.LOG_EMITTER.history() - for timestamp, text, is_stderr in history: - self._append_log_line(timestamp, text, is_stderr=is_stderr) + for log_line in history: + self._append_log_line(log_line) if history: - last_timestamp, last_text, last_is_stderr = history[-1] - self._update_log_footer(last_timestamp, last_text, is_stderr=last_is_stderr) + self._update_log_footer(history[-1]) log_capture.LOG_EMITTER.line_logged.connect(self._on_log_line) # Restore the panel's last expanded/collapsed state. @@ -351,7 +358,8 @@ 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, timestamp: str, text: str, is_stderr: bool): # noqa: FBT001 # is_stderr is intrinsic line data, not an arbitrary boolean flag + 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() @@ -363,16 +371,16 @@ def _append_log_line(self, timestamp: str, text: str, is_stderr: bool): # noqa: scrollbar = self.log_history_view.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) - def _update_log_footer(self, timestamp: str, text: str, is_stderr: bool): # noqa: FBT001 # is_stderr is intrinsic line data, not an arbitrary boolean flag - self._last_footer_entry = (timestamp, text, is_stderr) + def _update_log_footer(self, log_line: log_capture.LogLine): + self._last_footer_entry = log_line self._refresh_log_footer() def _refresh_log_footer(self): if self._last_footer_entry is None: return timestamp, text, is_stderr = self._last_footer_entry - # The footer is a single line; show the timestamp and first line of (multi-line) entries. - first_line = text.split("\n", 1)[0] + # Footer is single-line and shows the message directly (no path prefix). + first_line = _footer_preview(text) # Footer affordances hinting it expands a log panel: a chevron and hover/border styling. chevron = "▲" if self.log_dock.isVisible() else "▶" color = LOG_STDERR_COLOR.name() if is_stderr else "palette(text)" @@ -380,9 +388,9 @@ def _refresh_log_footer(self): self.log_footer_label.set_elided_text(f"{chevron} {timestamp} {first_line}") @QtCore.Slot(str, str, bool) - def _on_log_line(self, timestamp: str, text: str, is_stderr: bool): # noqa: FBT001 # is_stderr is intrinsic line data, not an arbitrary boolean flag - self._append_log_line(timestamp, text, is_stderr) - self._update_log_footer(timestamp, text, is_stderr) + def _on_log_line(self, *log_line: *log_capture.LogLine): + self._append_log_line(log_line) + self._update_log_footer(log_line) def __browse(self): # User selects the file with the split images in it. diff --git a/src/log_capture.py b/src/log_capture.py index f121c6da..3c679fa7 100644 --- a/src/log_capture.py +++ b/src/log_capture.py @@ -10,6 +10,7 @@ from __future__ import annotations +import os import sys import threading from collections import deque @@ -35,6 +36,9 @@ LogLine = tuple[str, str, bool] """A logged line: ``(timestamp, text, is_stderr)``. ``is_stderr`` marks warnings/errors.""" +_WORKING_DIR_PREFIX = f"{os.getcwd()}{os.sep}" +"""Absolute prefix stripped from captured paths to show them relative to the working dir.""" + class LogEmitter(QtCore.QObject): """Thread-safe fan-out of logged lines to the GUI, with a bounded scrollback buffer.""" @@ -52,6 +56,8 @@ def emit_line(self, text: str, *, is_stderr: bool): # 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 (the console already got the absolute ones). + text = text.replace(_WORKING_DIR_PREFIX, "") # 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 From 32a15a934145903bd071a482f8404cb07f79fe3e Mon Sep 17 00:00:00 2001 From: Avasam Date: Tue, 30 Jun 2026 21:32:00 -0400 Subject: [PATCH 04/13] Add action View >Toggle Logs (with shortcut CRTL+L) --- res/design.ui | 18 ++++++++++++++++++ src/AutoSplit.py | 1 + 2 files changed, 19 insertions(+) diff --git a/res/design.ui b/res/design.ui index 6b0782eb..8b77b4f1 100644 --- a/res/design.ui +++ b/res/design.ui @@ -927,7 +927,14 @@ + + + View + + + + @@ -1081,6 +1088,17 @@ QAction::MenuRole::AboutQtRole
+ + + Toggle Logs + + + Ctrl+L + + + Qt::ShortcutContext::ApplicationShortcut + + diff --git a/src/AutoSplit.py b/src/AutoSplit.py index b9272a59..575fb1d5 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -233,6 +233,7 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.split_image_folder_input.setText("No Folder Selected") # 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) From 57758934fad563ce953e82e10829371182d23aa5 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 1 Jul 2026 13:08:37 -0400 Subject: [PATCH 05/13] Move some things to .ui files --- res/design.ui | 21 +++++++++++++++++++++ src/AutoSplit.py | 21 +++++---------------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/res/design.ui b/res/design.ui index 8b77b4f1..45e41974 100644 --- a/res/design.ui +++ b/res/design.ui @@ -35,7 +35,22 @@ :/resources/icon.ico:/resources/icon.ico + + QMainWindow::separator { height: 0px; width: 0px; } + + + + 0 + 404 + + + + + 16777215 + 404 + + @@ -938,6 +953,12 @@ + + false + + + QStatusBar { border-top: 1px solid palette(mid); } + diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 575fb1d5..ecff5d51 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -109,10 +109,6 @@ def do_nothing(*_): ... CHECK_FPS_ITERATIONS = 10 -MAIN_CONTENT_HEIGHT = 404 -"""Fixed height of the main (absolutely-laid-out) content, so the expandable log panel grows the -window downward instead of squashing it. Matches the window's collapsed minimum minus the -menu and status bars.""" LOG_PANEL_HEIGHT = 250 """How tall the expandable log history panel is when shown.""" LOG_STDERR_COLOR = QtGui.QColor("#c0392b") @@ -322,19 +318,11 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) def _setup_log_footer(self): """Wire up the clickable log footer and the expandable log history panel.""" - # Pin the main content so the log panel grows the window downward rather than squashing it. - self.central_widget.setFixedHeight(MAIN_CONTENT_HEIGHT) - # Place the footer label so it fills the status bar and is the click target. A top border - # and dropped size grip make the footer read as its own clearly-defined, clickable strip. + # 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.setSizeGripEnabled(False) self.status_bar.setContentsMargins(0, 0, 0, 0) # Let the label's padding define the insets. - self.status_bar.setStyleSheet("QStatusBar { border-top: 1px solid palette(mid); }") - # Make the dock read as an inline panel: no title bar, hidden until the footer is clicked. - title_spacer = QWidget() - title_spacer.setFixedHeight(0) # Hide draggable separator - self.log_dock.setTitleBarWidget(title_spacer) - self.setStyleSheet("QMainWindow::separator { height: 0px; width: 0px; }") + # An empty title bar (collapses to no height) makes the dock read as an inline panel. + self.log_dock.setTitleBarWidget(QWidget()) self.log_footer_label.clicked.connect(self._toggle_log_panel) # Surface anything already logged (e.g. the version/PID line) before connecting live. @@ -383,7 +371,8 @@ def _refresh_log_footer(self): # Footer is single-line and shows the message directly (no path prefix). first_line = _footer_preview(text) # Footer affordances hinting it expands a log panel: a chevron and hover/border styling. - chevron = "▲" if self.log_dock.isVisible() else "▶" + # Not using isVisible()) as it's incorrect during startup restore, before window is shown + chevron = "▶" if self.log_dock.isHidden() else "▲" color = LOG_STDERR_COLOR.name() if is_stderr else "palette(text)" self.log_footer_label.setStyleSheet(LOG_FOOTER_STYLE.format(color=color)) self.log_footer_label.set_elided_text(f"{chevron} {timestamp} {first_line}") From 5f0c4dd18c598275532363a01d79116b860a1c33 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 1 Jul 2026 13:42:54 -0400 Subject: [PATCH 06/13] fix resizable window height --- res/design.ui | 2 +- src/AutoSplit.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/res/design.ui b/res/design.ui index 45e41974..b0fc2d22 100644 --- a/res/design.ui +++ b/res/design.ui @@ -8,7 +8,7 @@ 0 0 786 - 450 + 700 diff --git a/src/AutoSplit.py b/src/AutoSplit.py index ecff5d51..7c5606ba 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -158,6 +158,9 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): # Last (timestamp, text, is_stderr) shown in the footer, kept so it can be re-rendered. _last_footer_entry: tuple[str, str, bool] | None = None + # Window height with the log panel collapsed; captured from the .ui to fix the height per state. + _collapsed_height = 0 + def __init__(self): # noqa: PLR0915 super().__init__() @@ -318,6 +321,7 @@ def _update_checker_widget_signal_slot(latest_version: str, check_on_open: bool) def _setup_log_footer(self): """Wire up the clickable log footer and the expandable log history panel.""" + self._collapsed_height = self.minimumHeight() # 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. @@ -340,7 +344,8 @@ def _setup_log_footer(self): def _set_log_panel_visible(self, show: bool): # noqa: FBT001 # boolean value setter, not an arbitrary flag self.log_dock.setVisible(show) - self.resize(self.width(), self.minimumHeight() + (LOG_PANEL_HEIGHT if show else 0)) + # Fix the height per state so it can't be dragged to over-expand or hide content. + self.setFixedHeight(self._collapsed_height + (LOG_PANEL_HEIGHT if show else 0)) self._refresh_log_footer() # Flip the chevron to match the new state. def _toggle_log_panel(self): From 51f1ac3bd3477f163089e5e03c5d46a4e273b65c Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 1 Jul 2026 14:04:03 -0400 Subject: [PATCH 07/13] More ui simplifications --- res/design.ui | 23 ++++++++++++++++++----- src/AutoSplit.py | 11 ++--------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/res/design.ui b/res/design.ui index b0fc2d22..537f49b4 100644 --- a/res/design.ui +++ b/res/design.ui @@ -527,6 +527,9 @@ 22 + + No Folder Selected + true @@ -953,13 +956,23 @@ + + QStatusBar { border-top: 1px solid palette(mid); } +ClickableLabel { padding: 1px 16px 1px 6px; } +ClickableLabel:hover { background-color: palette(midlight); } + false - - QStatusBar { border-top: 1px solid palette(mid); } - + + + 0 + 0 + 100 + 30 + + 1 @@ -987,13 +1000,13 @@ - 0 + 3 0 - 0 + 3 0 diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 7c5606ba..6cf5d300 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -113,10 +113,6 @@ def do_nothing(*_): ... """How tall the expandable log history panel is when shown.""" LOG_STDERR_COLOR = QtGui.QColor("#c0392b") """Color for log lines that came from stderr (warnings, errors, tracebacks).""" -LOG_FOOTER_STYLE = """ -ClickableLabel {{ padding: 1px 16px 1px 6px; color: {color}; }} -ClickableLabel:hover {{ background-color: palette(midlight); }} -""" _LOG_LOCATION_PREFIX = re.compile(r"^\S+:\d+:\s+") """Matches a leading ``path:lineno:`` (e.g. from a warning) to drop it from the footer preview.""" @@ -228,9 +224,6 @@ def _show_error_signal_slot(error_message_box: Callable[..., object]): self.update_auto_control = AutoControlledThread(self) self.update_auto_control.start() - # split image folder line edit text - self.split_image_folder_input.setText("No Folder Selected") - # Connecting menu actions self.action_toggle_logs.triggered.connect(self._toggle_log_panel) self.action_view_help.triggered.connect(view_help) @@ -378,8 +371,8 @@ def _refresh_log_footer(self): # 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 "▲" - color = LOG_STDERR_COLOR.name() if is_stderr else "palette(text)" - self.log_footer_label.setStyleSheet(LOG_FOOTER_STYLE.format(color=color)) + 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}") @QtCore.Slot(str, str, bool) From 7520adb5e75f1f2d72c5abf995279059c8ee2f2e Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 1 Jul 2026 14:15:35 -0400 Subject: [PATCH 08/13] Monospace and aligned log font --- src/AutoSplit.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 6cf5d300..75709c12 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -320,6 +320,10 @@ def _setup_log_footer(self): 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()) + # Monospace so timestamps and indented continuation lines align (system fixed-width font). + self.log_history_view.setFont( + QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.SystemFont.FixedFont) + ) self.log_footer_label.clicked.connect(self._toggle_log_panel) # Surface anything already logged (e.g. the version/PID line) before connecting live. @@ -354,7 +358,9 @@ def _append_log_line(self, log_line: log_capture.LogLine): 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" - cursor.insertText(f"{prefix}{timestamp} {text}", char_format) + # 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()) From 506229406fdef3d669422d78e05996b0c1ec3b12 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 1 Jul 2026 15:29:46 -0400 Subject: [PATCH 09/13] More self-review --- src/AutoSplit.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 75709c12..e889488a 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -117,11 +117,6 @@ def do_nothing(*_): ... """Matches a leading ``path:lineno:`` (e.g. from a warning) to drop it from the footer preview.""" -def _footer_preview(text: str) -> str: - """First line of an (already-relativized) entry; drops a leading ``path:lineno:`` prefix.""" - return _LOG_LOCATION_PREFIX.sub("", text.split("\n", 1)[0]) - - class AutoSplit(QMainWindow, design.Ui_MainWindow): # Parse command line args is_auto_controlled = "--auto-controlled" in sys.argv @@ -151,8 +146,8 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): CheckForUpdatesThread: QtCore.QThread | None = None SettingsWidget: settings.Ui_SettingsWidget | None = None - # Last (timestamp, text, is_stderr) shown in the footer, kept so it can be re-rendered. - _last_footer_entry: tuple[str, str, bool] | None = None + _last_footer_entry: log_capture.LogLine | None = None + """Last (timestamp, text, is_stderr) shown in the footer, kept so it can be re-rendered.""" # Window height with the log panel collapsed; captured from the .ui to fix the height per state. _collapsed_height = 0 @@ -373,7 +368,8 @@ def _refresh_log_footer(self): return timestamp, text, is_stderr = self._last_footer_entry # Footer is single-line and shows the message directly (no path prefix). - first_line = _footer_preview(text) + # First line of an (already-relativized) entry; drops a leading ``path:lineno:`` prefix. + first_line = _LOG_LOCATION_PREFIX.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 "▲" From 8af4577310c87cb8734bef8e58f04a8f422dfcc4 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 1 Jul 2026 16:24:20 -0400 Subject: [PATCH 10/13] Some simplifications --- src/AutoSplit.py | 10 ++++------ src/log_capture.py | 17 ++++++++++------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index e889488a..20b7a33f 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -327,7 +327,10 @@ def _setup_log_footer(self): self._append_log_line(log_line) if history: self._update_log_footer(history[-1]) - log_capture.LOG_EMITTER.line_logged.connect(self._on_log_line) + # 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( @@ -377,11 +380,6 @@ def _refresh_log_footer(self): self.log_footer_label.setStyleSheet(stderr_color) self.log_footer_label.set_elided_text(f"{chevron} {timestamp} {first_line}") - @QtCore.Slot(str, str, bool) - def _on_log_line(self, *log_line: *log_capture.LogLine): - self._append_log_line(log_line) - self._update_log_footer(log_line) - def __browse(self): # User selects the file with the split images in it. new_split_image_directory = QFileDialog.getExistingDirectory( diff --git a/src/log_capture.py b/src/log_capture.py index 3c679fa7..5d0029a2 100644 --- a/src/log_capture.py +++ b/src/log_capture.py @@ -43,8 +43,10 @@ class LogEmitter(QtCore.QObject): """Thread-safe fan-out of logged lines to the GUI, with a bounded scrollback buffer.""" - line_logged = QtCore.Signal(str, str, bool) - """Emitted once per completed line: ``(timestamp, text, is_stderr)``.""" + # `Signal(LogLine)` can't be used: PySide rejects the subscripted `tuple[...]` alias and + # silently registers a zero-arg signal, so the payload type is documented here instead. + line_logged = QtCore.Signal(tuple) + """Emitted once per completed line, carrying a `LogLine`: `(timestamp, text, is_stderr)`.""" def __init__(self): super().__init__() @@ -61,12 +63,13 @@ def emit_line(self, text: str, *, is_stderr: bool): # 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((timestamp, text, is_stderr)) + self._history.append(log_line) # Queued across threads thanks to Qt's auto-connection: safe to call from any thread. - self.line_logged.emit(timestamp, text, is_stderr) + self.line_logged.emit(log_line) - def history(self) -> list[LogLine]: + def history(self): """Snapshot of the lines logged so far, oldest first.""" with self._lock: return list(self._history) @@ -93,7 +96,7 @@ def __init__(self, real: TextIO | None, emitter: LogEmitter, *, is_stderr: bool) self._is_stderr = is_stderr self._partial = "" - def write(self, text: str) -> int: + def write(self, text: str): if self._real is not None: self._real.write(text) self._partial += text @@ -109,7 +112,7 @@ def flush(self): if self._real is not None: self._real.flush() - def __getattr__(self, name: str) -> object: + 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"] From 1cf0304bcd2276ef796c83fd299d42448b4da7c3 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 1 Jul 2026 18:39:09 -0400 Subject: [PATCH 11/13] More comment simplifications --- src/AutoSplit.py | 22 ++++++++++++---------- src/error_messages.py | 2 +- src/log_capture.py | 40 +++++++++++++++++----------------------- 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 20b7a33f..8edb8761 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -34,10 +34,9 @@ def do_nothing(*_): ... # os.environ.setdefault("QT_DEBUG_PLUGINS", "1") # ruff: disable[E402] # https://github.com/astral-sh/ruff/issues/21423 -# Tee stdout/stderr before the heavy imports below, so import-time warnings and errors -# (e.g. Xlib ResourceWarnings) are mirrored to the log footer too. This must run after the -# platform-specific setup above (which has to happen before any Qt import) but before -# cv2/PySide6/capture_method are imported. + +# 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() @@ -112,9 +111,10 @@ def do_nothing(*_): ... LOG_PANEL_HEIGHT = 250 """How tall the expandable log history panel is when shown.""" LOG_STDERR_COLOR = QtGui.QColor("#c0392b") -"""Color for log lines that came from stderr (warnings, errors, tracebacks).""" -_LOG_LOCATION_PREFIX = re.compile(r"^\S+:\d+:\s+") -"""Matches a leading ``path:lineno:`` (e.g. from a warning) to drop it from the footer preview.""" +"""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): @@ -149,8 +149,10 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): _last_footer_entry: log_capture.LogLine | None = None """Last (timestamp, text, is_stderr) shown in the footer, kept so it can be re-rendered.""" - # Window height with the log panel collapsed; captured from the .ui to fix the height per state. _collapsed_height = 0 + """ + Window height with the log panel collapsed; captured from the .ui to fix the height per state. + """ def __init__(self): # noqa: PLR0915 super().__init__() @@ -371,8 +373,8 @@ def _refresh_log_footer(self): return timestamp, text, is_stderr = self._last_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.sub("", text.split("\n", 1)[0]) + # 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 "▲" diff --git a/src/error_messages.py b/src/error_messages.py index 0dfb0df1..40461308 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -41,7 +41,7 @@ def _set_text_message( kill_button: str = "", accept_button: str = "", ): - # Also surface the error in the log (console + footer/history) as a single stderr entry. + # 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") diff --git a/src/log_capture.py b/src/log_capture.py index 5d0029a2..b33b81ee 100644 --- a/src/log_capture.py +++ b/src/log_capture.py @@ -1,10 +1,10 @@ """ -Capture everything written to the console (``print``, ``warnings``, ``logging``'s last-resort +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 byte-for-byte (this keeps the ``--auto-controlled`` LiveSplit stdout protocol intact), and a +The real `sys.stdout` / `sys.stderr` are *teed*, never replaced: writes still reach the original +streams byte-for-byte (this keeps the `--auto-controlled` LiveSplit stdout protocol intact), and a copy of each line is emitted on top. """ @@ -19,32 +19,27 @@ from PySide6 import QtCore, QtGui, QtWidgets -LOG_HISTORY_MAX_LINES = 5000 -"""A line is more than enough context for a footer, but keep a generous scrollback for the panel.""" +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 = ( - # The `keyboard` library prints this on Linux when AutoSplit isn't in the `input` group. - # AutoSplit already surfaces this properly via `error_messages.linux_groups()`. + # 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 known-benign dependency messages to hide from the footer/history because they would -mislead users. These are still written to the real console: only the in-app log is filtered""" +"""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.""" - -_WORKING_DIR_PREFIX = f"{os.getcwd()}{os.sep}" -"""Absolute prefix stripped from captured paths to show them relative to the working dir.""" +"""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.""" - # `Signal(LogLine)` can't be used: PySide rejects the subscripted `tuple[...]` alias and - # silently registers a zero-arg signal, so the payload type is documented here instead. line_logged = QtCore.Signal(tuple) """Emitted once per completed line, carrying a `LogLine`: `(timestamp, text, is_stderr)`.""" @@ -58,15 +53,14 @@ def emit_line(self, text: str, *, is_stderr: bool): # 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 (the console already got the absolute ones). - text = text.replace(_WORKING_DIR_PREFIX, "") + # 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) - # Queued across threads thanks to Qt's auto-connection: safe to call from any thread. self.line_logged.emit(log_line) def history(self): @@ -82,12 +76,12 @@ def history(self): 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 + 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 + 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. + Separate `write` calls (e.g. successive `print` calls) stay separate entries. """ def __init__(self, real: TextIO | None, emitter: LogEmitter, *, is_stderr: bool): @@ -123,7 +117,7 @@ def __getattr__(self, name: str): def install(): """ - Wrap ``sys.stdout`` and ``sys.stderr`` so console output is mirrored to `LOG_EMITTER`. + 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. """ @@ -134,7 +128,7 @@ def install(): 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``. + Used as the log footer; promoted from `QLabel` in `design.ui`. """ clicked = QtCore.Signal() From 984eb73c12f19dde11cab3303d7119c293748385 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 1 Jul 2026 18:52:44 -0400 Subject: [PATCH 12/13] Happy with current state. Validate log height --- src/AutoSplit.py | 5 ++--- src/log_capture.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 8edb8761..59c47ecf 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -317,13 +317,12 @@ def _setup_log_footer(self): 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()) - # Monospace so timestamps and indented continuation lines align (system fixed-width font). - self.log_history_view.setFont( + self.log_history_view.setFont( # Use the system's monospace font. QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.SystemFont.FixedFont) ) self.log_footer_label.clicked.connect(self._toggle_log_panel) - # Surface anything already logged (e.g. the version/PID line) before connecting live. + # Include logs already emitted before connecting live. history = log_capture.LOG_EMITTER.history() for log_line in history: self._append_log_line(log_line) diff --git a/src/log_capture.py b/src/log_capture.py index b33b81ee..56f9ba79 100644 --- a/src/log_capture.py +++ b/src/log_capture.py @@ -4,7 +4,7 @@ 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 byte-for-byte (this keeps the `--auto-controlled` LiveSplit stdout protocol intact), and a +streams as-is (keeping `--auto-controlled` stdout protocol intact), and a formatted copy of each line is emitted on top. """ From 1a7abd01ce3934a967c611af2e9f854e11e028ac Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 1 Jul 2026 20:38:21 -0400 Subject: [PATCH 13/13] Set a height I prefer --- res/design.ui | 26 +++++++++++++++++++++++--- src/AutoSplit.py | 38 ++++++++++++++++++++++++-------------- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/res/design.ui b/res/design.ui index 537f49b4..62eb6630 100644 --- a/res/design.ui +++ b/res/design.ui @@ -8,7 +8,7 @@ 0 0 786 - 700 + 598 @@ -20,7 +20,7 @@ 786 - 720 + 598 @@ -988,11 +988,15 @@ ClickableLabel:hover { background-color: palette(midlight); } + + QDockWidget::title { padding: 6px; } + QDockWidget::DockWidgetFeature::NoDockWidgetFeatures - Log + 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 @@ -1013,9 +1017,25 @@ ClickableLabel:hover { background-color: palette(midlight); } + + + monospace + + true + + 3 +4 +5 +6 +7 +8 +9 +10 +11 + Qt::TextInteractionFlag::TextSelectableByKeyboard|Qt::TextInteractionFlag::TextSelectableByMouse diff --git a/src/AutoSplit.py b/src/AutoSplit.py index 59c47ecf..0a6b6b24 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -108,8 +108,6 @@ def do_nothing(*_): ... CHECK_FPS_ITERATIONS = 10 -LOG_PANEL_HEIGHT = 250 -"""How tall the expandable log history panel is when shown.""" 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""" @@ -146,14 +144,6 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): CheckForUpdatesThread: QtCore.QThread | None = None SettingsWidget: settings.Ui_SettingsWidget | None = None - _last_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. - """ - def __init__(self): # noqa: PLR0915 super().__init__() @@ -309,9 +299,24 @@ 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. @@ -320,6 +325,9 @@ def _setup_log_footer(self): 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. @@ -341,7 +349,7 @@ def _setup_log_footer(self): 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 + (LOG_PANEL_HEIGHT if show else 0)) + 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): @@ -364,13 +372,13 @@ def _append_log_line(self, log_line: log_capture.LogLine): scrollbar.setValue(scrollbar.maximum()) def _update_log_footer(self, log_line: log_capture.LogLine): - self._last_footer_entry = log_line + self._last_log_footer_entry = log_line self._refresh_log_footer() def _refresh_log_footer(self): - if self._last_footer_entry is None: + if self._last_log_footer_entry is None: return - timestamp, text, is_stderr = self._last_footer_entry + 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]) @@ -381,6 +389,8 @@ def _refresh_log_footer(self): 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(