From aef6f962f9e9a347f42b6a471090e55b03cde6cd Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sun, 5 Apr 2026 21:09:49 -0700 Subject: [PATCH 1/8] Add hidden-file controls and refine tree navigation behavior --- sview/qt/icon_view.py | 2 ++ sview/qt/main_window.py | 79 ++++++++++++++++++++++++++++++++++++++--- sview/qt/table.py | 22 +++++++++++- sview/qt/tree.py | 64 ++++++++++++++++++++++++++++++--- 4 files changed, 157 insertions(+), 10 deletions(-) diff --git a/sview/qt/icon_view.py b/sview/qt/icon_view.py index fc824c4..8c5f071 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() diff --git a/sview/qt/main_window.py b/sview/qt/main_window.py index 2a599e8..fe8d4ec 100644 --- a/sview/qt/main_window.py +++ b/sview/qt/main_window.py @@ -40,7 +40,7 @@ import subprocess import sys -from PySide6.QtCore import QProcess, QTimer, Qt, QUrl +from PySide6.QtCore import QPoint, QProcess, QTimer, Qt, QUrl from PySide6.QtGui import QAction, QCloseEvent, QDesktopServices, QGuiApplication from PySide6.QtWidgets import ( QFileDialog, @@ -54,7 +54,6 @@ QPushButton, QSplitter, QStackedWidget, - QProgressBar, QStyle, QToolButton, QVBoxLayout, @@ -109,6 +108,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 +136,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 = ( @@ -239,6 +242,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 +262,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() @@ -281,6 +295,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 +373,13 @@ 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._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._back_button.clicked.connect(self._go_back) self._home_button.clicked.connect(self._go_home) self._up_button.clicked.connect(self._go_up) @@ -481,6 +499,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 +644,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 +711,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 +746,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) + menu.addSeparator() + menu.addAction(self._show_hidden_action) + menu.addAction(self._preferences_action) + + help_menu = menu.addMenu("Help") + help_menu.addAction(self._help_repo_action) + help_menu.addAction(self._help_about_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: @@ -1155,6 +1217,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 +1246,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,6 +1285,10 @@ 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() diff --git a/sview/qt/table.py b/sview/qt/table.py index c9c9058..4f1407a 100644 --- a/sview/qt/table.py +++ b/sview/qt/table.py @@ -45,6 +45,22 @@ 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) @@ -100,6 +116,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 +142,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 +162,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() 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) From f27adb6d14d93234a0db374a169ddadfddc91e28 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sun, 5 Apr 2026 21:46:14 -0700 Subject: [PATCH 2/8] Release 0.2.1 with app icon and navigation polish --- CHANGELOG.md | 5 +++++ pyproject.toml | 5 ++++- sview/__init__.py | 2 +- sview/qt/app.py | 8 +++++++- sview/qt/main_window.py | 24 ++++++++++++++++++------ sview/qt/sview_icon.png | Bin 0 -> 39384 bytes 6 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 sview/qt/sview_icon.png 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/qt/app.py b/sview/qt/app.py index 200bfb5..ce1c652 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")) diff --git a/sview/qt/main_window.py b/sview/qt/main_window.py index fe8d4ec..442dff6 100644 --- a/sview/qt/main_window.py +++ b/sview/qt/main_window.py @@ -41,7 +41,7 @@ import sys from PySide6.QtCore import QPoint, QProcess, QTimer, Qt, QUrl -from PySide6.QtGui import QAction, QCloseEvent, QDesktopServices, QGuiApplication +from PySide6.QtGui import QAction, QCloseEvent, QDesktopServices, QGuiApplication, QPixmap from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, @@ -1329,11 +1329,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 0000000000000000000000000000000000000000..ea69ab88501c6b8a4ddcae6705b6fd980c1cd48a GIT binary patch literal 39384 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 literal 0 HcmV?d00001 From 0cc0a7d22a6ccc59abcc5cefe1b7c8ac31095843 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Mon, 6 Apr 2026 06:12:23 -0700 Subject: [PATCH 3/8] Polish toolbar menus and add a custom sidebar toggle icon --- sview/qt/icons.py | 47 +++++++++++++++++++++++++++++++++++++++++ sview/qt/main_window.py | 33 ++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 5 deletions(-) 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/main_window.py b/sview/qt/main_window.py index 442dff6..87b788f 100644 --- a/sview/qt/main_window.py +++ b/sview/qt/main_window.py @@ -41,7 +41,13 @@ import sys from PySide6.QtCore import QPoint, QProcess, QTimer, Qt, QUrl -from PySide6.QtGui import QAction, QCloseEvent, QDesktopServices, QGuiApplication, QPixmap +from PySide6.QtGui import ( + QAction, + QCloseEvent, + QDesktopServices, + QGuiApplication, + QPixmap, +) from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, @@ -72,6 +78,7 @@ 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 @@ -186,6 +193,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( @@ -280,6 +294,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) @@ -380,6 +395,7 @@ def _connect_signals(self) -> None: 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) @@ -764,13 +780,13 @@ def _show_toolbar_menu(self) -> None: edit_menu.addAction(self._edit_copy_path_action) edit_menu.addAction(self._edit_copy_pattern_action) edit_menu.addAction(self._edit_properties_action) - menu.addSeparator() - menu.addAction(self._show_hidden_action) - menu.addAction(self._preferences_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_repo_action) 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)) @@ -1293,11 +1309,18 @@ 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) From 2920c715ba230fe3a5dc97d1c33369b54c491ccc Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Mon, 6 Apr 2026 06:21:09 -0700 Subject: [PATCH 4/8] Improve table keyboard navigation and keep tree selection in sync --- sview/qt/main_window.py | 15 ++++++++++++++- sview/qt/table.py | 12 ++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/sview/qt/main_window.py b/sview/qt/main_window.py index 87b788f..0f9113b 100644 --- a/sview/qt/main_window.py +++ b/sview/qt/main_window.py @@ -410,6 +410,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) @@ -1045,7 +1047,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() diff --git a/sview/qt/table.py b/sview/qt/table.py index 4f1407a..8df0969 100644 --- a/sview/qt/table.py +++ b/sview/qt/table.py @@ -67,6 +67,8 @@ class ContentsTable(QTableWidget): filter_text_typed = Signal(str) filter_backspace_requested = Signal() filter_clear_requested = Signal() + activate_current_requested = Signal() + navigate_up_requested = Signal() HEADERS = [ "Name", @@ -201,6 +203,16 @@ 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() == 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): From c999ae59c05635c258c67356d551aa45aee81f6e Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Mon, 6 Apr 2026 06:26:02 -0700 Subject: [PATCH 5/8] Refine table keyboard bindings for enter and folder navigation --- sview/qt/table.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sview/qt/table.py b/sview/qt/table.py index 8df0969..a68a2b3 100644 --- a/sview/qt/table.py +++ b/sview/qt/table.py @@ -207,6 +207,11 @@ def keyPressEvent(self, event) -> None: 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: From 7162a0e1165922134bfd29cd9ce28b1c555c49d1 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Mon, 6 Apr 2026 06:33:31 -0700 Subject: [PATCH 6/8] Restore lightweight file and folder timestamps in the browser --- sview/scanner.py | 5 +++-- tests/test_scanner.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) 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..a3f1bf3 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) From 62b7687044a3781ee98a5fae549f1e7b150a3dee Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Mon, 6 Apr 2026 06:57:55 -0700 Subject: [PATCH 7/8] Improve sequence metadata display and fix table row highlight regression --- sview/controller.py | 37 ++++++++++++++++++++++++++++++--- sview/model.py | 20 ++++++++++++++++++ sview/qt/inspector.py | 16 +++++++++++---- sview/qt/main_window.py | 45 ++++++++++++++++++++++++++++++++++++++--- sview/qt/table.py | 25 +++++++++++++++-------- tests/test_scanner.py | 27 +++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 18 deletions(-) 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/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 0f9113b..d5f1649 100644 --- a/sview/qt/main_window.py +++ b/sview/qt/main_window.py @@ -82,7 +82,7 @@ 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): @@ -856,6 +856,11 @@ 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._inspector.set_item(item) @@ -873,12 +878,30 @@ 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._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() @@ -1137,8 +1160,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, @@ -1156,6 +1180,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: diff --git a/sview/qt/table.py b/sview/qt/table.py index a68a2b3..5b4c09a 100644 --- a/sview/qt/table.py +++ b/sview/qt/table.py @@ -38,10 +38,10 @@ 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 @@ -182,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: @@ -237,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/tests/test_scanner.py b/tests/test_scanner.py index a3f1bf3..eb9fa36 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -142,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) From 88d57f00ce5a7c9d9f4c98daffff46823c14c2ed Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Mon, 6 Apr 2026 07:05:45 -0700 Subject: [PATCH 8/8] Improve sequence metadata UX and refresh missing-state styling immediately --- sview/qt/app.py | 19 +++++++++++++++++++ sview/qt/icon_view.py | 12 +++++++++++- sview/qt/main_window.py | 9 +++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/sview/qt/app.py b/sview/qt/app.py index ce1c652..1d8f80c 100644 --- a/sview/qt/app.py +++ b/sview/qt/app.py @@ -300,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 8c5f071..5008090 100644 --- a/sview/qt/icon_view.py +++ b/sview/qt/icon_view.py @@ -99,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: @@ -120,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) @@ -132,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/main_window.py b/sview/qt/main_window.py index d5f1649..75f48f1 100644 --- a/sview/qt/main_window.py +++ b/sview/qt/main_window.py @@ -46,6 +46,7 @@ QCloseEvent, QDesktopServices, QGuiApplication, + QKeySequence, QPixmap, ) from PySide6.QtWidgets import ( @@ -183,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") @@ -392,6 +396,9 @@ def _connect_signals(self) -> None: 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) @@ -863,6 +870,7 @@ def _find_missing_for_selected_sequence(self) -> 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 @@ -897,6 +905,7 @@ def _get_size_for_selected_sequence(self) -> 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(