diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dde810..97b75b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.2.0 - 2026-04-05 + +- Refined the desktop UI with a darker theme, tighter toolbar, custom item icons, and cleaner loading feedback +- Added icon and detail browsing improvements including type-to-filter, breadcrumbs, and better context menu organization +- Enhanced the Properties panel with responsive image thumbnails, scrolling content, and on-demand sequence actions +- Improved path handling for symlinked directories and polished overall navigation, search, and panel behavior + ## 0.1.2 - 2026-04-05 - Moved directory scanning into a subprocess worker for safer cancellation diff --git a/pyproject.toml b/pyproject.toml index 2a1422b..83c1e1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sview" -version = "0.1.2" +version = "0.2.0" description = "Sequence-aware filesystem browser" readme = "README.md" requires-python = ">=3.8" diff --git a/sview.png b/sview.png index e3c0573..ec84879 100644 Binary files a/sview.png and b/sview.png differ diff --git a/sview/__init__.py b/sview/__init__.py index e961ee1..97f9142 100644 --- a/sview/__init__.py +++ b/sview/__init__.py @@ -35,4 +35,4 @@ on usability and a clean interface. """ -__version__ = "0.1.2" +__version__ = "0.2.0" diff --git a/sview/qt/app.py b/sview/qt/app.py index 545fe89..200bfb5 100644 --- a/sview/qt/app.py +++ b/sview/qt/app.py @@ -87,16 +87,16 @@ def _wants_debug(args: list[str]) -> bool: def _apply_dark_theme(app: QApplication) -> None: palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor("#14191e")) + palette.setColor(QPalette.ColorRole.Window, QColor("#101519")) 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.Base, QColor("#0d1216")) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#141b20")) 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.Button, QColor("#161d23")) palette.setColor(QPalette.ColorRole.ButtonText, QColor("#edf2f7")) - palette.setColor(QPalette.ColorRole.Highlight, QColor("#313b44")) + palette.setColor(QPalette.ColorRole.Highlight, QColor("#2a333b")) palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff")) palette.setColor(QPalette.ColorRole.PlaceholderText, QColor("#75818d")) app.setPalette(palette) @@ -106,35 +106,65 @@ def _apply_dark_theme(app: QApplication) -> None: font-size: 12px; } QTreeView, QTableWidget, QLineEdit, QPushButton, QLabel, QListWidget { - background-color: #1e252c; + background-color: #1b232a; color: #dbe2e8; } + QMenuBar { + background-color: #0f1519; + color: #d5dde4; + padding: 1px 4px; + } + QMenuBar::item { + background: transparent; + padding: 4px 8px; + } + QMenuBar::item:selected { + background-color: #1e272f; + } + QMenu { + background-color: #151c22; + color: #dbe2e8; + border: 1px solid #26303a; + } + QMenu::item { + padding: 6px 18px; + } + QMenu::item:selected { + background-color: #27313a; + } QMainWindow, QWidget#mainContent { - background-color: #13191f; + background-color: #0f1418; color: #dbe2e8; } QHeaderView::section { - background-color: #1e252c; + background-color: #171f26; color: #d4dde4; padding: 4px 6px; border: 0; - border-right: 1px solid #29333c; + border-right: 1px solid #222b33; } QTreeView, QTableWidget, QLineEdit, QListWidget { - background-color: #161d23; - border: 1px solid #222c34; + background-color: #141b20; + border: 1px solid #1f2830; border-radius: 3px; } QTreeView { background-color: #171f26; } + QLineEdit#searchInput { + background-color: #171e24; + border: 1px solid #202933; + border-radius: 6px; + padding: 0 12px; + font-size: 13px; + } QScrollBar:vertical { - background: #10161a; + background: #0e1317; width: 10px; margin: 2px; } QScrollBar::handle:vertical { - background: #36424c; + background: #34414a; min-height: 28px; border-radius: 4px; } @@ -159,23 +189,33 @@ def _apply_dark_theme(app: QApplication) -> None: border: none; } QTableWidget::item:selected, QTreeView::item:selected { - background-color: #2c353d; + background-color: #273039; color: white; } QPushButton { - background-color: #20282f; - border: 1px solid #29333c; + background-color: #1a2127; + border: 1px solid #25303a; border-radius: 3px; padding: 4px 9px; min-height: 24px; } + QPushButton#navButton, QPushButton#toolbarToggle { + background-color: transparent; + border: 0; + border-radius: 5px; + padding: 0; + min-height: 28px; + } + QPushButton#navButton:hover, QPushButton#toolbarToggle:hover { + background-color: #1e262d; + } QPushButton:checked { - background-color: #2b3640; + background-color: #263039; border-color: #34414d; } QPushButton:disabled { color: #697581; - background-color: #1a2127; + background-color: #151b20; } QToolButton { background: transparent; @@ -186,11 +226,48 @@ def _apply_dark_theme(app: QApplication) -> None: color: #d6dde4; } QStatusBar { - background-color: #141b20; + background-color: #10161b; color: #b6c1ca; } + QWidget#breadcrumbBar { + background-color: #11181d; + border: 1px solid #1e2831; + border-radius: 5px; + padding: 1px 4px; + } + QToolButton#breadcrumbButton { + background: transparent; + border: 0; + color: #cfd8e0; + padding: 2px 3px; + border-radius: 4px; + } + QToolButton#breadcrumbButton:hover { + background-color: #1f2931; + color: #f2f6f9; + } + QLabel#breadcrumbSeparator { + color: #6f7e8b; + padding: 0; + } + QLabel#loadingOverlay { + color: #8b99a5; + padding: 2px 0; + } + QProgressBar#loadingSpinner { + background: transparent; + border: 0; + min-width: 72px; + max-width: 72px; + min-height: 6px; + max-height: 6px; + } + QProgressBar#loadingSpinner::chunk { + background-color: #5f7f95; + border-radius: 3px; + } QSplitter::handle { - background-color: #36414a; + background-color: #2c3740; width: 1px; } QLabel#inspectorTitle { @@ -201,14 +278,21 @@ def _apply_dark_theme(app: QApplication) -> None: color: #8998a5; margin-bottom: 6px; } + QLabel#inspectorThumbnail { + background-color: #12181d; + border: 1px solid #202932; + border-radius: 6px; + color: #7f8d99; + padding: 6px; + } QWidget#iconCard { - background-color: #1c232a; - border: 1px solid #252f37; + background-color: #182026; + border: 1px solid #202a32; border-radius: 6px; } QWidget#iconCard:hover { - background-color: #212a32; - border-color: #2f3b45; + background-color: #1d262e; + border-color: #2b3741; } QLabel#iconCardTitle { font-size: 13px; diff --git a/sview/qt/icon_view.py b/sview/qt/icon_view.py index 58c6120..fc824c4 100644 --- a/sview/qt/icon_view.py +++ b/sview/qt/icon_view.py @@ -43,16 +43,19 @@ QLabel, QListWidget, QListWidgetItem, - QStyle, QVBoxLayout, QWidget, ) from sview.model import BrowserItem, ItemType +from sview.qt.icons import browser_item_icon class ContentsIconView(QListWidget): context_requested = Signal(object, object) + filter_text_typed = Signal(str) + filter_backspace_requested = Signal() + filter_clear_requested = Signal() CARD_WIDTH = 270 CARD_HEIGHT = 88 @@ -63,8 +66,9 @@ def __init__(self) -> None: self.setMovement(QListWidget.Movement.Static) self.setWrapping(True) self.setSelectionMode(QListWidget.SelectionMode.SingleSelection) - self.setSpacing(14) - self.setIconSize(QSize(44, 44)) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setSpacing(12) + self.setIconSize(QSize(52, 52)) self.setGridSize(QSize(self.CARD_WIDTH, self.CARD_HEIGHT)) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._emit_context_request) @@ -103,25 +107,24 @@ def _emit_context_request(self, position) -> None: self.setCurrentItem(item) self.context_requested.emit(browser_item, self.viewport().mapToGlobal(position)) + def keyPressEvent(self, event) -> None: + if self._handle_filter_key(event): + return + super().keyPressEvent(event) + 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) + return browser_item_icon(item, size=52) def _build_card(self, item: BrowserItem) -> QWidget: card = QWidget() card.setObjectName("iconCard") layout = QHBoxLayout(card) layout.setContentsMargins(14, 12, 14, 12) - layout.setSpacing(12) + layout.setSpacing(14) icon_label = QLabel() icon_label.setPixmap(self._icon(item).pixmap(self.iconSize())) - icon_label.setFixedSize(48, 48) + icon_label.setFixedSize(56, 56) icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) name_label = QLabel(item.display_name) @@ -182,3 +185,29 @@ def _format_mtime(timestamp: float) -> str: if timestamp <= 0: return "" return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M") + + def _handle_filter_key(self, event) -> bool: + if event.modifiers() & ( + Qt.KeyboardModifier.ControlModifier + | Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.MetaModifier + ): + return False + if event.key() == Qt.Key.Key_Backspace: + self.filter_backspace_requested.emit() + event.accept() + return True + if event.key() == Qt.Key.Key_Delete: + self.filter_clear_requested.emit() + event.accept() + return True + if event.key() == Qt.Key.Key_Escape: + self.filter_clear_requested.emit() + event.accept() + return True + text = event.text() + if text and text.isprintable(): + self.filter_text_typed.emit(text) + event.accept() + return True + return False diff --git a/sview/qt/icons.py b/sview/qt/icons.py new file mode 100644 index 0000000..8e89d61 --- /dev/null +++ b/sview/qt/icons.py @@ -0,0 +1,146 @@ +#!/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 lightweight custom icons used throughout the sview UI. +""" + +from __future__ import annotations + +from functools import lru_cache + +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap + +from sview.model import BrowserItem, ItemType + + +def browser_item_icon(item: BrowserItem, size: int = 18) -> QIcon: + if item.item_type is ItemType.DIRECTORY: + return _folder_icon(size) + if item.item_type is ItemType.SEQUENCE: + return _sequence_icon(size) + return _file_icon(size) + + +@lru_cache(maxsize=12) +def _folder_icon(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) + + tab = QColor("#cf6b6b") + body = QColor("#9e4b53") + painter.setBrush(tab) + painter.drawRoundedRect( + QRectF(size * 0.12, size * 0.20, size * 0.34, size * 0.20), 2, 2 + ) + painter.setBrush(body) + painter.drawRoundedRect( + QRectF(size * 0.08, size * 0.30, size * 0.84, size * 0.48), 2.5, 2.5 + ) + painter.end() + return QIcon(pixmap) + + +@lru_cache(maxsize=12) +def _file_icon(size: int) -> QIcon: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(QPen(QColor("#ced6de"), 1)) + painter.setBrush(QColor("#f2f5f8")) + + page = QPainterPath() + page.moveTo(size * 0.22, size * 0.10) + page.lineTo(size * 0.62, size * 0.10) + page.lineTo(size * 0.78, size * 0.26) + page.lineTo(size * 0.78, size * 0.88) + page.lineTo(size * 0.22, size * 0.88) + page.closeSubpath() + painter.drawPath(page) + + fold = QPainterPath() + fold.moveTo(size * 0.62, size * 0.10) + fold.lineTo(size * 0.62, size * 0.26) + fold.lineTo(size * 0.78, size * 0.26) + fold.closeSubpath() + painter.fillPath(fold, QColor("#dde5eb")) + painter.drawPath(fold) + painter.end() + return QIcon(pixmap) + + +@lru_cache(maxsize=12) +def _sequence_icon(size: int) -> QIcon: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(QPen(QColor("#d8e0e7"), 1)) + painter.setBrush(QColor("#f3f6f9")) + + page = QPainterPath() + page.moveTo(size * 0.18, size * 0.10) + page.lineTo(size * 0.68, size * 0.10) + page.lineTo(size * 0.82, size * 0.24) + page.lineTo(size * 0.82, size * 0.90) + page.lineTo(size * 0.18, size * 0.90) + page.closeSubpath() + painter.drawPath(page) + + fold = QPainterPath() + fold.moveTo(size * 0.68, size * 0.10) + fold.lineTo(size * 0.68, size * 0.24) + fold.lineTo(size * 0.82, size * 0.24) + fold.closeSubpath() + painter.fillPath(fold, QColor("#dde6ec")) + painter.drawPath(fold) + + painter.setPen(QPen(QColor("#2f3b46"), max(1, size // 14))) + for offset in (0.34, 0.50, 0.66): + painter.drawLine( + QPointF(size * 0.28, size * offset), + QPointF(size * 0.72, size * offset), + ) + + badge = QRectF(size * 0.18, size * 0.70, size * 0.30, size * 0.16) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor("#4d8fb5")) + painter.drawRoundedRect(badge, 2, 2) + painter.end() + return QIcon(pixmap) diff --git a/sview/qt/inspector.py b/sview/qt/inspector.py index 540ec15..f19a683 100644 --- a/sview/qt/inspector.py +++ b/sview/qt/inspector.py @@ -37,12 +37,15 @@ from pathlib import Path -from PySide6.QtCore import Qt +from PySide6.QtCore import QEvent, QSize, Qt +from PySide6.QtGui import QImageReader, QPixmap from PySide6.QtWidgets import ( QFormLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, + QSizePolicy, QStyle, QVBoxLayout, QWidget, @@ -52,11 +55,15 @@ class InspectorPanel(QWidget): + MIN_THUMBNAIL_HEIGHT = 140 + def __init__(self) -> None: super().__init__() self._title = QLabel("Properties") self._subtitle = QLabel("Select an item to inspect") self._current_item: BrowserItem | None = None + self._thumbnail_pixmap: QPixmap | None = None + self._thumbnail_source_path: str | None = None self._type_value = QLabel("-") self._range_value = QLabel("-") @@ -85,13 +92,29 @@ def __init__(self) -> None: layout.setSpacing(6) self._title.setObjectName("inspectorTitle") self._subtitle.setObjectName("inspectorSubtitle") + self._thumbnail = QLabel("No preview") + self._thumbnail.setObjectName("inspectorThumbnail") + self._thumbnail.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._thumbnail.setMinimumHeight(self.MIN_THUMBNAIL_HEIGHT) + self._thumbnail.setMaximumHeight(16777215) + self._thumbnail.setMinimumWidth(0) + self._thumbnail.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self._thumbnail.setWordWrap(True) header_row = QHBoxLayout() header_row.addWidget(self._title) header_row.addStretch(1) header_row.addWidget(self.close_button) layout.addLayout(header_row) - layout.addWidget(self._subtitle) + + body = QWidget() + body_layout = QVBoxLayout(body) + body_layout.setContentsMargins(0, 0, 0, 0) + body_layout.setSpacing(6) + body_layout.addWidget(self._subtitle) + body_layout.addWidget(self._thumbnail) form = QFormLayout() form.setVerticalSpacing(4) @@ -105,24 +128,38 @@ def __init__(self) -> None: form.addRow("Size", self._size_value) form.addRow("Modified", self._modified_value) form.addRow("Path", self._path_value) - layout.addLayout(form) - layout.addStretch(1) + body_layout.addLayout(form) button_row = QHBoxLayout() button_row.addWidget(self.copy_path_button) button_row.addWidget(self.copy_pattern_button) - layout.addLayout(button_row) + body_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) + body_layout.addLayout(metadata_row) + body_layout.addWidget(self.expand_button) + body_layout.addStretch(1) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QScrollArea.Shape.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._scroll.setWidget(body) + self._scroll.viewport().installEventFilter(self) + layout.addWidget(self._scroll, 1) self.clear_details() def clear_details(self) -> None: self._title.setText("Properties") self._subtitle.setText("Select an item to inspect") self._current_item = None + self._thumbnail_pixmap = None + self._thumbnail_source_path = None + self._thumbnail.setPixmap(QPixmap()) + self._thumbnail.setText("No preview") + self._thumbnail.setMinimumHeight(self.MIN_THUMBNAIL_HEIGHT) + self._thumbnail.setMaximumHeight(16777215) for label in ( self._type_value, self._range_value, @@ -155,6 +192,7 @@ def set_item(self, item: BrowserItem) -> None: self._size_value.setText(self._format_size(item.size_bytes)) self._modified_value.setText(self._format_modified(item.modified_time)) self._path_value.setText(str(Path(item.path))) + self._update_thumbnail(item) self.copy_path_button.setEnabled(True) self.copy_pattern_button.setEnabled(item.item_type is ItemType.SEQUENCE) @@ -171,6 +209,15 @@ def set_item(self, item: BrowserItem) -> None: def current_item(self) -> BrowserItem | None: return self._current_item + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._apply_thumbnail_pixmap() + + def eventFilter(self, watched, event) -> bool: + if watched is self._scroll.viewport() and event.type() == QEvent.Type.Resize: + self._apply_thumbnail_pixmap() + return super().eventFilter(watched, event) + @staticmethod def _type_text(item: BrowserItem) -> str: if item.item_type is ItemType.SEQUENCE: @@ -207,3 +254,68 @@ def _format_modified(timestamp: float) -> str: if timestamp <= 0: return "-" return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M") + + def _update_thumbnail(self, item: BrowserItem) -> None: + preview_path = self._preview_path(item) + if preview_path is None: + self._thumbnail_pixmap = None + self._thumbnail_source_path = None + self._thumbnail.setPixmap(QPixmap()) + self._thumbnail.setText("No preview") + return + + self._thumbnail_source_path = preview_path + self._apply_thumbnail_pixmap() + + def _apply_thumbnail_pixmap(self) -> None: + if self._thumbnail_source_path is None: + return + available_width = max(120, self._scroll.viewport().width() - 28) + reader = QImageReader(self._thumbnail_source_path) + reader.setAutoTransform(True) + if not reader.canRead(): + self._thumbnail_pixmap = None + self._thumbnail.setPixmap(QPixmap()) + self._thumbnail.setText("Preview unavailable") + return + source_size = reader.size() + if source_size.isValid() and source_size.width() > 0: + target_height = max( + self.MIN_THUMBNAIL_HEIGHT, + int(source_size.height() * (available_width / source_size.width())), + ) + reader.setScaledSize(QSize(available_width, target_height)) + pixmap = QPixmap.fromImageReader(reader) + if pixmap.isNull(): + self._thumbnail_pixmap = None + self._thumbnail.setPixmap(QPixmap()) + self._thumbnail.setText("Preview unavailable") + return + self._thumbnail_pixmap = pixmap + frame_height = max(self.MIN_THUMBNAIL_HEIGHT, pixmap.height() + 12) + self._thumbnail.setMinimumHeight(frame_height) + self._thumbnail.setMaximumHeight(frame_height) + self._thumbnail.setText("") + self._thumbnail.setPixmap(pixmap) + + @staticmethod + def _preview_path(item: BrowserItem) -> str | None: + if item.item_type is ItemType.SEQUENCE and item.child_paths: + candidate = Path(item.child_paths[0]) + else: + candidate = Path(item.path) + + if not candidate.is_file(): + return None + if candidate.suffix.lower() not in { + ".jpg", + ".jpeg", + ".png", + ".tif", + ".tiff", + ".bmp", + ".gif", + ".webp", + }: + return None + return str(candidate) diff --git a/sview/qt/main_window.py b/sview/qt/main_window.py index c8336bd..2a599e8 100644 --- a/sview/qt/main_window.py +++ b/sview/qt/main_window.py @@ -54,10 +54,12 @@ QPushButton, QSplitter, QStackedWidget, + QProgressBar, QStyle, QToolButton, QVBoxLayout, QWidget, + QSizePolicy, ) from sview import __version__ @@ -113,10 +115,24 @@ def __init__( self._filter_input = QLineEdit() self._filter_input.setPlaceholderText("Search") + self._filter_input.setObjectName("searchInput") + self._filter_input.setMinimumWidth(360) + self._filter_input.setMaximumWidth(460) + self._filter_input.setFixedHeight(34) + self._clear_filter_action = self._filter_input.addAction( + self.style().standardIcon(QStyle.StandardPixmap.SP_LineEditClearButton), + QLineEdit.ActionPosition.TrailingPosition, + ) + self._clear_filter_action.setVisible(False) self._table = ContentsTable() self._icon_view = ContentsIconView() self._center_stack = QStackedWidget() + self._breadcrumb_bar = QWidget() + self._breadcrumb_bar.setObjectName("breadcrumbBar") + self._breadcrumb_layout = QHBoxLayout(self._breadcrumb_bar) + self._breadcrumb_layout.setContentsMargins(0, 0, 0, 0) + self._breadcrumb_layout.setSpacing(2) self._inspector = InspectorPanel() self._tree_title = QLabel("Folders") self._tree_toggle = QToolButton() @@ -126,16 +142,18 @@ def __init__( self._sidebar_restore_width = ( self._coerce_int(self._ui_state.get("sidebar_width")) or self.SIDEBAR_WIDTH ) + self._loading_overlay = QLabel("Loading…") + self._loading_overlay.setObjectName("loadingOverlay") + self._loading_spinner = QProgressBar() + self._loading_spinner.setObjectName("loadingSpinner") + self._loading_spinner.setRange(0, 0) + self._loading_spinner.setTextVisible(False) + self._loading_spinner.hide() + self._loading_overlay.hide() - self._progress_timer = QTimer(self) - 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 self._build_toolbar() self._build_menu_bar() @@ -159,36 +177,42 @@ def _build_toolbar(self) -> None: self.addAction(self._refresh_action) self._back_button = QPushButton() + self._back_button.setObjectName("navButton") self._back_button.setIcon( self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack) ) self._back_button.setToolTip("Back") self._back_button.setFixedWidth(28) self._home_button = QPushButton() + self._home_button.setObjectName("navButton") self._home_button.setIcon( self.style().standardIcon(QStyle.StandardPixmap.SP_DirHomeIcon) ) self._home_button.setToolTip("Home") self._home_button.setFixedWidth(28) self._up_button = QPushButton() + self._up_button.setObjectName("navButton") self._up_button.setIcon( self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp) ) self._up_button.setToolTip("Up") self._up_button.setFixedWidth(28) self._open_button = QPushButton() + self._open_button.setObjectName("navButton") self._open_button.setIcon( self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon) ) self._open_button.setToolTip("Open Folder") self._open_button.setFixedWidth(32) self._refresh_button = QPushButton() + self._refresh_button.setObjectName("navButton") self._refresh_button.setIcon( self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload) ) self._refresh_button.setToolTip("Refresh") self._refresh_button.setFixedWidth(32) self._content_mode_toggle = QPushButton() + self._content_mode_toggle.setObjectName("toolbarToggle") self._content_mode_toggle.setIcon( self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView) ) @@ -197,6 +221,7 @@ def _build_toolbar(self) -> None: self._content_mode_toggle.setChecked(True) self._content_mode_toggle.setFixedWidth(32) self._layout_mode_toggle = QPushButton() + self._layout_mode_toggle.setObjectName("toolbarToggle") self._layout_mode_toggle.setIcon( self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView) ) @@ -207,19 +232,13 @@ def _build_toolbar(self) -> None: ) self._layout_mode_toggle.setFixedWidth(32) self._stop_button = QPushButton() + self._stop_button.setObjectName("toolbarToggle") self._stop_button.setIcon( self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop) ) self._stop_button.setToolTip("Stop") self._stop_button.setFixedWidth(32) self._stop_button.setEnabled(False) - self._progress_bar = QProgressBar() - self._progress_bar.setRange(0, 100) - self._progress_bar.setValue(0) - self._progress_bar.setMaximumWidth(84) - self._progress_bar.setFixedHeight(12) - self._progress_bar.setTextVisible(False) - self._progress_bar.hide() def _build_menu_bar(self) -> None: menu_bar = self.menuBar() @@ -243,23 +262,24 @@ def _build_layout(self) -> None: center = QWidget() root_layout = QVBoxLayout(center) root_layout.setContentsMargins(8, 8, 8, 8) - root_layout.setSpacing(6) + root_layout.setSpacing(4) toolbar_row = QHBoxLayout() - toolbar_row.setSpacing(6) + toolbar_row.setSpacing(4) toolbar_row.addWidget(self._back_button) toolbar_row.addWidget(self._up_button) toolbar_row.addWidget(self._home_button) toolbar_row.addWidget(self._open_button) toolbar_row.addWidget(self._refresh_button) - toolbar_row.addSpacing(6) + toolbar_row.addSpacing(4) toolbar_row.addWidget(self._content_mode_toggle) - toolbar_row.addSpacing(6) + toolbar_row.addSpacing(4) toolbar_row.addWidget(self._layout_mode_toggle) toolbar_row.addWidget(self._stop_button) - toolbar_row.addWidget(self._progress_bar) - toolbar_row.addStretch(1) - self._filter_input.setMaximumWidth(320) + self._breadcrumb_bar.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + toolbar_row.addWidget(self._breadcrumb_bar, 1) toolbar_row.addWidget(self._filter_input) root_layout.addLayout(toolbar_row) @@ -294,27 +314,35 @@ def _build_layout(self) -> None: self._center_stack.setCurrentWidget( self._icon_view if not self._layout_mode_toggle.isChecked() else self._table ) + center_panel = QWidget() + center_layout = QVBoxLayout(center_panel) + center_layout.setContentsMargins(0, 0, 0, 0) + center_layout.setSpacing(0) + center_layout.addWidget(self._center_stack, 1) splitter.addWidget(sidebar) - splitter.addWidget(self._center_stack) + splitter.addWidget(center_panel) splitter.addWidget(self._inspector) splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 5) splitter.setStretchFactor(2, 3) self._main_splitter = splitter splitter.setCollapsible(0, True) - splitter.setSizes( - self._coerce_splitter_sizes( - self._ui_state.get("main_splitter_sizes"), - [self._sidebar_restore_width, 900, 0], - ) + initial_sizes = self._coerce_splitter_sizes( + self._ui_state.get("main_splitter_sizes"), + [self._sidebar_restore_width, 900, 0], ) + if self._sidebar_expanded and initial_sizes[0] <= 0: + initial_sizes[0] = self._sidebar_restore_width + splitter.setSizes(initial_sizes) if not self._sidebar_expanded: self._apply_sidebar_state(False) root_layout.addWidget(splitter, 1) self.setCentralWidget(center) center.setObjectName("mainContent") + self.statusBar().addPermanentWidget(self._loading_overlay) + self.statusBar().addPermanentWidget(self._loading_spinner) self.statusBar().showMessage("Ready") width = self._coerce_int(self._ui_state.get("window_width")) height = self._coerce_int(self._ui_state.get("window_height")) @@ -340,12 +368,20 @@ def _connect_signals(self) -> None: self._stop_button.clicked.connect(self._cancel_scan) self._content_mode_toggle.toggled.connect(self._toggle_content_mode) self._filter_input.textChanged.connect(self._apply_filter) + self._filter_input.textChanged.connect(self._update_filter_clear_action) + self._clear_filter_action.triggered.connect(self._clear_filter_text) 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._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._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._icon_view.filter_text_typed.connect(self._append_filter_text) + self._icon_view.filter_backspace_requested.connect(self._delete_filter_text) + self._icon_view.filter_clear_requested.connect(self._clear_filter_text) self._tree.selectionModel().selectionChanged.connect( self._handle_tree_selection ) @@ -439,6 +475,7 @@ def _toggle_layout_mode(self, detail_view: bool) -> None: self._table if detail_view else self._icon_view ) self._save_ui_state() + QTimer.singleShot(0, self._focus_active_browser) def _handle_tree_selection(self) -> None: index = self._tree.currentIndex() @@ -585,7 +622,10 @@ def _handle_finished_scan(self, result: object) -> None: self._push_history(str(result.path)) self._active_request = None self._sync_tree_to_path(str(result.path)) - self._apply_filter(self._filter_input.text()) + self._update_breadcrumbs(result.path) + self._filter_input.clear() + self._apply_filter("") + QTimer.singleShot(0, self._focus_active_browser) self._drain_pending_request() def _handle_failed_scan(self, error_message: str) -> None: @@ -655,6 +695,32 @@ def _apply_filter(self, text: str) -> None: self._inspector.clear_details() self._update_status_bar() + def _append_filter_text(self, text: str) -> None: + if not text or not self._filter_input.isEnabled(): + return + self._filter_input.setFocus(Qt.FocusReason.ShortcutFocusReason) + self._filter_input.setText(self._filter_input.text() + text) + self._filter_input.setCursorPosition(len(self._filter_input.text())) + + def _delete_filter_text(self) -> None: + if not self._filter_input.isEnabled(): + return + current = self._filter_input.text() + if not current: + return + self._filter_input.setFocus(Qt.FocusReason.ShortcutFocusReason) + self._filter_input.setText(current[:-1]) + self._filter_input.setCursorPosition(len(self._filter_input.text())) + + def _clear_filter_text(self) -> None: + if not self._filter_input.isEnabled() or not self._filter_input.text(): + return + self._filter_input.setFocus(Qt.FocusReason.ShortcutFocusReason) + self._filter_input.clear() + + def _update_filter_clear_action(self, text: str) -> None: + self._clear_filter_action.setVisible(bool(text)) + def _sync_inspector(self) -> None: item = self._current_browser_item() if item is None: @@ -763,10 +829,10 @@ def _show_item_context_menu( and self._controller.expanded_sequence.path == item.path else "Expand Sequence" ) - menu.addSeparator() - sstat_action = menu.addAction("sstat") - scopy_action = menu.addAction("scopy...") - smove_action = menu.addAction("smove...") + sequence_menu = menu.addMenu("Sequence") + sstat_action = sequence_menu.addAction("sstat") + scopy_action = sequence_menu.addAction("scopy...") + smove_action = sequence_menu.addAction("smove...") menu.addSeparator() properties_action = menu.addAction("Properties") @@ -926,16 +992,12 @@ def _set_loading_state(self, loading: bool, path: str | None = None) -> None: self._filter_input.setEnabled(not loading) self._tree.setEnabled(not loading) self._table.setEnabled(not loading) + self._icon_view.setEnabled(not loading) self._stop_button.setEnabled(loading) - self._progress_bar.setVisible(loading) + self._loading_spinner.setVisible(loading) + self._loading_overlay.setVisible(loading) if loading: - self._busy_value = 0 - self._busy_direction = 1 - self._progress_bar.setValue(self._busy_value) - self._busy_timer.start() - else: - self._busy_timer.stop() - self._progress_bar.setValue(0) + self._loading_overlay.setText("Loading…") if loading and path is not None: self.statusBar().showMessage(f"Loading {path}...") @@ -1030,16 +1092,6 @@ def _coerce_missing_list(cls, value: object) -> list[int]: frames.append(coerced) return frames - def _advance_busy_indicator(self) -> None: - self._busy_value += self._busy_direction * 4 - if self._busy_value >= 100: - self._busy_value = 100 - self._busy_direction = -1 - elif self._busy_value <= 0: - self._busy_value = 0 - self._busy_direction = 1 - self._progress_bar.setValue(self._busy_value) - def _close_inspector(self) -> None: if self._main_splitter is None: return @@ -1086,16 +1138,18 @@ def _clear_scan_process(self) -> None: def _save_ui_state(self) -> None: sidebar_width = self._sidebar_restore_width + splitter_sizes = [self.SIDEBAR_WIDTH, 900, 0] if self._main_splitter is not None: sizes = self._main_splitter.sizes() + splitter_sizes = list(sizes) if sizes and sizes[0] > 0: sidebar_width = sizes[0] + elif self._sidebar_expanded: + splitter_sizes[0] = sidebar_width 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], + "main_splitter_sizes": splitter_sizes, "center_view": "icons" if not self._layout_mode_toggle.isChecked() else "details", @@ -1124,6 +1178,46 @@ def _current_browser_item(self) -> BrowserItem | None: return self._icon_view.current_browser_item() return self._table.current_browser_item() + def _focus_active_browser(self) -> None: + widget = self._center_stack.currentWidget() + if widget is not None and widget.isEnabled(): + widget.setFocus(Qt.FocusReason.OtherFocusReason) + + def _update_breadcrumbs(self, path: Path) -> None: + while self._breadcrumb_layout.count(): + item = self._breadcrumb_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + normalized = Path(path) + parts = [normalized.anchor] if normalized.anchor else [] + parts.extend(part for part in normalized.parts[len(parts) :] if part) + + current_path = Path(parts[0]) if parts else normalized + for index, part in enumerate(parts): + if index > 0: + separator = QLabel("/") + separator.setObjectName("breadcrumbSeparator") + self._breadcrumb_layout.addWidget(separator) + current_path = current_path / part + + button = QToolButton() + button.setObjectName("breadcrumbButton") + button.setAutoRaise(True) + button.setText( + "Home" if current_path == Path.home() else part.rstrip("/") or "/" + ) + button.setToolTip(str(current_path)) + button.clicked.connect( + lambda _checked=False, target=str( + current_path + ): self._request_directory(target, add_to_history=True) + ) + self._breadcrumb_layout.addWidget(button) + + self._breadcrumb_layout.addStretch(1) + def _toggle_sidebar(self, expanded: bool) -> None: self._apply_sidebar_state(expanded) self._save_ui_state() @@ -1147,7 +1241,11 @@ def _apply_sidebar_state(self, expanded: bool) -> None: return if expanded: target = min(self.SIDEBAR_WIDTH, max(220, self._sidebar_restore_width)) - sizes[1] = max(0, sizes[1] - target) + current_sidebar = sizes[0] + if current_sidebar <= 0: + sizes[1] = max(0, sizes[1] - target) + else: + sizes[1] = max(0, sizes[1] + current_sidebar - target) sizes[0] = target else: if sizes[0] > 0: diff --git a/sview/qt/table.py b/sview/qt/table.py index 1d03230..c9c9058 100644 --- a/sview/qt/table.py +++ b/sview/qt/table.py @@ -39,14 +39,18 @@ from PySide6.QtCore import QTimer, Qt, Signal from PySide6.QtGui import QColor -from PySide6.QtWidgets import QHeaderView, QStyle, QTableWidget, QTableWidgetItem +from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem from sview.model import BrowserItem, ItemType +from sview.qt.icons import browser_item_icon 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() HEADERS = [ "Name", @@ -79,7 +83,7 @@ def __init__(self) -> None: self.setColumnWidth(6, 140) self.setShowGrid(False) self.setAlternatingRowColors(False) - self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setWordWrap(False) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._emit_context_request) @@ -174,14 +178,13 @@ def _emit_context_request(self, position) -> None: self.selectRow(table_item.row()) self.context_requested.emit(item, self.viewport().mapToGlobal(position)) + def keyPressEvent(self, event) -> None: + if self._handle_filter_key(event): + return + super().keyPressEvent(event) + def _item_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) + return browser_item_icon(item, size=18) @staticmethod def _type_label(item: BrowserItem) -> str: @@ -221,3 +224,29 @@ def _format_mtime(timestamp: float) -> str: if timestamp <= 0: return "" return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M") + + def _handle_filter_key(self, event) -> bool: + if event.modifiers() & ( + Qt.KeyboardModifier.ControlModifier + | Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.MetaModifier + ): + return False + if event.key() == Qt.Key.Key_Backspace: + self.filter_backspace_requested.emit() + event.accept() + return True + if event.key() == Qt.Key.Key_Delete: + self.filter_clear_requested.emit() + event.accept() + return True + if event.key() == Qt.Key.Key_Escape: + self.filter_clear_requested.emit() + event.accept() + return True + text = event.text() + if text and text.isprintable(): + self.filter_text_typed.emit(text) + event.accept() + return True + return False diff --git a/sview/scanner.py b/sview/scanner.py index 6f74f8a..0c66797 100644 --- a/sview/scanner.py +++ b/sview/scanner.py @@ -131,7 +131,7 @@ def _build_raw_items( for entry in entries: self._raise_if_cancelled(cancel_check) try: - is_directory = entry.is_dir(follow_symlinks=False) + is_directory = entry.is_dir() except OSError: continue items.append( diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 27369c5..225886d 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -75,6 +75,25 @@ def test_sequence_range_handles_frame_zero(self) -> None: ) self.assertEqual(sequence.frame_range, "0-1") + def test_directory_symlink_stays_a_folder(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + target = root / "target" + target.mkdir() + link = root / "linked-target" + try: + link.symlink_to(target, target_is_directory=True) + except (NotImplementedError, OSError): + self.skipTest("Symlinks are not available in this environment.") + + result = DirectoryScanner().scan(root) + + linked_item = next( + item for item in result.raw_items if item.name == link.name + ) + self.assertIs(linked_item.item_type, ItemType.DIRECTORY) + self.assertEqual(linked_item.path, str(link)) + def test_controller_can_expand_and_collapse_sequence(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir)