diff --git a/CHANGELOG.md b/CHANGELOG.md index 97b75b3..54eca6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.2.1 - 2026-04-05 + +- Added a packaged application icon and surfaced it in the About dialog +- Continued browser polish around hidden-file controls and tree navigation behavior + ## 0.2.0 - 2026-04-05 - Refined the desktop UI with a darker theme, tighter toolbar, custom item icons, and cleaner loading feedback diff --git a/pyproject.toml b/pyproject.toml index 83c1e1d..00e99f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sview" -version = "0.2.0" +version = "0.2.1" description = "Sequence-aware filesystem browser" readme = "README.md" requires-python = ">=3.8" @@ -24,3 +24,6 @@ sview = "sview.qt.app:main" [tool.setuptools] packages = ["sview", "sview.qt"] + +[tool.setuptools.package-data] +"sview.qt" = ["sview_icon.png"] diff --git a/sview/__init__.py b/sview/__init__.py index 97f9142..f1e51c5 100644 --- a/sview/__init__.py +++ b/sview/__init__.py @@ -35,4 +35,4 @@ on usability and a clean interface. """ -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/sview/controller.py b/sview/controller.py index ec619d7..90a36ae 100644 --- a/sview/controller.py +++ b/sview/controller.py @@ -52,6 +52,7 @@ def __init__(self, scanner: DirectoryScanner | None = None) -> None: self._current_items: list[BrowserItem] = [] self._raw_items: list[BrowserItem] = [] self._grouped_items: list[BrowserItem] = [] + self._sequence_metadata_cache: dict[str, dict[str, object]] = {} self._grouped_view = True self._expanded_sequence: BrowserItem | None = None @@ -103,18 +104,34 @@ def apply_scan_result(self, result: ScanResult) -> list[BrowserItem]: self._current_path = result.path self._grouped_items = result.grouped_items self._raw_items = result.raw_items + self._apply_cached_sequence_metadata() self._expanded_sequence = None self._refresh_current_items() return self.current_items def update_sequence_metadata( - self, item_path: str, size_bytes: int, modified_time: float + self, + item_path: str, + size_bytes: int | None = None, + modified_time: float | None = None, + missing: list[int] | None = None, ) -> BrowserItem | None: + cache_entry = self._sequence_metadata_cache.setdefault(item_path, {}) + if size_bytes is not None: + cache_entry["size_bytes"] = size_bytes + if modified_time is not None: + cache_entry["modified_time"] = modified_time + if missing is not None: + cache_entry["missing"] = list(missing) for item in self._grouped_items: if item.path != item_path: continue - item.size_bytes = size_bytes - item.modified_time = modified_time + if size_bytes is not None: + item.size_bytes = size_bytes + if modified_time is not None: + item.modified_time = modified_time + if missing is not None: + item.missing = list(missing) return item return None @@ -122,3 +139,17 @@ def _refresh_current_items(self) -> None: self._current_items = ( self._grouped_items if self._grouped_view else self._raw_items ) + + def _apply_cached_sequence_metadata(self) -> None: + for item in self._grouped_items: + if item.item_type is not ItemType.SEQUENCE: + continue + cached = self._sequence_metadata_cache.get(item.path) + if not cached: + continue + if "size_bytes" in cached: + item.size_bytes = int(cached["size_bytes"]) + if "modified_time" in cached: + item.modified_time = float(cached["modified_time"]) + if "missing" in cached: + item.missing = [int(frame) for frame in cached["missing"]] diff --git a/sview/model.py b/sview/model.py index 5ead5f3..67fd1e6 100644 --- a/sview/model.py +++ b/sview/model.py @@ -101,3 +101,23 @@ def from_dict(cls, data: dict[str, object]) -> BrowserItem: if data["child_paths"] is not None else None, ) + + +def format_frame_ranges(frames: list[int] | None) -> str: + if not frames: + return "-" + + ordered = sorted(dict.fromkeys(int(frame) for frame in frames)) + ranges: list[str] = [] + start = ordered[0] + end = ordered[0] + + for frame in ordered[1:]: + if frame == end + 1: + end = frame + continue + ranges.append(str(start) if start == end else f"{start}-{end}") + start = end = frame + + ranges.append(str(start) if start == end else f"{start}-{end}") + return ", ".join(ranges) diff --git a/sview/qt/app.py b/sview/qt/app.py index 200bfb5..1d8f80c 100644 --- a/sview/qt/app.py +++ b/sview/qt/app.py @@ -38,7 +38,7 @@ import sys from pathlib import Path -from PySide6.QtGui import QColor, QPalette +from PySide6.QtGui import QColor, QIcon, QPalette from PySide6.QtWidgets import QApplication, QMessageBox from sview import __version__ @@ -61,10 +61,12 @@ def main() -> int: return 1 app.setApplicationName("sview") app.setApplicationVersion(__version__) + app.setWindowIcon(_app_icon()) app.setStyle("Fusion") _apply_dark_theme(app) initial_path = _parse_initial_path(sys.argv[1:]) window = MainWindow(initial_path=initial_path, debug=debug) + window.setWindowIcon(_app_icon()) window.show() return app.exec() @@ -85,6 +87,10 @@ def _wants_debug(args: list[str]) -> bool: return "--debug" in args +def _app_icon() -> QIcon: + return QIcon(str(Path(__file__).with_name("sview_icon.png"))) + + def _apply_dark_theme(app: QApplication) -> None: palette = QPalette() palette.setColor(QPalette.ColorRole.Window, QColor("#101519")) @@ -294,13 +300,32 @@ def _apply_dark_theme(app: QApplication) -> None: background-color: #1d262e; border-color: #2b3741; } + QWidget#iconCard[missing="true"] { + background-color: #2a211f; + border: 1px solid #4a3731; + border-radius: 6px; + } + QWidget#iconCard[missing="true"]:hover { + background-color: #332824; + border-color: #5a433b; + } QLabel#iconCardTitle { font-size: 13px; font-weight: 600; color: #eef3f7; + background: transparent; } QLabel#iconCardSubtitle { color: #99a8b3; + background: transparent; + } + QLabel#iconCardTitle[missing="true"] { + background: transparent; + color: #f3e7e2; + } + QLabel#iconCardSubtitle[missing="true"] { + background: transparent; + color: #ccb7ae; } """ ) diff --git a/sview/qt/icon_view.py b/sview/qt/icon_view.py index fc824c4..5008090 100644 --- a/sview/qt/icon_view.py +++ b/sview/qt/icon_view.py @@ -81,6 +81,8 @@ def set_items(self, items: list[BrowserItem]) -> None: list_item.setSizeHint(QSize(self.CARD_WIDTH - 12, self.CARD_HEIGHT - 10)) self.addItem(list_item) self.setItemWidget(list_item, self._build_card(item)) + if self.count() > 0: + self.setCurrentRow(0) def current_browser_item(self) -> BrowserItem | None: current = self.currentItem() @@ -97,7 +99,11 @@ def update_item(self, item: BrowserItem) -> None: 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)) + card = self._build_card(item) + self.setItemWidget(list_item, card) + card.style().unpolish(card) + card.style().polish(card) + card.update() return def _emit_context_request(self, position) -> None: @@ -118,6 +124,8 @@ def _icon(self, item: BrowserItem): def _build_card(self, item: BrowserItem) -> QWidget: card = QWidget() card.setObjectName("iconCard") + if item.item_type is ItemType.SEQUENCE and item.missing_count: + card.setProperty("missing", True) layout = QHBoxLayout(card) layout.setContentsMargins(14, 12, 14, 12) layout.setSpacing(14) @@ -130,10 +138,14 @@ def _build_card(self, item: BrowserItem) -> QWidget: name_label = QLabel(item.display_name) name_label.setObjectName("iconCardTitle") name_label.setWordWrap(True) + if item.item_type is ItemType.SEQUENCE and item.missing_count: + name_label.setProperty("missing", True) subtitle_label = QLabel(self._secondary_text(item)) subtitle_label.setObjectName("iconCardSubtitle") subtitle_label.setWordWrap(True) + if item.item_type is ItemType.SEQUENCE and item.missing_count: + subtitle_label.setProperty("missing", True) text_layout = QVBoxLayout() text_layout.setContentsMargins(0, 0, 0, 0) diff --git a/sview/qt/icons.py b/sview/qt/icons.py index 8e89d61..c6dd7d6 100644 --- a/sview/qt/icons.py +++ b/sview/qt/icons.py @@ -51,6 +51,10 @@ def browser_item_icon(item: BrowserItem, size: int = 18) -> QIcon: return _file_icon(size) +def sidebar_icon(expanded: bool, size: int = 18) -> QIcon: + return _sidebar_icon(expanded, size) + + @lru_cache(maxsize=12) def _folder_icon(size: int) -> QIcon: pixmap = QPixmap(size, size) @@ -144,3 +148,46 @@ def _sequence_icon(size: int) -> QIcon: painter.drawRoundedRect(badge, 2, 2) painter.end() return QIcon(pixmap) + + +@lru_cache(maxsize=24) +def _sidebar_icon(expanded: bool, size: int) -> QIcon: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(Qt.PenStyle.NoPen) + + outer = QRectF(size * 0.10, size * 0.16, size * 0.80, size * 0.68) + sidebar = QRectF(size * 0.14, size * 0.20, size * 0.20, size * 0.60) + content = QRectF(size * 0.38, size * 0.20, size * 0.48, size * 0.60) + + painter.setBrush(QColor("#24303a")) + painter.drawRoundedRect(outer, 2.5, 2.5) + painter.setBrush(QColor("#d16f6f") if expanded else QColor("#485663")) + painter.drawRoundedRect(sidebar, 2, 2) + painter.setBrush(QColor("#d8e0e7")) + painter.drawRoundedRect(content, 2, 2) + + painter.setPen(QPen(QColor("#34414c"), max(1, size // 16))) + if expanded: + painter.drawLine( + QPointF(size * 0.24, size * 0.42), + QPointF(size * 0.18, size * 0.50), + ) + painter.drawLine( + QPointF(size * 0.18, size * 0.50), + QPointF(size * 0.24, size * 0.58), + ) + else: + painter.drawLine( + QPointF(size * 0.18, size * 0.42), + QPointF(size * 0.24, size * 0.50), + ) + painter.drawLine( + QPointF(size * 0.24, size * 0.50), + QPointF(size * 0.18, size * 0.58), + ) + painter.end() + return QIcon(pixmap) diff --git a/sview/qt/inspector.py b/sview/qt/inspector.py index f19a683..834b233 100644 --- a/sview/qt/inspector.py +++ b/sview/qt/inspector.py @@ -51,7 +51,7 @@ QWidget, ) -from sview.model import BrowserItem, ItemType +from sview.model import BrowserItem, ItemType, format_frame_ranges class InspectorPanel(QWidget): @@ -69,6 +69,7 @@ def __init__(self) -> None: self._range_value = QLabel("-") self._count_value = QLabel("-") self._missing_value = QLabel("-") + self._missing_value.setWordWrap(True) self._padding_value = QLabel("-") self._size_value = QLabel("-") self._modified_value = QLabel("-") @@ -86,6 +87,15 @@ def __init__(self) -> None: ) self.close_button.setToolTip("Close inspector") self.close_button.setFixedWidth(24) + for button in ( + self.copy_path_button, + self.copy_pattern_button, + self.find_missing_button, + self.get_size_button, + self.expand_button, + ): + button.setMinimumHeight(30) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) layout = QVBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) @@ -185,9 +195,7 @@ def set_item(self, item: BrowserItem) -> None: self._type_value.setText(self._type_text(item)) self._range_value.setText(item.frame_range or "-") self._count_value.setText(str(item.count) if item.count else "-") - self._missing_value.setText( - ", ".join(str(frame) for frame in item.missing) if item.missing else "-" - ) + self._missing_value.setText(format_frame_ranges(item.missing)) self._padding_value.setText(item.pad or "-") self._size_value.setText(self._format_size(item.size_bytes)) self._modified_value.setText(self._format_modified(item.modified_time)) diff --git a/sview/qt/main_window.py b/sview/qt/main_window.py index 2a599e8..75f48f1 100644 --- a/sview/qt/main_window.py +++ b/sview/qt/main_window.py @@ -40,8 +40,15 @@ import subprocess import sys -from PySide6.QtCore import QProcess, QTimer, Qt, QUrl -from PySide6.QtGui import QAction, QCloseEvent, QDesktopServices, QGuiApplication +from PySide6.QtCore import QPoint, QProcess, QTimer, Qt, QUrl +from PySide6.QtGui import ( + QAction, + QCloseEvent, + QDesktopServices, + QGuiApplication, + QKeySequence, + QPixmap, +) from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, @@ -54,7 +61,6 @@ QPushButton, QSplitter, QStackedWidget, - QProgressBar, QStyle, QToolButton, QVBoxLayout, @@ -73,10 +79,11 @@ from sview.controller import BrowserController from sview.model import BrowserItem, ItemType from sview.qt.icon_view import ContentsIconView +from sview.qt.icons import sidebar_icon from sview.qt.inspector import InspectorPanel from sview.qt.table import ContentsTable from sview.qt.tree import DirectoryTree -from sview.scanner import ScanResult +from sview.scanner import ScanResult, pyseq class MainWindow(QMainWindow): @@ -109,6 +116,7 @@ def __init__( self._load_stderr_partial = "" self._load_cancelled = False self._load_timed_out = False + self._focus_after_load = "browser" self.setWindowTitle("sview") self.resize(1400, 800) @@ -136,7 +144,10 @@ def __init__( self._inspector = InspectorPanel() self._tree_title = QLabel("Folders") self._tree_toggle = QToolButton() - self._tree = DirectoryTree(self._initial_path) + self._show_hidden_files = bool(self._ui_state.get("show_hidden_files", False)) + self._tree = DirectoryTree( + self._initial_path, show_hidden=self._show_hidden_files + ) self._main_splitter: QSplitter | None = None self._sidebar_expanded = bool(self._ui_state.get("sidebar_expanded", True)) self._sidebar_restore_width = ( @@ -173,8 +184,11 @@ def _normalize_path(path: str | Path) -> Path: def _build_toolbar(self) -> None: self._open_action = QAction("Open Folder", self) self._refresh_action = QAction("Refresh", self) + self._find_missing_action = QAction("Find Missing", self) + self._find_missing_action.setShortcut(QKeySequence("Ctrl+M")) self.addAction(self._open_action) self.addAction(self._refresh_action) + self.addAction(self._find_missing_action) self._back_button = QPushButton() self._back_button.setObjectName("navButton") @@ -183,6 +197,13 @@ def _build_toolbar(self) -> None: ) self._back_button.setToolTip("Back") self._back_button.setFixedWidth(28) + self._sidebar_button = QPushButton() + self._sidebar_button.setObjectName("navButton") + self._sidebar_button.setFixedWidth(28) + self._sidebar_button.setIcon(sidebar_icon(self._sidebar_expanded, size=18)) + self._sidebar_button.setToolTip( + "Collapse folders" if self._sidebar_expanded else "Show folders" + ) self._home_button = QPushButton() self._home_button.setObjectName("navButton") self._home_button.setIcon( @@ -239,6 +260,11 @@ def _build_toolbar(self) -> None: self._stop_button.setToolTip("Stop") self._stop_button.setFixedWidth(32) self._stop_button.setEnabled(False) + self._menu_button = QPushButton() + self._menu_button.setObjectName("toolbarToggle") + self._menu_button.setText("≡") + self._menu_button.setToolTip("Menu") + self._menu_button.setFixedWidth(32) def _build_menu_bar(self) -> None: menu_bar = self.menuBar() @@ -254,9 +280,15 @@ def _build_menu_bar(self) -> None: self._edit_copy_path_action = edit_menu.addAction("Copy Path") self._edit_copy_pattern_action = edit_menu.addAction("Copy Pattern") self._edit_properties_action = edit_menu.addAction("Properties") + edit_menu.addSeparator() + self._show_hidden_action = edit_menu.addAction("Show Hidden Files") + self._show_hidden_action.setCheckable(True) + self._show_hidden_action.setChecked(self._show_hidden_files) + self._preferences_action = edit_menu.addAction("Preferences") self._help_about_action = help_menu.addAction("About") self._help_repo_action = help_menu.addAction("GitHub Repo") + menu_bar.hide() def _build_layout(self) -> None: center = QWidget() @@ -266,6 +298,7 @@ def _build_layout(self) -> None: toolbar_row = QHBoxLayout() toolbar_row.setSpacing(4) + toolbar_row.addWidget(self._sidebar_button) toolbar_row.addWidget(self._back_button) toolbar_row.addWidget(self._up_button) toolbar_row.addWidget(self._home_button) @@ -281,6 +314,7 @@ def _build_layout(self) -> None: ) toolbar_row.addWidget(self._breadcrumb_bar, 1) toolbar_row.addWidget(self._filter_input) + toolbar_row.addWidget(self._menu_button) root_layout.addLayout(toolbar_row) splitter = QSplitter(Qt.Orientation.Horizontal) @@ -358,10 +392,17 @@ def _connect_signals(self) -> None: self._edit_copy_path_action.triggered.connect(self._copy_selected_path) self._edit_copy_pattern_action.triggered.connect(self._copy_selected_pattern) self._edit_properties_action.triggered.connect(self._open_selected_properties) + self._show_hidden_action.toggled.connect(self._toggle_show_hidden_files) + self._preferences_action.triggered.connect(self._show_preferences_dialog) self._help_repo_action.triggered.connect(self._open_repo_page) self._help_about_action.triggered.connect(self._show_about_dialog) + self._find_missing_action.triggered.connect( + self._find_missing_for_selected_sequence + ) self._open_button.clicked.connect(self._choose_directory) self._refresh_button.clicked.connect(self._refresh_directory) + self._menu_button.clicked.connect(self._show_toolbar_menu) + self._sidebar_button.clicked.connect(self._toggle_sidebar_from_toolbar) self._back_button.clicked.connect(self._go_back) self._home_button.clicked.connect(self._go_home) self._up_button.clicked.connect(self._go_up) @@ -376,6 +417,8 @@ def _connect_signals(self) -> None: self._table.filter_text_typed.connect(self._append_filter_text) self._table.filter_backspace_requested.connect(self._delete_filter_text) self._table.filter_clear_requested.connect(self._clear_filter_text) + self._table.activate_current_requested.connect(self._activate_selected_item) + self._table.navigate_up_requested.connect(self._go_up) 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) @@ -481,6 +524,7 @@ def _handle_tree_selection(self) -> None: index = self._tree.currentIndex() path = self._tree.filesystem_model.filePath(index) if path: + self._focus_after_load = "tree" self._request_directory(path, add_to_history=True) def _request_directory(self, path: str | Path, add_to_history: bool) -> None: @@ -625,7 +669,11 @@ def _handle_finished_scan(self, result: object) -> None: self._update_breadcrumbs(result.path) self._filter_input.clear() self._apply_filter("") - QTimer.singleShot(0, self._focus_active_browser) + if self._focus_after_load == "tree": + QTimer.singleShot(0, self._focus_tree) + else: + QTimer.singleShot(0, self._focus_active_browser) + self._focus_after_load = "browser" self._drain_pending_request() def _handle_failed_scan(self, error_message: str) -> None: @@ -688,6 +736,8 @@ def _apply_filter(self, text: str) -> None: for item in self._controller.current_items if needle in item.display_name.lower() ] + if not self._show_hidden_files: + filtered = [item for item in filtered if not self._is_hidden_item(item)] self._visible_items = filtered self._table.set_items(filtered) @@ -721,6 +771,43 @@ def _clear_filter_text(self) -> None: def _update_filter_clear_action(self, text: str) -> None: self._clear_filter_action.setVisible(bool(text)) + def _toggle_show_hidden_files(self, enabled: bool) -> None: + self._show_hidden_files = enabled + self._tree.set_show_hidden(enabled) + self._apply_filter(self._filter_input.text()) + self._save_ui_state() + + def _show_toolbar_menu(self) -> None: + menu = QMenu(self) + file_menu = menu.addMenu("File") + file_menu.addAction(self._file_open_action) + file_menu.addAction(self._file_refresh_action) + file_menu.addSeparator() + file_menu.addAction(self._file_quit_action) + + edit_menu = menu.addMenu("Edit") + edit_menu.addAction(self._edit_copy_path_action) + edit_menu.addAction(self._edit_copy_pattern_action) + edit_menu.addAction(self._edit_properties_action) + edit_menu.addSeparator() + edit_menu.addAction(self._show_hidden_action) + edit_menu.addAction(self._preferences_action) + + help_menu = menu.addMenu("Help") + help_menu.addAction(self._help_about_action) + help_menu.addAction(self._help_repo_action) + anchor = self._menu_button.mapToGlobal(self._menu_button.rect().bottomRight()) + menu_size = menu.sizeHint() + menu.exec(anchor - QPoint(menu_size.width(), 0)) + + def _show_preferences_dialog(self) -> None: + QMessageBox.information( + self, + "Preferences", + "Preferences are currently stored in ~/.config/sview.\n\n" + "More settings can be added here in a future pass.", + ) + def _sync_inspector(self) -> None: item = self._current_browser_item() if item is None: @@ -776,8 +863,14 @@ def _find_missing_for_selected_sequence(self) -> None: if data is None: return missing = self._coerce_missing_list(data.get("missing")) + updated_item = self._controller.update_sequence_metadata( + item.path, missing=missing or [] + ) + if updated_item is not None: + item = updated_item item.missing = missing or None self._table.update_item(item) + self._icon_view.update_item(item) self._inspector.set_item(item) self.statusBar().showMessage( f"Loaded missing-frame data for {item.display_name}", 3000 @@ -793,12 +886,31 @@ def _get_size_for_selected_sequence(self) -> None: size_bytes = self._coerce_int(data.get("size_bytes")) or self._coerce_int( data.get("size") ) + modified_time = item.modified_time + if item.child_paths: + modified_time = max( + ( + Path(child_path).stat().st_mtime + for child_path in item.child_paths + if Path(child_path).exists() + ), + default=modified_time, + ) + updated_item = self._controller.update_sequence_metadata( + item.path, size_bytes=size_bytes, modified_time=modified_time + ) + if updated_item is not None: + item = updated_item if size_bytes is not None: item.size_bytes = size_bytes + item.modified_time = modified_time self._table.update_item(item) + self._icon_view.update_item(item) self._inspector.set_item(item) self._update_status_bar() - self.statusBar().showMessage(f"Loaded size for {item.display_name}", 3000) + self.statusBar().showMessage( + f"Loaded size and modified time for {item.display_name}", 3000 + ) def _open_selected_properties(self) -> None: item = self._inspector.current_item or self._current_browser_item() @@ -967,7 +1079,18 @@ def _update_navigation_buttons(self) -> None: def _sync_tree_to_path(self, path: str) -> None: model = self._tree.filesystem_model - index = model.index(path) + normalized = str(self._normalize_path(path)) + root_index = self._tree.rootIndex() + root_path = model.filePath(root_index) + if root_path: + try: + Path(normalized).relative_to(Path(root_path)) + except ValueError: + parent_path = str(Path(normalized).parent) + parent_index = model.index(parent_path) + if parent_index.isValid(): + self._tree.setRootIndex(parent_index) + index = model.index(normalized) if not index.isValid(): return parent = index.parent() @@ -1046,8 +1169,9 @@ def _run_sstat_json(self, item: BrowserItem) -> dict[str, object] | None: executable = self._tool_path("sstat") if executable is None: return None + target = self._sequence_stat_target(item) completed = subprocess.run( - [executable, item.path, "--json"], + [executable, target, "--json"], capture_output=True, text=True, check=False, @@ -1065,6 +1189,21 @@ def _run_sstat_json(self, item: BrowserItem) -> dict[str, object] | None: ) return None + def _sequence_stat_target(self, item: BrowserItem) -> str: + if ( + item.item_type is not ItemType.SEQUENCE + or not item.child_paths + or pyseq is None + ): + return item.path + try: + sequences = pyseq.get_sequences(item.child_paths) + if sequences: + return sequences[0].format("%D%h%p%t") + except Exception: + pass + return item.path + @staticmethod def _coerce_int(value: object) -> int | None: try: @@ -1155,6 +1294,7 @@ def _save_ui_state(self) -> None: else "details", "sidebar_expanded": self._sidebar_expanded, "sidebar_width": sidebar_width, + "show_hidden_files": self._show_hidden_files, } try: save_ui_state(state) @@ -1183,6 +1323,10 @@ def _focus_active_browser(self) -> None: if widget is not None and widget.isEnabled(): widget.setFocus(Qt.FocusReason.OtherFocusReason) + def _focus_tree(self) -> None: + if self._tree.isEnabled(): + self._tree.setFocus(Qt.FocusReason.OtherFocusReason) + def _update_breadcrumbs(self, path: Path) -> None: while self._breadcrumb_layout.count(): item = self._breadcrumb_layout.takeAt(0) @@ -1218,15 +1362,26 @@ def _update_breadcrumbs(self, path: Path) -> None: self._breadcrumb_layout.addStretch(1) + @staticmethod + def _is_hidden_item(item: BrowserItem) -> bool: + return Path(item.path).name.startswith(".") + def _toggle_sidebar(self, expanded: bool) -> None: self._apply_sidebar_state(expanded) self._save_ui_state() + def _toggle_sidebar_from_toolbar(self) -> None: + self._toggle_sidebar(not self._sidebar_expanded) + def _apply_sidebar_state(self, expanded: bool) -> None: if self._main_splitter is None: self._sidebar_expanded = expanded return self._sidebar_expanded = expanded + self._sidebar_button.setIcon(sidebar_icon(expanded, size=18)) + self._sidebar_button.setToolTip( + "Collapse folders" if expanded else "Show folders" + ) self._tree_toggle.blockSignals(True) self._tree_toggle.setChecked(expanded) self._tree_toggle.blockSignals(False) @@ -1258,11 +1413,23 @@ def _open_repo_page(self) -> None: QDesktopServices.openUrl(QUrl(get_repository_url())) def _show_about_dialog(self) -> None: - QMessageBox.about( - self, - "About sview", - f"sview {__version__}\n\nSequence-aware filesystem browser.", - ) + dialog = QMessageBox(self) + dialog.setWindowTitle("About sview") + dialog.setText(f"sview {__version__}") + dialog.setInformativeText("Sequence-aware filesystem browser.") + icon_path = Path(__file__).with_name("sview_icon.png") + if icon_path.exists(): + pixmap = QPixmap(str(icon_path)) + if not pixmap.isNull(): + dialog.setIconPixmap( + pixmap.scaled( + 96, + 96, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + dialog.exec() def _format_sstat_output(self, raw_output: str) -> str: try: diff --git a/sview/qt/sview_icon.png b/sview/qt/sview_icon.png new file mode 100644 index 0000000..ea69ab8 Binary files /dev/null and b/sview/qt/sview_icon.png differ diff --git a/sview/qt/table.py b/sview/qt/table.py index c9c9058..5b4c09a 100644 --- a/sview/qt/table.py +++ b/sview/qt/table.py @@ -38,19 +38,37 @@ from datetime import datetime from PySide6.QtCore import QTimer, Qt, Signal -from PySide6.QtGui import QColor +from PySide6.QtGui import QColor, QBrush from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem -from sview.model import BrowserItem, ItemType +from sview.model import BrowserItem, ItemType, format_frame_ranges from sview.qt.icons import browser_item_icon +class BrowserTableItem(QTableWidgetItem): + def __lt__(self, other) -> bool: + if not isinstance(other, QTableWidgetItem): + return super().__lt__(other) + + self_item = self.data(Qt.ItemDataRole.UserRole) + other_item = other.data(Qt.ItemDataRole.UserRole) + if isinstance(self_item, BrowserItem) and isinstance(other_item, BrowserItem): + self_priority = 0 if self_item.item_type is ItemType.DIRECTORY else 1 + other_priority = 0 if other_item.item_type is ItemType.DIRECTORY else 1 + if self_priority != other_priority: + return self_priority < other_priority + + return self.text().lower() < other.text().lower() + + class ContentsTable(QTableWidget): RENDER_BATCH_SIZE = 100 context_requested = Signal(object, object) filter_text_typed = Signal(str) filter_backspace_requested = Signal() filter_clear_requested = Signal() + activate_current_requested = Signal() + navigate_up_requested = Signal() HEADERS = [ "Name", @@ -100,6 +118,7 @@ def set_items(self, items: list[BrowserItem]) -> None: self._pending_items = list(items) self._render_index = 0 self.clearContents() + self.clearSelection() self.setSortingEnabled(False) self.setRowCount(len(items)) if not items: @@ -125,7 +144,7 @@ def _render_next_batch(self) -> None: ] for column, value in enumerate(values): - table_item = QTableWidgetItem(value) + table_item = BrowserTableItem(value) table_item.setData(Qt.ItemDataRole.UserRole, item) if column == 0: table_item.setIcon(self._item_icon(item)) @@ -145,6 +164,9 @@ def _render_next_batch(self) -> None: self.setSortingEnabled(True) self.sortItems(0, Qt.SortOrder.AscendingOrder) self.horizontalHeader().setSortIndicator(0, Qt.SortOrder.AscendingOrder) + if self.rowCount() > 0: + self.setCurrentCell(0, 0) + self.selectRow(0) def current_browser_item(self) -> BrowserItem | None: selected = self.selectedItems() @@ -160,14 +182,26 @@ def update_item(self, item: BrowserItem) -> None: row_browser_item = row_item.data(Qt.ItemDataRole.UserRole) if row_browser_item is None or row_browser_item.path != item.path: continue + missing_item = self.item(row, 4) size_item = self.item(row, 5) modified_item = self.item(row, 6) + for column in range(self.columnCount()): + cell = self.item(row, column) + if cell is None: + continue + cell.setData(Qt.ItemDataRole.UserRole, item) + if item.item_type is ItemType.SEQUENCE and item.missing_count: + cell.setBackground(QColor("#433631")) + else: + cell.setBackground(QBrush()) + if item.item_type is ItemType.DIRECTORY: + cell.setForeground(QColor("#d0d6de")) if size_item is not None: size_item.setText(self._format_size(item.size_bytes)) - size_item.setData(Qt.ItemDataRole.UserRole, item) + if missing_item is not None: + missing_item.setText(self._missing_label(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: @@ -181,6 +215,21 @@ def _emit_context_request(self, position) -> None: def keyPressEvent(self, event) -> None: if self._handle_filter_key(event): return + if event.key() == Qt.Key.Key_Left: + self.navigate_up_requested.emit() + event.accept() + return + if event.key() in {Qt.Key.Key_Return, Qt.Key.Key_Enter}: + if self.current_browser_item() is not None: + self.activate_current_requested.emit() + event.accept() + return + if event.key() == Qt.Key.Key_Right: + current_item = self.current_browser_item() + if current_item is not None and current_item.item_type is not ItemType.FILE: + self.activate_current_requested.emit() + event.accept() + return super().keyPressEvent(event) def _item_icon(self, item: BrowserItem): @@ -200,10 +249,7 @@ def _missing_label(item: BrowserItem) -> str: return "" if not item.missing_count: return "" - preview = ", ".join(str(frame) for frame in (item.missing or [])[:3]) - if item.missing_count > 3: - preview = f"{preview}, +{item.missing_count - 3}" - return preview + return format_frame_ranges(item.missing) @staticmethod def _format_size(size_bytes: int) -> str: diff --git a/sview/qt/tree.py b/sview/qt/tree.py index 8992596..b2d75e6 100644 --- a/sview/qt/tree.py +++ b/sview/qt/tree.py @@ -37,11 +37,16 @@ from pathlib import Path -from PySide6.QtCore import QDir +from PySide6.QtCore import QDir, Qt from PySide6.QtWidgets import QFileSystemModel, QTreeView class DirectoryModel(QFileSystemModel): + def __init__(self, *args, show_hidden: bool = False, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._show_hidden = show_hidden + self._apply_filter() + def hasChildren(self, parent) -> bool: # type: ignore[override] if not parent.isValid(): return True @@ -51,16 +56,28 @@ def hasChildren(self, parent) -> bool: # type: ignore[override] return False directory = QDir(file_info.absoluteFilePath()) - children = directory.entryList(QDir.AllDirs | QDir.NoDotAndDotDot) + flags = QDir.AllDirs | QDir.NoDotAndDotDot + if self._show_hidden: + flags |= QDir.Hidden + children = directory.entryList(flags) return bool(children) + def set_show_hidden(self, enabled: bool) -> None: + self._show_hidden = enabled + self._apply_filter() + + def _apply_filter(self) -> None: + flags = QDir.AllDirs | QDir.NoDotAndDotDot + if self._show_hidden: + flags |= QDir.Hidden + self.setFilter(flags) + class DirectoryTree(QTreeView): - def __init__(self, root_path: str | Path) -> None: + def __init__(self, root_path: str | Path, show_hidden: bool = False) -> None: super().__init__() - self._model = DirectoryModel(self) + self._model = DirectoryModel(self, show_hidden=show_hidden) self._model.setRootPath(str(root_path)) - self._model.setFilter(QDir.AllDirs | QDir.NoDotAndDotDot) self.setModel(self._model) self.setRootIndex(self._model.index(str(root_path))) self.setUniformRowHeights(True) @@ -71,3 +88,40 @@ def __init__(self, root_path: str | Path) -> None: @property def filesystem_model(self) -> QFileSystemModel: return self._model + + def set_show_hidden(self, enabled: bool) -> None: + self._model.set_show_hidden(enabled) + + def keyPressEvent(self, event) -> None: + current = self.currentIndex() + if event.key() == Qt.Key.Key_Up: + target = self.indexAbove(current) + if target.isValid(): + self.setCurrentIndex(target) + event.accept() + return + if event.key() == Qt.Key.Key_Down: + target = self.indexBelow(current) + if target.isValid(): + self.setCurrentIndex(target) + event.accept() + return + if event.key() == Qt.Key.Key_Left: + if current.isValid() and self.isExpanded(current): + self.collapse(current) + elif current.isValid(): + parent = current.parent() + if parent.isValid(): + self.setCurrentIndex(parent) + event.accept() + return + if event.key() == Qt.Key.Key_Right: + if current.isValid(): + if self.model().hasChildren(current): + self.expand(current) + child = self.model().index(0, 0, current) + if child.isValid(): + self.setCurrentIndex(child) + event.accept() + return + super().keyPressEvent(event) diff --git a/sview/scanner.py b/sview/scanner.py index 0c66797..df10ce4 100644 --- a/sview/scanner.py +++ b/sview/scanner.py @@ -132,6 +132,7 @@ def _build_raw_items( self._raise_if_cancelled(cancel_check) try: is_directory = entry.is_dir() + stat_result = entry.stat() except OSError: continue items.append( @@ -144,8 +145,8 @@ def _build_raw_items( pad=None, count=0 if is_directory else 1, missing=None, - size_bytes=0, - modified_time=0.0, + size_bytes=0 if is_directory else int(stat_result.st_size), + modified_time=float(stat_result.st_mtime), child_paths=None, ) ) diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 225886d..eb9fa36 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -94,6 +94,31 @@ def test_directory_symlink_stays_a_folder(self) -> None: self.assertIs(linked_item.item_type, ItemType.DIRECTORY) self.assertEqual(linked_item.path, str(link)) + def test_raw_files_and_directories_include_basic_stat_metadata(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + folder = root / "folder" + folder.mkdir() + file_path = root / "notes.txt" + _touch(file_path) + + result = DirectoryScanner().scan(root) + + folder_item = next( + item for item in result.raw_items if item.name == "folder" + ) + file_item = next( + item for item in result.raw_items if item.name == "notes.txt" + ) + + self.assertIs(folder_item.item_type, ItemType.DIRECTORY) + self.assertGreater(folder_item.modified_time, 0.0) + self.assertEqual(folder_item.size_bytes, 0) + + self.assertIs(file_item.item_type, ItemType.FILE) + self.assertGreater(file_item.modified_time, 0.0) + self.assertGreater(file_item.size_bytes, 0) + def test_controller_can_expand_and_collapse_sequence(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) @@ -117,6 +142,33 @@ 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_controller_preserves_sequence_metadata_across_rescan(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + _touch(root / "plate.0001.jpg") + _touch(root / "plate.0003.jpg") + + controller = BrowserController(scanner=DirectoryScanner()) + first_items = controller.load_path(root) + sequence = next( + item for item in first_items if item.item_type is ItemType.SEQUENCE + ) + + controller.update_sequence_metadata( + sequence.path, + size_bytes=1234, + modified_time=42.0, + missing=[2], + ) + + rescanned_items = controller.load_path(root) + refreshed = next( + item for item in rescanned_items if item.item_type is ItemType.SEQUENCE + ) + self.assertEqual(refreshed.size_bytes, 1234) + self.assertEqual(refreshed.modified_time, 42.0) + self.assertEqual(refreshed.missing, [2]) + def test_scan_result_round_trip_serialization(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir)