diff --git a/CHANGELOG.md b/CHANGELOG.md index 24cbeee..8dde810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.2 - 2026-04-05 + +- Moved directory scanning into a subprocess worker for safer cancellation +- Added scan worker guardrails including priority, memory, and timeout controls +- Improved scan error reporting with dialogs and optional `--debug` terminal output +- Disabled the default worker memory cap to avoid false positives on normal scans + ## 0.1.1 - 2026-04-05 - Improved scanner responsiveness and stop/cancel behavior diff --git a/README.md b/README.md index 222f577..5088625 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ Print the version: sview --version ``` +Run with scanner debug output in the terminal: + +```bash +sview --debug +``` + ## Configuration On first launch, `sview` writes a user config file to: @@ -40,5 +46,29 @@ On first launch, `sview` writes a user config file to: ~/.config/sview/config.json ``` +It also stores lightweight UI state in: + +```bash +~/.config/sview/ui_state.json +``` + This file controls default file and sequence handlers, including custom commands for double-click actions. + +It also contains scanner worker safety settings such as process priority and +memory limit: + +```json +{ + "scanner": { + "worker": { + "nice_increment": 15, + "memory_limit_mb": null, + "timeout_seconds": 30 + } + } +} +``` + +`memory_limit_mb` is opt-in. Leave it as `null` unless you specifically want a hard +address-space cap on the worker process. diff --git a/pyproject.toml b/pyproject.toml index 983e3b1..2a1422b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sview" -version = "0.1.1" +version = "0.1.2" description = "Sequence-aware filesystem browser" readme = "README.md" requires-python = ">=3.8" diff --git a/sview.png b/sview.png index 84f1423..e3c0573 100644 Binary files a/sview.png and b/sview.png differ diff --git a/sview/__init__.py b/sview/__init__.py index 8eccb03..e961ee1 100644 --- a/sview/__init__.py +++ b/sview/__init__.py @@ -35,4 +35,4 @@ on usability and a clean interface. """ -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/sview/config.py b/sview/config.py index 904d081..91cee63 100644 --- a/sview/config.py +++ b/sview/config.py @@ -44,6 +44,7 @@ CONFIG_DIR = Path.home() / ".config" / "sview" CONFIG_PATH = CONFIG_DIR / "config.json" +UI_STATE_PATH = CONFIG_DIR / "ui_state.json" DEFAULT_REPOSITORY_URL = "https://github.com/rsgalloway/sview" @@ -53,36 +54,61 @@ class HandlerConfig: command: str = "" +@dataclass +class ScanWorkerConfig: + nice_increment: int = 15 + memory_limit_mb: int | None = None + timeout_seconds: int | None = 30 + + @dataclass class AppConfig: file_handler: HandlerConfig sequence_handler: HandlerConfig + scan_worker: ScanWorkerConfig @classmethod def default(cls) -> "AppConfig": return cls( file_handler=HandlerConfig(mode="system"), sequence_handler=HandlerConfig(mode="expand"), + scan_worker=ScanWorkerConfig(), ) @classmethod def load(cls) -> "AppConfig": if not CONFIG_PATH.exists(): config = cls.default() - config.save() + try: + config.save() + except OSError: + pass return config try: data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): config = cls.default() - config.save() + try: + config.save() + except OSError: + pass return config handlers = data.get("handlers", {}) + scanner = data.get("scanner", {}) + worker = scanner.get("worker", {}) + memory_limit_mb = cls._load_memory_limit(worker) + needs_save = ( + "scanner" not in data + or "worker" not in scanner + or "nice_increment" not in worker + or "memory_limit_mb" not in worker + or "timeout_seconds" not in worker + ) file_handler = handlers.get("file", {}) sequence_handler = handlers.get("sequence", {}) - return cls( + config = cls( file_handler=HandlerConfig( mode=file_handler.get("mode", "system"), command=file_handler.get("command", ""), @@ -91,7 +117,23 @@ def load(cls) -> "AppConfig": mode=sequence_handler.get("mode", "expand"), command=sequence_handler.get("command", ""), ), + scan_worker=ScanWorkerConfig( + nice_increment=cls._coerce_int(worker.get("nice_increment"), 15), + memory_limit_mb=memory_limit_mb, + timeout_seconds=( + None + if "timeout_seconds" in worker + and worker.get("timeout_seconds") is None + else cls._coerce_int(worker.get("timeout_seconds"), 30) + ), + ), ) + if needs_save: + try: + config.save() + except OSError: + pass + return config def save(self) -> None: CONFIG_DIR.mkdir(parents=True, exist_ok=True) @@ -105,10 +147,53 @@ def save(self) -> None: "mode": self.sequence_handler.mode, "command": self.sequence_handler.command, }, - } + }, + "scanner": { + "worker": { + "nice_increment": self.scan_worker.nice_increment, + "memory_limit_mb": self.scan_worker.memory_limit_mb, + "timeout_seconds": self.scan_worker.timeout_seconds, + } + }, } CONFIG_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + @staticmethod + def _load_memory_limit(worker: dict[str, object]) -> int | None: + if "memory_limit_mb" not in worker: + return None + + value = worker.get("memory_limit_mb") + if value is None: + return None + + memory_limit_mb = AppConfig._coerce_optional_int(value, None) + if memory_limit_mb is None: + return None + # Treat the old default memory cap from early 0.1.2 development as "unset" + # because RLIMIT_AS proved too blunt for normal scans. + if memory_limit_mb == 2048: + return None + return memory_limit_mb + + @staticmethod + def _coerce_int(value: object, default: int) -> int: + try: + if value is None: + return default + return int(value) + except (TypeError, ValueError): + return default + + @staticmethod + def _coerce_optional_int(value: object, default: int | None) -> int | None: + try: + if value is None: + return default + return int(value) + except (TypeError, ValueError): + return default + def build_command(command_template: str, path: str) -> list[str]: return shlex.split(command_template.format(path=path)) @@ -128,3 +213,18 @@ def get_repository_url() -> str: return url.strip() return DEFAULT_REPOSITORY_URL + + +def load_ui_state() -> dict[str, object]: + if not UI_STATE_PATH.exists(): + return {} + try: + data = json.loads(UI_STATE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def save_ui_state(state: dict[str, object]) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + UI_STATE_PATH.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8") diff --git a/sview/controller.py b/sview/controller.py index 270e8d3..ec619d7 100644 --- a/sview/controller.py +++ b/sview/controller.py @@ -107,6 +107,17 @@ def apply_scan_result(self, result: ScanResult) -> list[BrowserItem]: self._refresh_current_items() return self.current_items + def update_sequence_metadata( + self, item_path: str, size_bytes: int, modified_time: float + ) -> BrowserItem | None: + for item in self._grouped_items: + if item.path != item_path: + continue + item.size_bytes = size_bytes + item.modified_time = modified_time + return item + return None + def _refresh_current_items(self) -> None: self._current_items = ( self._grouped_items if self._grouped_view else self._raw_items diff --git a/sview/model.py b/sview/model.py index d909c06..5ead5f3 100644 --- a/sview/model.py +++ b/sview/model.py @@ -62,3 +62,42 @@ class BrowserItem: @property def missing_count(self) -> int: return len(self.missing or []) + + def to_dict(self) -> dict[str, object]: + return { + "path": self.path, + "item_type": self.item_type.value, + "name": self.name, + "display_name": self.display_name, + "frame_range": self.frame_range, + "pad": self.pad, + "count": self.count, + "missing": list(self.missing) if self.missing is not None else None, + "size_bytes": self.size_bytes, + "modified_time": self.modified_time, + "child_paths": list(self.child_paths) + if self.child_paths is not None + else None, + } + + @classmethod + def from_dict(cls, data: dict[str, object]) -> BrowserItem: + return cls( + path=str(data["path"]), + item_type=ItemType(str(data["item_type"])), + name=str(data["name"]), + display_name=str(data["display_name"]), + frame_range=str(data["frame_range"]) + if data["frame_range"] is not None + else None, + pad=str(data["pad"]) if data["pad"] is not None else None, + count=int(data["count"]), + missing=[int(value) for value in data["missing"]] + if data["missing"] is not None + else None, + size_bytes=int(data["size_bytes"]), + modified_time=float(data["modified_time"]), + child_paths=[str(value) for value in data["child_paths"]] + if data["child_paths"] is not None + else None, + ) diff --git a/sview/qt/app.py b/sview/qt/app.py index 0acc022..545fe89 100644 --- a/sview/qt/app.py +++ b/sview/qt/app.py @@ -51,6 +51,7 @@ def main() -> int: print(__version__) return 0 + debug = _wants_debug(sys.argv[1:]) app = QApplication(sys.argv) try: ensure_pyseq_available() @@ -63,13 +64,13 @@ def main() -> int: app.setStyle("Fusion") _apply_dark_theme(app) initial_path = _parse_initial_path(sys.argv[1:]) - window = MainWindow(initial_path=initial_path) + window = MainWindow(initial_path=initial_path, debug=debug) window.show() return app.exec() def _parse_initial_path(args: list[str]) -> str | None: - filtered_args = [arg for arg in args if arg not in {"--version", "-V"}] + filtered_args = [arg for arg in args if arg not in {"--version", "-V", "--debug"}] if not filtered_args: return None candidate = Path(filtered_args[0]).expanduser() @@ -80,97 +81,116 @@ def _wants_version(args: list[str]) -> bool: return any(arg in {"--version", "-V"} for arg in args) +def _wants_debug(args: list[str]) -> bool: + return "--debug" in args + + def _apply_dark_theme(app: QApplication) -> None: palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor("#232528")) - palette.setColor(QPalette.ColorRole.WindowText, QColor("#e4e6e8")) - palette.setColor(QPalette.ColorRole.Base, QColor("#191b1e")) - palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#202327")) - palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#191b1e")) - palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#f2f3f5")) - palette.setColor(QPalette.ColorRole.Text, QColor("#e4e6e8")) - palette.setColor(QPalette.ColorRole.Button, QColor("#2c2f34")) - palette.setColor(QPalette.ColorRole.ButtonText, QColor("#edf0f2")) - palette.setColor(QPalette.ColorRole.Highlight, QColor("#454b53")) + palette.setColor(QPalette.ColorRole.Window, QColor("#14191e")) + palette.setColor(QPalette.ColorRole.WindowText, QColor("#dbe2e8")) + palette.setColor(QPalette.ColorRole.Base, QColor("#11161b")) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#171d22")) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#11161b")) + palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#f2f5f8")) + palette.setColor(QPalette.ColorRole.Text, QColor("#dbe2e8")) + palette.setColor(QPalette.ColorRole.Button, QColor("#1f262d")) + palette.setColor(QPalette.ColorRole.ButtonText, QColor("#edf2f7")) + palette.setColor(QPalette.ColorRole.Highlight, QColor("#313b44")) palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff")) - palette.setColor(QPalette.ColorRole.PlaceholderText, QColor("#808790")) + palette.setColor(QPalette.ColorRole.PlaceholderText, QColor("#75818d")) app.setPalette(palette) app.setStyleSheet( """ QWidget { font-size: 12px; } - QMainWindow, QTreeView, QTableWidget, QLineEdit, QPushButton, QLabel { - background-color: #25282c; - color: #e4e6e8; + QTreeView, QTableWidget, QLineEdit, QPushButton, QLabel, QListWidget { + background-color: #1e252c; + color: #dbe2e8; + } + QMainWindow, QWidget#mainContent { + background-color: #13191f; + color: #dbe2e8; } QHeaderView::section { - background-color: #2d3136; - color: #d9dde1; + background-color: #1e252c; + color: #d4dde4; padding: 4px 6px; border: 0; - border-right: 1px solid #3d4248; + border-right: 1px solid #29333c; } - QTreeView, QTableWidget, QLineEdit { - background-color: #1d2024; - border: 1px solid #30343a; + QTreeView, QTableWidget, QLineEdit, QListWidget { + background-color: #161d23; + border: 1px solid #222c34; border-radius: 3px; } + QTreeView { + background-color: #171f26; + } QScrollBar:vertical { - background: #1b1e22; + background: #10161a; width: 10px; margin: 2px; } QScrollBar::handle:vertical { - background: #474c53; + background: #36424c; min-height: 28px; border-radius: 4px; } QScrollBar::handle:vertical:hover { - background: #5a6068; + background: #414f5a; } QScrollBar:horizontal { - background: #1b1e22; + background: #10161a; height: 10px; margin: 2px; } QScrollBar::handle:horizontal { - background: #474c53; + background: #36424c; min-width: 28px; border-radius: 4px; } QScrollBar::handle:horizontal:hover { - background: #5a6068; + background: #414f5a; } QScrollBar::add-line, QScrollBar::sub-line, QScrollBar::add-page, QScrollBar::sub-page { background: transparent; border: none; } QTableWidget::item:selected, QTreeView::item:selected { - background-color: #40454d; + background-color: #2c353d; color: white; } QPushButton { - background-color: #2f3338; - border: 1px solid #393e45; + background-color: #20282f; + border: 1px solid #29333c; border-radius: 3px; padding: 4px 9px; min-height: 24px; } QPushButton:checked { - background-color: #3b4047; - border-color: #474d55; + background-color: #2b3640; + border-color: #34414d; } QPushButton:disabled { - color: #727881; - background-color: #272b30; + color: #697581; + background-color: #1a2127; + } + QToolButton { + background: transparent; + border: 0; + color: #83919e; + } + QToolButton:hover { + color: #d6dde4; } QStatusBar { - background-color: #1d2024; - color: #bfc4ca; + background-color: #141b20; + color: #b6c1ca; } QSplitter::handle { - background-color: #4a4f56; + background-color: #36414a; width: 1px; } QLabel#inspectorTitle { @@ -178,9 +198,26 @@ def _apply_dark_theme(app: QApplication) -> None: font-weight: 600; } QLabel#inspectorSubtitle { - color: #959ca5; + color: #8998a5; margin-bottom: 6px; } + QWidget#iconCard { + background-color: #1c232a; + border: 1px solid #252f37; + border-radius: 6px; + } + QWidget#iconCard:hover { + background-color: #212a32; + border-color: #2f3b45; + } + QLabel#iconCardTitle { + font-size: 13px; + font-weight: 600; + color: #eef3f7; + } + QLabel#iconCardSubtitle { + color: #99a8b3; + } """ ) diff --git a/sview/qt/icon_view.py b/sview/qt/icon_view.py new file mode 100644 index 0000000..58c6120 --- /dev/null +++ b/sview/qt/icon_view.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026, Ryan Galloway (ryan@rsgalloway.com) +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# - Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# - Neither the name of the software nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# ----------------------------------------------------------------------------- + +""" +Contains an icon-based center-pane browser for sview. +""" + +from __future__ import annotations + +from datetime import datetime + +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QStyle, + QVBoxLayout, + QWidget, +) + +from sview.model import BrowserItem, ItemType + + +class ContentsIconView(QListWidget): + context_requested = Signal(object, object) + CARD_WIDTH = 270 + CARD_HEIGHT = 88 + + def __init__(self) -> None: + super().__init__() + self.setViewMode(QListWidget.ViewMode.IconMode) + self.setResizeMode(QListWidget.ResizeMode.Adjust) + self.setMovement(QListWidget.Movement.Static) + self.setWrapping(True) + self.setSelectionMode(QListWidget.SelectionMode.SingleSelection) + self.setSpacing(14) + self.setIconSize(QSize(44, 44)) + self.setGridSize(QSize(self.CARD_WIDTH, self.CARD_HEIGHT)) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._emit_context_request) + + def set_items(self, items: list[BrowserItem]) -> None: + self.clear() + for item in items: + list_item = QListWidgetItem() + list_item.setData(Qt.ItemDataRole.UserRole, item) + list_item.setSizeHint(QSize(self.CARD_WIDTH - 12, self.CARD_HEIGHT - 10)) + self.addItem(list_item) + self.setItemWidget(list_item, self._build_card(item)) + + def current_browser_item(self) -> BrowserItem | None: + current = self.currentItem() + if current is None: + return None + return current.data(Qt.ItemDataRole.UserRole) + + def update_item(self, item: BrowserItem) -> None: + for row in range(self.count()): + list_item = self.item(row) + if list_item is None: + continue + browser_item = list_item.data(Qt.ItemDataRole.UserRole) + if browser_item is None or browser_item.path != item.path: + continue + list_item.setData(Qt.ItemDataRole.UserRole, item) + self.setItemWidget(list_item, self._build_card(item)) + return + + def _emit_context_request(self, position) -> None: + item = self.itemAt(position) + browser_item = item.data(Qt.ItemDataRole.UserRole) if item is not None else None + if item is not None: + self.setCurrentItem(item) + self.context_requested.emit(browser_item, self.viewport().mapToGlobal(position)) + + def _icon(self, item: BrowserItem): + if item.item_type is ItemType.DIRECTORY: + return self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon) + if item.item_type is ItemType.SEQUENCE: + return self.style().standardIcon( + QStyle.StandardPixmap.SP_FileDialogDetailedView + ) + return self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon) + + def _build_card(self, item: BrowserItem) -> QWidget: + card = QWidget() + card.setObjectName("iconCard") + layout = QHBoxLayout(card) + layout.setContentsMargins(14, 12, 14, 12) + layout.setSpacing(12) + + icon_label = QLabel() + icon_label.setPixmap(self._icon(item).pixmap(self.iconSize())) + icon_label.setFixedSize(48, 48) + icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + name_label = QLabel(item.display_name) + name_label.setObjectName("iconCardTitle") + name_label.setWordWrap(True) + + subtitle_label = QLabel(self._secondary_text(item)) + subtitle_label.setObjectName("iconCardSubtitle") + subtitle_label.setWordWrap(True) + + text_layout = QVBoxLayout() + text_layout.setContentsMargins(0, 0, 0, 0) + text_layout.setSpacing(2) + text_layout.addWidget(name_label) + text_layout.addWidget(subtitle_label) + text_layout.addStretch(1) + + layout.addWidget(icon_label, 0, Qt.AlignmentFlag.AlignTop) + layout.addLayout(text_layout, 1) + return card + + def _secondary_text(self, item: BrowserItem) -> str: + if item.item_type is ItemType.DIRECTORY: + return self._format_mtime(item.modified_time) or "Folder" + if item.item_type is ItemType.SEQUENCE: + parts = [item.frame_range or f"{item.count} frames"] + size_text = self._format_size(item.size_bytes) + if size_text: + parts.append(size_text) + modified_text = self._format_mtime(item.modified_time) + if modified_text: + parts.append(modified_text) + return " · ".join(parts) + parts = [] + size_text = self._format_size(item.size_bytes) + if size_text: + parts.append(size_text) + modified_text = self._format_mtime(item.modified_time) + if modified_text: + parts.append(modified_text) + return " · ".join(parts) or "File" + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes <= 0: + return "" + units = ["B", "KB", "MB", "GB", "TB"] + size = float(size_bytes) + unit = units[0] + for unit in units: + if size < 1024.0 or unit == units[-1]: + break + size /= 1024.0 + return f"{size:.1f} {unit}" + + @staticmethod + def _format_mtime(timestamp: float) -> str: + if timestamp <= 0: + return "" + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M") diff --git a/sview/qt/inspector.py b/sview/qt/inspector.py index cbb4c22..540ec15 100644 --- a/sview/qt/inspector.py +++ b/sview/qt/inspector.py @@ -70,6 +70,8 @@ def __init__(self) -> None: self.copy_path_button = QPushButton("Copy Path") self.copy_pattern_button = QPushButton("Copy Pattern") + self.find_missing_button = QPushButton("Find Missing") + self.get_size_button = QPushButton("Get Size") self.expand_button = QPushButton("Expand Sequence") self.close_button = QPushButton() self.close_button.setIcon( @@ -110,6 +112,10 @@ def __init__(self) -> None: button_row.addWidget(self.copy_path_button) button_row.addWidget(self.copy_pattern_button) layout.addLayout(button_row) + metadata_row = QHBoxLayout() + metadata_row.addWidget(self.find_missing_button) + metadata_row.addWidget(self.get_size_button) + layout.addLayout(metadata_row) layout.addWidget(self.expand_button) self.clear_details() @@ -130,6 +136,8 @@ def clear_details(self) -> None: label.setText("-") self.copy_path_button.setEnabled(False) self.copy_pattern_button.setEnabled(False) + self.find_missing_button.setEnabled(False) + self.get_size_button.setEnabled(False) self.expand_button.setEnabled(False) self.expand_button.setText("Expand Sequence") @@ -150,6 +158,8 @@ def set_item(self, item: BrowserItem) -> None: self.copy_path_button.setEnabled(True) self.copy_pattern_button.setEnabled(item.item_type is ItemType.SEQUENCE) + self.find_missing_button.setEnabled(item.item_type is ItemType.SEQUENCE) + self.get_size_button.setEnabled(item.item_type is ItemType.SEQUENCE) self.expand_button.setEnabled(item.item_type is ItemType.SEQUENCE) self.expand_button.setText( "Expand Sequence" @@ -194,4 +204,6 @@ def _format_size(size_bytes: int) -> str: def _format_modified(timestamp: float) -> str: from datetime import datetime + if timestamp <= 0: + return "-" return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M") diff --git a/sview/qt/main_window.py b/sview/qt/main_window.py index ce9091f..c8336bd 100644 --- a/sview/qt/main_window.py +++ b/sview/qt/main_window.py @@ -35,18 +35,17 @@ from __future__ import annotations -from pathlib import Path -from queue import Empty, Queue import json +from pathlib import Path import subprocess import sys -from threading import Event, Thread -from PySide6.QtCore import QTimer, Qt, QUrl +from PySide6.QtCore import QProcess, QTimer, Qt, QUrl from PySide6.QtGui import QAction, QCloseEvent, QDesktopServices, QGuiApplication from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, + QLabel, QLineEdit, QMainWindow, QMenu, @@ -54,30 +53,44 @@ QProgressBar, QPushButton, QSplitter, + QStackedWidget, QStyle, + QToolButton, QVBoxLayout, QWidget, ) from sview import __version__ -from sview.config import AppConfig, build_command, get_repository_url +from sview.config import ( + AppConfig, + build_command, + get_repository_url, + load_ui_state, + save_ui_state, +) from sview.controller import BrowserController from sview.model import BrowserItem, ItemType +from sview.qt.icon_view import ContentsIconView from sview.qt.inspector import InspectorPanel from sview.qt.table import ContentsTable from sview.qt.tree import DirectoryTree -from sview.scanner import DirectoryScanner, ScanCancelled, ScanResult +from sview.scanner import ScanResult class MainWindow(QMainWindow): + SIDEBAR_WIDTH = 280 + def __init__( self, controller: BrowserController | None = None, initial_path: str | Path | None = None, + debug: bool = False, ) -> None: super().__init__() self._controller = controller or BrowserController() self._config = AppConfig.load() + self._ui_state = load_ui_state() + self._debug = debug self._initial_path = ( self._normalize_path(initial_path) if initial_path is not None @@ -88,10 +101,12 @@ def __init__( self._history_index = -1 self._pending_request: tuple[str, bool] | None = None self._active_request: tuple[str, bool] | None = None - self._load_thread: Thread | None = None - self._load_cancel_event: Event | None = None - self._load_queue: Queue[tuple[str, int, object]] = Queue() - self._load_token = 0 + self._load_process: QProcess | None = None + self._load_stdout_buffer = bytearray() + self._load_stderr_buffer = "" + self._load_stderr_partial = "" + self._load_cancelled = False + self._load_timed_out = False self.setWindowTitle("sview") self.resize(1400, 800) @@ -100,16 +115,25 @@ def __init__( self._filter_input.setPlaceholderText("Search") self._table = ContentsTable() + self._icon_view = ContentsIconView() + self._center_stack = QStackedWidget() self._inspector = InspectorPanel() + self._tree_title = QLabel("Folders") + self._tree_toggle = QToolButton() self._tree = DirectoryTree(self._initial_path) self._main_splitter: QSplitter | None = None + self._sidebar_expanded = bool(self._ui_state.get("sidebar_expanded", True)) + self._sidebar_restore_width = ( + self._coerce_int(self._ui_state.get("sidebar_width")) or self.SIDEBAR_WIDTH + ) self._progress_timer = QTimer(self) - self._progress_timer.setInterval(50) - self._progress_timer.timeout.connect(self._process_worker_messages) self._busy_timer = QTimer(self) self._busy_timer.setInterval(30) self._busy_timer.timeout.connect(self._advance_busy_indicator) + self._scan_timeout_timer = QTimer(self) + self._scan_timeout_timer.setSingleShot(True) + self._scan_timeout_timer.timeout.connect(self._handle_scan_timeout) self._busy_value = 0 self._busy_direction = 1 @@ -164,21 +188,24 @@ def _build_toolbar(self) -> None: ) self._refresh_button.setToolTip("Refresh") self._refresh_button.setFixedWidth(32) - self._group_toggle = QPushButton() - self._group_toggle.setIcon( + self._content_mode_toggle = QPushButton() + self._content_mode_toggle.setIcon( self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView) ) - self._group_toggle.setToolTip("Sequence View") - self._group_toggle.setCheckable(True) - self._group_toggle.setChecked(True) - self._group_toggle.setFixedWidth(32) - self._raw_toggle = QPushButton() - self._raw_toggle.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon) - ) - self._raw_toggle.setToolTip("File View") - self._raw_toggle.setCheckable(True) - self._raw_toggle.setFixedWidth(32) + self._content_mode_toggle.setToolTip("Sequence View") + self._content_mode_toggle.setCheckable(True) + self._content_mode_toggle.setChecked(True) + self._content_mode_toggle.setFixedWidth(32) + self._layout_mode_toggle = QPushButton() + self._layout_mode_toggle.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView) + ) + self._layout_mode_toggle.setToolTip("Detail View") + self._layout_mode_toggle.setCheckable(True) + self._layout_mode_toggle.setChecked( + self._ui_state.get("center_view", "details") != "icons" + ) + self._layout_mode_toggle.setFixedWidth(32) self._stop_button = QPushButton() self._stop_button.setIcon( self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop) @@ -226,8 +253,9 @@ def _build_layout(self) -> None: toolbar_row.addWidget(self._open_button) toolbar_row.addWidget(self._refresh_button) toolbar_row.addSpacing(6) - toolbar_row.addWidget(self._group_toggle) - toolbar_row.addWidget(self._raw_toggle) + toolbar_row.addWidget(self._content_mode_toggle) + toolbar_row.addSpacing(6) + toolbar_row.addWidget(self._layout_mode_toggle) toolbar_row.addWidget(self._stop_button) toolbar_row.addWidget(self._progress_bar) toolbar_row.addStretch(1) @@ -236,18 +264,62 @@ def _build_layout(self) -> None: root_layout.addLayout(toolbar_row) splitter = QSplitter(Qt.Orientation.Horizontal) - splitter.addWidget(self._tree) - splitter.addWidget(self._table) + sidebar = QWidget() + sidebar.setMinimumWidth(0) + sidebar.setMaximumWidth(self.SIDEBAR_WIDTH) + sidebar_layout = QVBoxLayout(sidebar) + sidebar_layout.setContentsMargins(0, 0, 0, 0) + sidebar_layout.setSpacing(6) + sidebar_header = QHBoxLayout() + sidebar_header.setContentsMargins(2, 0, 2, 0) + sidebar_header.addWidget(self._tree_title) + sidebar_header.addStretch(1) + self._tree_toggle.setCheckable(True) + self._tree_toggle.setChecked(self._sidebar_expanded) + self._tree_toggle.setAutoRaise(True) + self._tree_toggle.setFixedSize(18, 18) + self._tree_toggle.setArrowType( + Qt.ArrowType.LeftArrow + if self._sidebar_expanded + else Qt.ArrowType.RightArrow + ) + self._tree_toggle.setToolTip( + "Collapse folders" if self._sidebar_expanded else "Expand folders" + ) + sidebar_header.addWidget(self._tree_toggle) + sidebar_layout.addLayout(sidebar_header) + sidebar_layout.addWidget(self._tree, 1) + self._center_stack.addWidget(self._table) + self._center_stack.addWidget(self._icon_view) + self._center_stack.setCurrentWidget( + self._icon_view if not self._layout_mode_toggle.isChecked() else self._table + ) + + splitter.addWidget(sidebar) + splitter.addWidget(self._center_stack) splitter.addWidget(self._inspector) - splitter.setStretchFactor(0, 2) + splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 5) splitter.setStretchFactor(2, 3) self._main_splitter = splitter - splitter.setSizes([260, 900, 0]) + splitter.setCollapsible(0, True) + splitter.setSizes( + self._coerce_splitter_sizes( + self._ui_state.get("main_splitter_sizes"), + [self._sidebar_restore_width, 900, 0], + ) + ) + if not self._sidebar_expanded: + self._apply_sidebar_state(False) root_layout.addWidget(splitter, 1) self.setCentralWidget(center) + center.setObjectName("mainContent") self.statusBar().showMessage("Ready") + width = self._coerce_int(self._ui_state.get("window_width")) + height = self._coerce_int(self._ui_state.get("window_height")) + if width and height: + self.resize(width, height) def _connect_signals(self) -> None: self._open_action.triggered.connect(self._choose_directory) @@ -266,17 +338,27 @@ def _connect_signals(self) -> None: self._home_button.clicked.connect(self._go_home) self._up_button.clicked.connect(self._go_up) self._stop_button.clicked.connect(self._cancel_scan) - self._group_toggle.toggled.connect(self._toggle_grouped_view) - self._raw_toggle.toggled.connect(self._toggle_raw_view) + self._content_mode_toggle.toggled.connect(self._toggle_content_mode) self._filter_input.textChanged.connect(self._apply_filter) self._table.itemSelectionChanged.connect(self._sync_inspector) self._table.itemDoubleClicked.connect(self._activate_selected_item) self._table.context_requested.connect(self._show_item_context_menu) + self._icon_view.itemSelectionChanged.connect(self._sync_inspector) + self._icon_view.itemActivated.connect(self._activate_selected_item) + self._icon_view.context_requested.connect(self._show_item_context_menu) self._tree.selectionModel().selectionChanged.connect( self._handle_tree_selection ) + self._layout_mode_toggle.toggled.connect(self._toggle_layout_mode) + self._tree_toggle.toggled.connect(self._toggle_sidebar) self._inspector.copy_path_button.clicked.connect(self._copy_selected_path) self._inspector.copy_pattern_button.clicked.connect(self._copy_selected_pattern) + self._inspector.find_missing_button.clicked.connect( + self._find_missing_for_selected_sequence + ) + self._inspector.get_size_button.clicked.connect( + self._get_size_for_selected_sequence + ) self._inspector.expand_button.clicked.connect( self._expand_or_collapse_selected_sequence ) @@ -284,10 +366,10 @@ def _connect_signals(self) -> None: def closeEvent(self, event: QCloseEvent) -> None: self._pending_request = None - if self._load_cancel_event is not None: - self._load_cancel_event.set() - if self._load_thread is not None and self._load_thread.is_alive(): - self._load_thread.join(timeout=0.5) + if self._load_process is not None: + self._load_process.kill() + self._load_process.waitForFinished(500) + self._save_ui_state() event.accept() def _choose_directory(self) -> None: @@ -328,27 +410,35 @@ def _go_home(self) -> None: self._sync_tree_to_path(str(home)) self._request_directory(home, add_to_history=True) - def _toggle_grouped_view(self, enabled: bool) -> None: - if not enabled and not self._raw_toggle.isChecked(): - self._group_toggle.setChecked(True) - return - self._controller.set_grouped_view(enabled) - self._raw_toggle.blockSignals(True) - self._raw_toggle.setChecked(not enabled) - self._raw_toggle.blockSignals(False) + def _toggle_content_mode(self, sequence_view: bool) -> None: + self._controller.set_grouped_view(sequence_view) + self._content_mode_toggle.setIcon( + self.style().standardIcon( + QStyle.StandardPixmap.SP_FileDialogDetailedView + if sequence_view + else QStyle.StandardPixmap.SP_FileIcon + ) + ) + self._content_mode_toggle.setToolTip( + "Sequence View" if sequence_view else "File View" + ) self._apply_filter(self._filter_input.text()) - def _toggle_raw_view(self, enabled: bool) -> None: - if not enabled and not self._group_toggle.isChecked(): - self._raw_toggle.setChecked(True) - return - if enabled == (not self._controller.grouped_view): - return - self._group_toggle.blockSignals(True) - self._group_toggle.setChecked(not enabled) - self._group_toggle.blockSignals(False) - self._controller.set_grouped_view(not enabled) - self._apply_filter(self._filter_input.text()) + def _toggle_layout_mode(self, detail_view: bool) -> None: + self._layout_mode_toggle.setIcon( + self.style().standardIcon( + QStyle.StandardPixmap.SP_FileDialogDetailedView + if detail_view + else QStyle.StandardPixmap.SP_FileDialogListView + ) + ) + self._layout_mode_toggle.setToolTip( + "Detail View" if detail_view else "Icon View" + ) + self._center_stack.setCurrentWidget( + self._table if detail_view else self._icon_view + ) + self._save_ui_state() def _handle_tree_selection(self) -> None: index = self._tree.currentIndex() @@ -357,76 +447,134 @@ def _handle_tree_selection(self) -> None: self._request_directory(path, add_to_history=True) def _request_directory(self, path: str | Path, add_to_history: bool) -> None: - requested_path = str(Path(path).expanduser()) - if self._load_thread is not None and self._load_thread.is_alive(): + requested_path = str(self._normalize_path(path)) + if self._is_loading(): self._pending_request = (requested_path, add_to_history) self.statusBar().showMessage(f"Queued {requested_path}") return self._active_request = (requested_path, add_to_history) self._set_loading_state(True, requested_path) - self._load_token += 1 - token = self._load_token - cancel_event = Event() - self._load_cancel_event = cancel_event - self._load_thread = Thread( - target=self._scan_directory_worker, - args=(requested_path, token, cancel_event), - daemon=True, - ) - self._progress_timer.start() - self._load_thread.start() - - def _scan_directory_worker( - self, path: str, token: int, cancel_event: Event - ) -> None: - scanner = DirectoryScanner() - - def send_progress(message: str) -> None: - self._load_queue.put(("progress", token, message)) - - try: - result = scanner.scan( - path, - cancel_check=cancel_event.is_set, - progress_callback=send_progress, + self._load_cancelled = False + self._load_timed_out = False + self._load_stdout_buffer = bytearray() + self._load_stderr_buffer = "" + self._load_stderr_partial = "" + + process = QProcess(self) + process.setProgram(sys.executable) + arguments = ["-u", "-m", "sview.worker"] + if self._debug: + arguments.append("--debug") + arguments.append(requested_path) + process.setArguments(arguments) + process.readyReadStandardOutput.connect( + lambda process=process: self._read_scan_stdout(process) + ) + process.readyReadStandardError.connect( + lambda process=process: self._read_scan_stderr(process) + ) + process.errorOccurred.connect( + lambda error, process=process: self._handle_scan_process_error( + process, error ) - except ScanCancelled: - self._load_queue.put(("cancelled", token, None)) + ) + process.finished.connect( + lambda exit_code, exit_status, process=process: self._handle_scan_process_finished( + process, exit_code, exit_status + ) + ) + self._load_process = process + process.start() + timeout_seconds = self._config.scan_worker.timeout_seconds + if timeout_seconds is not None and timeout_seconds > 0: + self._scan_timeout_timer.start(timeout_seconds * 1000) + + def _read_scan_stdout(self, process: QProcess) -> None: + if process is not self._load_process: + return + self._load_stdout_buffer.extend(bytes(process.readAllStandardOutput())) + + def _read_scan_stderr(self, process: QProcess) -> None: + if process is not self._load_process: return - except Exception as exc: # pragma: no cover - UI failure path - self._load_queue.put(("failed", token, str(exc))) + chunk = bytes(process.readAllStandardError()).decode("utf-8", "replace") + if not chunk: return - self._load_queue.put(("finished", token, result)) + self._load_stderr_buffer += chunk + self._load_stderr_partial += chunk + lines = self._load_stderr_partial.splitlines(keepends=True) + if lines and not lines[-1].endswith(("\n", "\r")): + self._load_stderr_partial = lines.pop() + else: + self._load_stderr_partial = "" + for line in lines: + text = line.strip() + if text: + if self._debug: + print(f"[sview scan] {text}", file=sys.stderr, flush=True) + self.statusBar().showMessage(text) + + def _handle_scan_process_error( + self, process: QProcess, error: QProcess.ProcessError + ) -> None: + if process is not self._load_process or self._load_cancelled: + return + if error is QProcess.ProcessError.FailedToStart: + message = process.errorString() or "Failed to start scan worker." + self._clear_scan_process() + self._handle_failed_scan(message) - def _process_worker_messages(self) -> None: - handled = False - while True: - try: - message_type, token, payload = self._load_queue.get_nowait() - except Empty: - break + def _handle_scan_process_finished( + self, process: QProcess, exit_code: int, exit_status: QProcess.ExitStatus + ) -> None: + if process is not self._load_process: + return + self._read_scan_stdout(process) + self._read_scan_stderr(process) - handled = True - if token != self._load_token: - continue + response_text = ( + bytes(self._load_stdout_buffer).decode("utf-8", "replace").strip() + ) + error_text = f"{self._load_stderr_buffer}{self._load_stderr_partial}".strip() + cancelled = self._load_cancelled + timed_out = self._load_timed_out + + self._clear_scan_process() - if message_type == "progress": - self.statusBar().showMessage(str(payload)) - elif message_type == "finished": - self._handle_finished_scan(payload) - elif message_type == "failed": - self._handle_failed_scan(str(payload)) - elif message_type == "cancelled": - self._handle_cancelled_scan() + if timed_out: + self._handle_timed_out_scan(error_text) + return + if cancelled: + self._handle_cancelled_scan() + return + if exit_status is QProcess.ExitStatus.CrashExit: + self._handle_failed_scan(error_text or "Scan worker crashed.") + return + if exit_code != 0: + self._handle_failed_scan( + error_text or f"Scan worker exited with code {exit_code}." + ) + return + if not response_text: + self._handle_failed_scan("Scan worker returned no data.") + return - if handled and self._load_thread is None: - self._progress_timer.stop() + try: + result = ScanResult.from_dict(json.loads(response_text)) + except ( + TypeError, + ValueError, + KeyError, + AttributeError, + json.JSONDecodeError, + ) as exc: + self._handle_failed_scan(f"Invalid scan response: {exc}") + return + self._handle_finished_scan(result) def _handle_finished_scan(self, result: object) -> None: request = self._active_request - self._load_thread = None - self._load_cancel_event = None self._set_loading_state(False) if request is None or not isinstance(result, ScanResult): return @@ -441,21 +589,55 @@ def _handle_finished_scan(self, result: object) -> None: self._drain_pending_request() def _handle_failed_scan(self, error_message: str) -> None: - self._load_thread = None - self._load_cancel_event = None self._active_request = None self._set_loading_state(False) + if self._debug and error_message: + print(f"[sview error] {error_message}", file=sys.stderr, flush=True) self.statusBar().showMessage(f"Failed to load directory: {error_message}", 5000) + dialog = QMessageBox(self) + dialog.setIcon(QMessageBox.Icon.Warning) + dialog.setWindowTitle("Scan failed") + dialog.setText("Failed to load directory.") + dialog.setInformativeText(self._summarize_error_message(error_message)) + details = error_message.strip() + if details and details != dialog.informativeText(): + dialog.setDetailedText(details) + dialog.exec() self._drain_pending_request() def _handle_cancelled_scan(self) -> None: - self._load_thread = None - self._load_cancel_event = None self._active_request = None self._set_loading_state(False) self.statusBar().showMessage("Scan cancelled", 3000) self._drain_pending_request() + def _handle_timed_out_scan(self, error_message: str) -> None: + self._active_request = None + self._set_loading_state(False) + timeout_seconds = self._config.scan_worker.timeout_seconds + summary = ( + f"Scan exceeded {timeout_seconds} seconds and was stopped." + if timeout_seconds is not None + else "Scan timed out and was stopped." + ) + if self._debug: + print(f"[sview error] {summary}", file=sys.stderr, flush=True) + if error_message: + print(error_message, file=sys.stderr, flush=True) + self.statusBar().showMessage(summary, 5000) + dialog = QMessageBox(self) + dialog.setIcon(QMessageBox.Icon.Warning) + dialog.setWindowTitle("Scan timed out") + dialog.setText(summary) + dialog.setInformativeText( + "This scan worker was terminated to protect system responsiveness." + ) + details = error_message.strip() + if details: + dialog.setDetailedText(details) + dialog.exec() + self._drain_pending_request() + def _apply_filter(self, text: str) -> None: needle = text.strip().lower() if not needle: @@ -469,11 +651,12 @@ def _apply_filter(self, text: str) -> None: self._visible_items = filtered self._table.set_items(filtered) + self._icon_view.set_items(filtered) self._inspector.clear_details() self._update_status_bar() def _sync_inspector(self) -> None: - item = self._table.current_browser_item() + item = self._current_browser_item() if item is None: self._inspector.clear_details() return @@ -482,13 +665,13 @@ def _sync_inspector(self) -> None: self._inspector.expand_button.setText("Collapse Sequence") def _activate_selected_item(self, *_args) -> None: - item = self._table.current_browser_item() + item = self._current_browser_item() if item is None: return self._activate_item(item) def _expand_or_collapse_selected_sequence(self) -> None: - item = self._inspector.current_item or self._table.current_browser_item() + item = self._inspector.current_item or self._current_browser_item() if item is None or item.item_type is not ItemType.SEQUENCE: return if ( @@ -506,21 +689,53 @@ def _expand_or_collapse_selected_sequence(self) -> None: ) def _copy_selected_path(self) -> None: - item = self._inspector.current_item or self._table.current_browser_item() + item = self._inspector.current_item or self._current_browser_item() if item is None: return QGuiApplication.clipboard().setText(item.path) self.statusBar().showMessage(f"Copied path for {item.display_name}", 3000) def _copy_selected_pattern(self) -> None: - item = self._inspector.current_item or self._table.current_browser_item() + item = self._inspector.current_item or self._current_browser_item() if item is None or item.item_type is not ItemType.SEQUENCE: return QGuiApplication.clipboard().setText(item.display_name) self.statusBar().showMessage(f"Copied pattern {item.display_name}", 3000) + def _find_missing_for_selected_sequence(self) -> None: + item = self._inspector.current_item or self._current_browser_item() + if item is None or item.item_type is not ItemType.SEQUENCE: + return + data = self._run_sstat_json(item) + if data is None: + return + missing = self._coerce_missing_list(data.get("missing")) + item.missing = missing or None + self._table.update_item(item) + self._inspector.set_item(item) + self.statusBar().showMessage( + f"Loaded missing-frame data for {item.display_name}", 3000 + ) + + def _get_size_for_selected_sequence(self) -> None: + item = self._inspector.current_item or self._current_browser_item() + if item is None or item.item_type is not ItemType.SEQUENCE: + return + data = self._run_sstat_json(item) + if data is None: + return + size_bytes = self._coerce_int(data.get("size_bytes")) or self._coerce_int( + data.get("size") + ) + if size_bytes is not None: + item.size_bytes = size_bytes + self._table.update_item(item) + self._inspector.set_item(item) + self._update_status_bar() + self.statusBar().showMessage(f"Loaded size for {item.display_name}", 3000) + def _open_selected_properties(self) -> None: - item = self._inspector.current_item or self._table.current_browser_item() + item = self._inspector.current_item or self._current_browser_item() if item is None: return self._open_properties(item) @@ -612,21 +827,10 @@ def _expand_or_collapse_item(self, item: BrowserItem) -> None: self._expand_or_collapse_selected_sequence() def _run_sequence_sstat(self, item: BrowserItem) -> None: - executable = self._tool_path("sstat") - if executable is None: - return - completed = subprocess.run( - [executable, item.path, "--json"], - capture_output=True, - text=True, - check=False, - ) - if completed.returncode != 0: - QMessageBox.warning( - self, "sstat failed", completed.stderr.strip() or "sstat failed." - ) + data = self._run_sstat_json(item) + if data is None: return - text = self._format_sstat_output(completed.stdout.strip()) + text = self._format_sstat_output(json.dumps(data)) QMessageBox.information(self, "Properties", text or "No output.") def _run_sequence_transfer(self, item: BrowserItem, tool_name: str) -> None: @@ -688,7 +892,7 @@ def _push_history(self, path: str) -> None: self._update_navigation_buttons() def _update_navigation_buttons(self) -> None: - loading = self._load_thread is not None and self._load_thread.is_alive() + loading = self._is_loading() self._back_button.setEnabled(not loading and self._history_index > 0) self._up_button.setEnabled( not loading @@ -715,8 +919,8 @@ def _set_loading_state(self, loading: bool, path: str | None = None) -> None: not loading and self._controller.current_path.parent != self._controller.current_path ) - self._group_toggle.setEnabled(not loading) - self._raw_toggle.setEnabled(not loading) + self._content_mode_toggle.setEnabled(not loading) + self._layout_mode_toggle.setEnabled(not loading) self._open_button.setEnabled(not loading) self._refresh_button.setEnabled(not loading) self._filter_input.setEnabled(not loading) @@ -744,11 +948,26 @@ def _drain_pending_request(self) -> None: def _cancel_scan(self) -> None: self._pending_request = None - if self._load_cancel_event is None: + if self._load_process is None: return - self._load_cancel_event.set() + self._load_cancelled = True self._stop_button.setEnabled(False) self.statusBar().showMessage("Cancelling scan...") + self._load_process.kill() + + def _handle_scan_timeout(self) -> None: + if self._load_process is None: + return + self._load_timed_out = True + self._stop_button.setEnabled(False) + self.statusBar().showMessage("Scan timed out, stopping worker...") + if self._debug: + print( + "[sview scan] timeout reached, killing worker", + file=sys.stderr, + flush=True, + ) + self._load_process.kill() def _update_status_bar(self) -> None: count = len(self._visible_items) @@ -761,6 +980,56 @@ def _update_status_bar(self) -> None: ) self._update_navigation_buttons() + def _run_sstat_json(self, item: BrowserItem) -> dict[str, object] | None: + executable = self._tool_path("sstat") + if executable is None: + return None + completed = subprocess.run( + [executable, item.path, "--json"], + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + QMessageBox.warning( + self, "sstat failed", completed.stderr.strip() or "sstat failed." + ) + return None + try: + return json.loads(completed.stdout.strip()) + except json.JSONDecodeError: + QMessageBox.warning( + self, "sstat failed", "Received invalid JSON from sstat." + ) + return None + + @staticmethod + def _coerce_int(value: object) -> int | None: + try: + if value is None: + return None + return int(value) + except (TypeError, ValueError): + return None + + @classmethod + def _coerce_missing_list(cls, value: object) -> list[int]: + if not isinstance(value, list): + return [] + frames: list[int] = [] + for item in value: + if isinstance(item, list) and len(item) == 2: + start = cls._coerce_int(item[0]) + end = cls._coerce_int(item[1]) + if start is None or end is None: + continue + frames.extend(range(start, end + 1)) + continue + coerced = cls._coerce_int(item) + if coerced is not None: + frames.append(coerced) + return frames + def _advance_busy_indicator(self) -> None: self._busy_value += self._busy_direction * 4 if self._busy_value >= 100: @@ -781,6 +1050,7 @@ def _close_inspector(self) -> None: sizes[1] += reclaimed sizes[2] = 0 self._main_splitter.setSizes(sizes) + self._save_ui_state() def _collapse_tree(self) -> None: self._tree.collapseAll() @@ -797,6 +1067,95 @@ def _collapse_expanded_sequence(self) -> bool: ) return True + def _is_loading(self) -> bool: + return ( + self._load_process is not None + and self._load_process.state() != QProcess.ProcessState.NotRunning + ) + + def _clear_scan_process(self) -> None: + self._scan_timeout_timer.stop() + if self._load_process is not None: + self._load_process.deleteLater() + self._load_process = None + self._load_stdout_buffer = bytearray() + self._load_stderr_buffer = "" + self._load_stderr_partial = "" + self._load_cancelled = False + self._load_timed_out = False + + def _save_ui_state(self) -> None: + sidebar_width = self._sidebar_restore_width + if self._main_splitter is not None: + sizes = self._main_splitter.sizes() + if sizes and sizes[0] > 0: + sidebar_width = sizes[0] + state = { + "window_width": self.width(), + "window_height": self.height(), + "main_splitter_sizes": self._main_splitter.sizes() + if self._main_splitter is not None + else [self.SIDEBAR_WIDTH, 900, 0], + "center_view": "icons" + if not self._layout_mode_toggle.isChecked() + else "details", + "sidebar_expanded": self._sidebar_expanded, + "sidebar_width": sidebar_width, + } + try: + save_ui_state(state) + except OSError: + pass + + @staticmethod + def _coerce_splitter_sizes(value: object, default: list[int]) -> list[int]: + if not isinstance(value, list) or len(value) != len(default): + return list(default) + sizes: list[int] = [] + for item in value: + try: + sizes.append(max(0, int(item))) + except (TypeError, ValueError): + return list(default) + return sizes + + def _current_browser_item(self) -> BrowserItem | None: + if self._center_stack.currentWidget() is self._icon_view: + return self._icon_view.current_browser_item() + return self._table.current_browser_item() + + def _toggle_sidebar(self, expanded: bool) -> None: + self._apply_sidebar_state(expanded) + self._save_ui_state() + + def _apply_sidebar_state(self, expanded: bool) -> None: + if self._main_splitter is None: + self._sidebar_expanded = expanded + return + self._sidebar_expanded = expanded + self._tree_toggle.blockSignals(True) + self._tree_toggle.setChecked(expanded) + self._tree_toggle.blockSignals(False) + self._tree_toggle.setArrowType( + Qt.ArrowType.LeftArrow if expanded else Qt.ArrowType.RightArrow + ) + self._tree_toggle.setToolTip( + "Collapse folders" if expanded else "Expand folders" + ) + sizes = self._main_splitter.sizes() + if len(sizes) < 3: + return + if expanded: + target = min(self.SIDEBAR_WIDTH, max(220, self._sidebar_restore_width)) + sizes[1] = max(0, sizes[1] - target) + sizes[0] = target + else: + if sizes[0] > 0: + self._sidebar_restore_width = sizes[0] + sizes[1] += sizes[0] + sizes[0] = 0 + self._main_splitter.setSizes(sizes) + def _open_repo_page(self) -> None: QDesktopServices.openUrl(QUrl(get_repository_url())) @@ -867,3 +1226,11 @@ def _format_size(size_bytes: int) -> str: break size /= 1024.0 return f"{size:.1f} {unit}" + + @staticmethod + def _summarize_error_message(error_message: str) -> str: + lines = [line.strip() for line in error_message.splitlines() if line.strip()] + for line in reversed(lines): + if line.startswith("ERROR:"): + return line[len("ERROR:") :].strip() + return lines[-1] if lines else "Unknown scan error." diff --git a/sview/qt/table.py b/sview/qt/table.py index 96c8152..1d03230 100644 --- a/sview/qt/table.py +++ b/sview/qt/table.py @@ -67,22 +67,16 @@ def __init__(self) -> None: self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.verticalHeader().setVisible(False) self.horizontalHeader().setStretchLastSection(True) - self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) - self.horizontalHeader().setSectionResizeMode( - 1, QHeaderView.ResizeMode.ResizeToContents - ) - self.horizontalHeader().setSectionResizeMode( - 2, QHeaderView.ResizeMode.ResizeToContents - ) - self.horizontalHeader().setSectionResizeMode( - 3, QHeaderView.ResizeMode.ResizeToContents - ) - self.horizontalHeader().setSectionResizeMode( - 4, QHeaderView.ResizeMode.ResizeToContents - ) - self.horizontalHeader().setSectionResizeMode( - 5, QHeaderView.ResizeMode.ResizeToContents - ) + header = self.horizontalHeader() + for column in range(len(self.HEADERS)): + header.setSectionResizeMode(column, QHeaderView.ResizeMode.Interactive) + self.setColumnWidth(0, 320) + self.setColumnWidth(1, 90) + self.setColumnWidth(2, 110) + self.setColumnWidth(3, 70) + self.setColumnWidth(4, 110) + self.setColumnWidth(5, 90) + self.setColumnWidth(6, 140) self.setShowGrid(False) self.setAlternatingRowColors(False) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) @@ -147,7 +141,6 @@ def _render_next_batch(self) -> None: self.setSortingEnabled(True) self.sortItems(0, Qt.SortOrder.AscendingOrder) self.horizontalHeader().setSortIndicator(0, Qt.SortOrder.AscendingOrder) - self.resizeColumnsToContents() def current_browser_item(self) -> BrowserItem | None: selected = self.selectedItems() @@ -155,6 +148,24 @@ def current_browser_item(self) -> BrowserItem | None: return None return selected[0].data(Qt.ItemDataRole.UserRole) + def update_item(self, item: BrowserItem) -> None: + for row in range(self.rowCount()): + row_item = self.item(row, 0) + if row_item is None: + continue + row_browser_item = row_item.data(Qt.ItemDataRole.UserRole) + if row_browser_item is None or row_browser_item.path != item.path: + continue + size_item = self.item(row, 5) + modified_item = self.item(row, 6) + if size_item is not None: + size_item.setText(self._format_size(item.size_bytes)) + size_item.setData(Qt.ItemDataRole.UserRole, item) + if modified_item is not None: + modified_item.setText(self._format_mtime(item.modified_time)) + modified_item.setData(Qt.ItemDataRole.UserRole, item) + return + def _emit_context_request(self, position) -> None: item = None table_item = self.itemAt(position) @@ -207,4 +218,6 @@ def _format_size(size_bytes: int) -> str: @staticmethod def _format_mtime(timestamp: float) -> str: + if timestamp <= 0: + return "" return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M") diff --git a/sview/scanner.py b/sview/scanner.py index 7ca5341..6f74f8a 100644 --- a/sview/scanner.py +++ b/sview/scanner.py @@ -58,6 +58,23 @@ class ScanResult: grouped_items: list[BrowserItem] raw_items: list[BrowserItem] + def to_dict(self) -> dict[str, object]: + return { + "path": str(self.path), + "grouped_items": [item.to_dict() for item in self.grouped_items], + "raw_items": [item.to_dict() for item in self.raw_items], + } + + @classmethod + def from_dict(cls, data: dict[str, object]) -> ScanResult: + grouped_items = [BrowserItem.from_dict(item) for item in data["grouped_items"]] + raw_items = [BrowserItem.from_dict(item) for item in data["raw_items"]] + return cls( + path=Path(str(data["path"])), + grouped_items=grouped_items, + raw_items=raw_items, + ) + def ensure_pyseq_available() -> None: if pyseq is None: @@ -114,11 +131,9 @@ def _build_raw_items( for entry in entries: self._raise_if_cancelled(cancel_check) try: - stat = entry.stat() + is_directory = entry.is_dir(follow_symlinks=False) except OSError: continue - - is_directory = entry.is_dir() items.append( BrowserItem( path=entry.path, @@ -129,8 +144,8 @@ def _build_raw_items( pad=None, count=0 if is_directory else 1, missing=None, - size_bytes=0 if is_directory else stat.st_size, - modified_time=stat.st_mtime, + size_bytes=0, + modified_time=0.0, child_paths=None, ) ) @@ -166,21 +181,34 @@ def _build_grouped_items( child_paths = [str(member.path) for member in members] consumed_paths.update(child_paths) - frame_range = sequence.format("%R").strip("[]") + start_frame = getattr(members[0], "frame", None) + end_frame = getattr(members[-1], "frame", None) + if start_frame is not None and end_frame is not None: + start_frame = int(start_frame) + end_frame = int(end_frame) + frame_range = ( + str(start_frame) + if start_frame == end_frame + else f"{start_frame}-{end_frame}" + ) + else: + frame_range = None pad_width = len(getattr(members[0], "digits", [""])[0]) if members else 0 - pad_value = f"%0{pad_width}d" if pad_width else sequence.format("%p") + pad_value = f"%0{pad_width}d" if pad_width else "%d" + head = members[0].head + tail = members[0].tail grouped.append( BrowserItem( - path=str(sequence.path()), + path=str(directory / f"{head}{pad_value}{tail}"), item_type=ItemType.SEQUENCE, - name=f"{sequence.head()}{pad_value}{sequence.tail()}", - display_name=f"{sequence.head()}{pad_value}{sequence.tail()}", + name=f"{head}{pad_value}{tail}", + display_name=f"{head}{pad_value}{tail}", frame_range=frame_range, pad=pad_value, count=len(sequence), - missing=list(sequence.missing()), - size_bytes=int(sequence.size), - modified_time=float(sequence.mtime), + missing=None, + size_bytes=0, + modified_time=0.0, child_paths=child_paths, ) ) diff --git a/sview/worker.py b/sview/worker.py new file mode 100644 index 0000000..31a9631 --- /dev/null +++ b/sview/worker.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026, Ryan Galloway (ryan@rsgalloway.com) +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# - Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# - Neither the name of the software nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# ----------------------------------------------------------------------------- + +""" +Runs directory scans in a subprocess and emits JSON results for the main UI. +""" + +from __future__ import annotations + +import json +import os +import sys +import traceback + +try: + import resource +except ImportError: # pragma: no cover - platform-dependent + resource = None + +from sview.config import AppConfig +from sview.scanner import DirectoryScanner + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + debug = False + if "--debug" in args: + debug = True + args = [arg for arg in args if arg != "--debug"] + + if len(args) != 1: + print("Usage: python -m sview.worker [--debug] ", file=sys.stderr) + return 2 + + path = args[0] + config = AppConfig.load() + _apply_worker_limits(config, debug=debug) + scanner = DirectoryScanner() + try: + result = scanner.scan(path, progress_callback=_emit_progress) + except MemoryError: + print( + "ERROR: scan worker exceeded its configured memory limit.", + file=sys.stderr, + flush=True, + ) + if debug: + traceback.print_exc(file=sys.stderr) + return 1 + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr, flush=True) + if debug: + traceback.print_exc(file=sys.stderr) + return 1 + + sys.stdout.write(json.dumps(result.to_dict())) + sys.stdout.flush() + return 0 + + +def _emit_progress(message: str) -> None: + print(message, file=sys.stderr, flush=True) + + +def _apply_worker_limits(config: AppConfig, debug: bool = False) -> None: + worker = config.scan_worker + + if worker.nice_increment: + try: + os.nice(worker.nice_increment) + if debug: + print( + f"[sview scan] applied nice increment {worker.nice_increment}", + file=sys.stderr, + flush=True, + ) + except (AttributeError, OSError): # pragma: no cover - platform-dependent + if debug: + print( + "[sview scan] could not adjust process priority", + file=sys.stderr, + flush=True, + ) + + if resource is None or worker.memory_limit_mb is None: + return + + limit_bytes = int(worker.memory_limit_mb) * 1024 * 1024 + try: + resource.setrlimit(resource.RLIMIT_AS, (limit_bytes, limit_bytes)) + if debug: + print( + f"[sview scan] applied memory limit {worker.memory_limit_mb} MB", + file=sys.stderr, + flush=True, + ) + except (OSError, ValueError): # pragma: no cover - platform-dependent + if debug: + print( + "[sview scan] could not apply memory limit", + file=sys.stderr, + flush=True, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_scanner.py b/tests/test_scanner.py index d8b36ac..27369c5 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -4,9 +4,10 @@ import unittest from pathlib import Path +from sview import config as config_module from sview.controller import BrowserController from sview.model import ItemType -from sview.scanner import DirectoryScanner +from sview.scanner import DirectoryScanner, ScanResult def _touch(path: Path) -> None: @@ -32,9 +33,9 @@ def test_sequence_grouping_and_missing_frames(self) -> None: if item.item_type is ItemType.SEQUENCE ) self.assertEqual(sequence.display_name, "shotA.%04d.exr") - self.assertEqual(sequence.frame_range, "1001-1002, 1004") + self.assertEqual(sequence.frame_range, "1001-1004") self.assertEqual(sequence.count, 3) - self.assertEqual(sequence.missing, [1003]) + self.assertIsNone(sequence.missing) self.assertEqual( sequence.child_paths, [ @@ -59,6 +60,21 @@ def test_single_numbered_file_stays_a_file(self) -> None: self.assertEqual(len(result.grouped_items), 1) self.assertIs(result.grouped_items[0].item_type, ItemType.FILE) + def test_sequence_range_handles_frame_zero(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + _touch(root / "plate.0000.jpg") + _touch(root / "plate.0001.jpg") + + result = DirectoryScanner().scan(root) + + sequence = next( + item + for item in result.grouped_items + if item.item_type is ItemType.SEQUENCE + ) + self.assertEqual(sequence.frame_range, "0-1") + def test_controller_can_expand_and_collapse_sequence(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) @@ -82,6 +98,66 @@ def test_controller_can_expand_and_collapse_sequence(self) -> None: self.assertEqual(len(collapsed_items), 1) self.assertIs(collapsed_items[0].item_type, ItemType.SEQUENCE) + def test_scan_result_round_trip_serialization(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + _touch(root / "plate.0001.jpg") + _touch(root / "plate.0002.jpg") + + result = DirectoryScanner().scan(root) + restored = ScanResult.from_dict(result.to_dict()) + + self.assertEqual(restored.path, result.path) + self.assertEqual( + [item.display_name for item in restored.grouped_items], + [item.display_name for item in result.grouped_items], + ) + self.assertEqual( + [item.path for item in restored.raw_items], + [item.path for item in result.raw_items], + ) + + +class AppConfigTests(unittest.TestCase): + def test_load_tolerates_invalid_worker_values(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + config_dir = Path(tmp_dir) + config_path = config_dir / "config.json" + ui_state_path = config_dir / "ui_state.json" + config_dir.mkdir(parents=True, exist_ok=True) + config_path.write_text( + """ +{ + "scanner": { + "worker": { + "nice_increment": "not-a-number", + "memory_limit_mb": "also-bad", + "timeout_seconds": "still-bad" + } + } +} +""".strip() + + "\n", + encoding="utf-8", + ) + + original_dir = config_module.CONFIG_DIR + original_config_path = config_module.CONFIG_PATH + original_ui_state_path = config_module.UI_STATE_PATH + config_module.CONFIG_DIR = config_dir + config_module.CONFIG_PATH = config_path + config_module.UI_STATE_PATH = ui_state_path + try: + loaded = config_module.AppConfig.load() + finally: + config_module.CONFIG_DIR = original_dir + config_module.CONFIG_PATH = original_config_path + config_module.UI_STATE_PATH = original_ui_state_path + + self.assertEqual(loaded.scan_worker.nice_increment, 15) + self.assertIsNone(loaded.scan_worker.memory_limit_mb) + self.assertEqual(loaded.scan_worker.timeout_seconds, 30) + if __name__ == "__main__": unittest.main()