330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""Filesystem pane with breadcrumb navigation, history and live filter.
|
||
|
||
This is one half of ISO Scalpel's split-nav interface. It browses the
|
||
host filesystem and emits "add to image" requests when the user drags
|
||
selections onto the ISO pane (or invokes the context-menu action).
|
||
|
||
The pane provides:
|
||
* a clickable breadcrumb path bar (jump to any ancestor),
|
||
* back / forward / up navigation buttons with a per-pane history,
|
||
* a live filter box that narrows the current listing by name,
|
||
* drag-out support so entries can be dropped onto the ISO pane.
|
||
"""
|
||
|
||
# This file is part of ISO Scalpel.
|
||
# Copyright (C) 2025 Jeremy Anderson <info@dcos.net>
|
||
#
|
||
# This program is free software; you can redistribute it and/or modify
|
||
# it under the terms of the GNU General Public License as published by
|
||
# the Free Software Foundation; either version 2 of the License, or
|
||
# (at your option) any later version.
|
||
#
|
||
# This program is distributed in the hope that it will be useful,
|
||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
# GNU General Public License for more details.
|
||
#
|
||
# You should have received a copy of the GNU General Public License
|
||
# along with this program; if not, see <https://www.gnu.org/licenses/>
|
||
# or write to the Free Software Foundation, Inc., 51 Franklin Street,
|
||
# Fifth Floor, Boston, MA 02110-1301 USA.
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
from PySide6.QtCore import QDir, QModelIndex, QSortFilterProxyModel, Qt, Signal
|
||
from PySide6.QtGui import QAction, QKeySequence
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QFileSystemModel,
|
||
QFrame,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QLabel,
|
||
QLineEdit,
|
||
QMenu,
|
||
QPushButton,
|
||
QSizePolicy,
|
||
QToolButton,
|
||
QTreeView,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from ..config import Settings
|
||
|
||
|
||
class FsPane(QWidget):
|
||
"""Browse the host filesystem; emit add-to-image requests."""
|
||
|
||
addRequested = Signal(list) # list[str] of local paths to add
|
||
pathChanged = Signal(str) # current directory
|
||
navChanged = Signal() # back/forward availability changed
|
||
|
||
def __init__(self, settings: Settings, parent=None):
|
||
super().__init__(parent)
|
||
self._settings = settings
|
||
self._history: list[str] = []
|
||
self._history_idx = -1
|
||
|
||
# --- model + proxy filter ----------------------------------------
|
||
self._model = QFileSystemModel()
|
||
self._model.setRootPath("")
|
||
self._filter = _NameFilterProxy()
|
||
self._filter.setSourceModel(self._model)
|
||
|
||
# --- view --------------------------------------------------------
|
||
self.view = QTreeView()
|
||
self.view.setModel(self._filter)
|
||
self.view.setSortingEnabled(True)
|
||
self.view.setRootIsDecorated(True)
|
||
self.view.setAlternatingRowColors(True)
|
||
self.view.setSelectionMode(QTreeView.ExtendedSelection)
|
||
self.view.setDragEnabled(True)
|
||
self.view.setDragDropMode(QTreeView.DragOnly)
|
||
self.view.setUniformRowHeights(True)
|
||
self.view.sortByColumn(0, Qt.AscendingOrder)
|
||
self.view.setColumnWidth(0, 260)
|
||
self.view.doubleClicked.connect(self._on_double_click)
|
||
self.view.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.view.customContextMenuRequested.connect(self._on_context)
|
||
# Column sizing: the Name column stretches to fill available
|
||
# width; Size / Type / Date Modified auto-fit to their contents
|
||
# so they're never cramped or absurdly wide.
|
||
hdr = self.view.header()
|
||
hdr.setStretchLastSection(False)
|
||
hdr.setSectionResizeMode(0, QHeaderView.Stretch)
|
||
for col in (1, 2, 3):
|
||
if col < hdr.count():
|
||
hdr.setSectionResizeMode(col, QHeaderView.ResizeToContents)
|
||
|
||
# --- navigation buttons -----------------------------------------
|
||
self.back_btn = QToolButton(arrowType=Qt.LeftArrow)
|
||
self.back_btn.setToolTip("Back (Alt+Left)")
|
||
self.back_btn.clicked.connect(self.go_back)
|
||
self.fwd_btn = QToolButton(arrowType=Qt.RightArrow)
|
||
self.fwd_btn.setToolTip("Forward (Alt+Right)")
|
||
self.fwd_btn.clicked.connect(self.go_forward)
|
||
self.up_btn = QToolButton(arrowType=Qt.UpArrow)
|
||
self.up_btn.setToolTip("Up (Alt+Up)")
|
||
self.up_btn.clicked.connect(self.go_up)
|
||
|
||
# --- breadcrumb bar ---------------------------------------------
|
||
self._crumb_layout = QHBoxLayout()
|
||
self._crumb_layout.setSpacing(0)
|
||
self._crumb_layout.setContentsMargins(0, 0, 0, 0)
|
||
self._crumb_frame = QFrame()
|
||
self._crumb_frame.setObjectName("Breadcrumb")
|
||
self._crumb_frame.setLayout(self._crumb_layout)
|
||
|
||
# --- filter box --------------------------------------------------
|
||
self.filter_edit = QLineEdit()
|
||
self.filter_edit.setPlaceholderText("Filter…")
|
||
self.filter_edit.setClearButtonEnabled(True)
|
||
self.filter_edit.textChanged.connect(self._filter.set_filter)
|
||
self.filter_edit.setMaxLength(200)
|
||
# Cap the filter box width so it doesn't stretch absurdly wide
|
||
# on large windows -- the breadcrumb bar should get the bulk of
|
||
# the available horizontal space.
|
||
self.filter_edit.setMaximumWidth(220)
|
||
self.filter_edit.setMinimumWidth(120)
|
||
|
||
# --- nav bar (buttons + breadcrumb + filter) --------------------
|
||
bar = QHBoxLayout()
|
||
bar.setSpacing(4)
|
||
bar.setContentsMargins(4, 2, 4, 2)
|
||
bar.addWidget(self.back_btn)
|
||
bar.addWidget(self.fwd_btn)
|
||
bar.addWidget(self.up_btn)
|
||
bar.addWidget(self._crumb_frame, 1)
|
||
bar.addWidget(self.filter_edit, 0)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
layout.addLayout(bar)
|
||
layout.addWidget(self.view, 1)
|
||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||
|
||
self.set_path(settings.last_dir)
|
||
|
||
# ------------------------------------------------------------------ navigation
|
||
def set_path(self, path: str) -> None:
|
||
path = os.path.abspath(path or os.getcwd())
|
||
if not os.path.isdir(path):
|
||
return
|
||
idx = self._model.index(path)
|
||
if not idx.isValid():
|
||
return
|
||
src_root = idx
|
||
proxy_root = self._filter.mapFromSource(src_root)
|
||
self.view.setRootIndex(proxy_root if proxy_root.isValid() else src_root)
|
||
if not self._history or self._history[self._history_idx] != path:
|
||
self._history = self._history[: self._history_idx + 1]
|
||
self._history.append(path)
|
||
self._history_idx = len(self._history) - 1
|
||
self._settings.last_dir = path
|
||
self._rebuild_breadcrumbs(path)
|
||
self.pathChanged.emit(path)
|
||
self.navChanged.emit()
|
||
|
||
def current_path(self) -> str:
|
||
if 0 <= self._history_idx < len(self._history):
|
||
return self._history[self._history_idx]
|
||
return self._settings.last_dir
|
||
|
||
def go_up(self) -> None:
|
||
parent = os.path.dirname(self.current_path())
|
||
if parent and parent != self.current_path():
|
||
self.set_path(parent)
|
||
|
||
def go_back(self) -> None:
|
||
if self._history_idx > 0:
|
||
self._history_idx -= 1
|
||
self._apply_history()
|
||
|
||
def go_forward(self) -> None:
|
||
if self._history_idx < len(self._history) - 1:
|
||
self._history_idx += 1
|
||
self._apply_history()
|
||
|
||
def can_back(self) -> bool:
|
||
return self._history_idx > 0
|
||
|
||
def can_forward(self) -> bool:
|
||
return self._history_idx < len(self._history) - 1
|
||
|
||
def _apply_history(self) -> None:
|
||
path = self._history[self._history_idx]
|
||
idx = self._model.index(path)
|
||
if idx.isValid():
|
||
proxy = self._filter.mapFromSource(idx)
|
||
self.view.setRootIndex(proxy if proxy.isValid() else idx)
|
||
self._settings.last_dir = path
|
||
self._rebuild_breadcrumbs(path)
|
||
self.pathChanged.emit(path)
|
||
self.navChanged.emit()
|
||
|
||
def _on_double_click(self, proxy_idx: QModelIndex) -> None:
|
||
src = self._filter.mapToSource(proxy_idx)
|
||
path = self._model.filePath(src)
|
||
if os.path.isdir(path):
|
||
self.set_path(path)
|
||
|
||
# ------------------------------------------------------------------ breadcrumbs
|
||
def _rebuild_breadcrumbs(self, path: str) -> None:
|
||
# clear
|
||
while self._crumb_layout.count():
|
||
it = self._crumb_layout.takeAt(0)
|
||
w = it.widget()
|
||
if w is not None:
|
||
w.deleteLater()
|
||
parts = []
|
||
cur = path
|
||
guard = 0
|
||
while cur and guard < 64:
|
||
parts.append((os.path.basename(cur) or cur, cur))
|
||
parent = os.path.dirname(cur)
|
||
if parent == cur:
|
||
break
|
||
cur = parent
|
||
guard += 1
|
||
parts.reverse()
|
||
for i, (label, target) in enumerate(parts):
|
||
if i > 0:
|
||
sep = QLabel("›") # noqa: RUF001 -- breadcrumb separator glyph
|
||
sep.setStyleSheet("color:#999; padding:0 1px;")
|
||
self._crumb_layout.addWidget(sep)
|
||
btn = QPushButton(label)
|
||
btn.setFlat(True)
|
||
btn.setStyleSheet(
|
||
"QPushButton { border:0; padding:2px 4px; text-align:left; "
|
||
"color:#1a73e8; } QPushButton:hover { text-decoration:underline; }")
|
||
btn.clicked.connect(lambda _=False, t=target: self.set_path(t))
|
||
self._crumb_layout.addWidget(btn)
|
||
self._crumb_layout.addStretch(1)
|
||
|
||
# ------------------------------------------------------------------ selection
|
||
def selected_paths(self) -> list[str]:
|
||
"""Local filesystem paths of the selected rows (empty strings dropped)."""
|
||
return [
|
||
self._model.filePath(self._filter.mapToSource(pi))
|
||
for pi in self.view.selectionModel().selectedRows()
|
||
if self._model.filePath(self._filter.mapToSource(pi))
|
||
]
|
||
|
||
def selection_count(self) -> int:
|
||
return len(self.view.selectionModel().selectedRows())
|
||
|
||
# ------------------------------------------------------------------ filter
|
||
def focus_filter(self) -> None:
|
||
self.filter_edit.setFocus()
|
||
self.filter_edit.selectAll()
|
||
|
||
def clear_filter(self) -> None:
|
||
self.filter_edit.clear()
|
||
|
||
# ------------------------------------------------------------------ options
|
||
def set_show_hidden(self, on: bool) -> None:
|
||
flt = QDir.AllEntries | QDir.NoDotAndDotDot
|
||
if on:
|
||
flt |= QDir.Hidden
|
||
self._model.setFilter(flt)
|
||
self._settings.show_hidden = on
|
||
|
||
# ------------------------------------------------------------------ context menu
|
||
def _on_context(self, pos) -> None:
|
||
idx = self.view.indexAt(pos)
|
||
menu = QMenu(self)
|
||
act_add = QAction("Add to ISO image", self)
|
||
act_add.triggered.connect(self._emit_add)
|
||
menu.addAction(act_add)
|
||
menu.addSeparator()
|
||
act_open = QAction("Open", self)
|
||
act_open.triggered.connect(self._open_current)
|
||
menu.addAction(act_open)
|
||
if idx.isValid():
|
||
act_copy = QAction("Copy path", self)
|
||
act_copy.triggered.connect(lambda: self._copy_path(idx))
|
||
menu.addAction(act_copy)
|
||
menu.addSeparator()
|
||
act_refresh = QAction("Refresh", self)
|
||
act_refresh.setShortcut(QKeySequence.Refresh)
|
||
act_refresh.triggered.connect(lambda: self.set_path(self.current_path()))
|
||
menu.addAction(act_refresh)
|
||
menu.exec(self.view.viewport().mapToGlobal(pos))
|
||
|
||
def _emit_add(self) -> None:
|
||
paths = self.selected_paths()
|
||
if paths:
|
||
self.addRequested.emit(paths)
|
||
|
||
def _open_current(self) -> None:
|
||
paths = self.selected_paths()
|
||
if paths and os.path.isdir(paths[0]):
|
||
self.set_path(paths[0])
|
||
|
||
def _copy_path(self, idx) -> None:
|
||
src = self._filter.mapToSource(idx)
|
||
QApplication.clipboard().setText(self._model.filePath(src))
|
||
|
||
|
||
class _NameFilterProxy(QSortFilterProxyModel):
|
||
"""Case-insensitive substring filter on the filename column."""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self._needle = ""
|
||
|
||
def set_filter(self, text: str) -> None:
|
||
self._needle = (text or "").lower()
|
||
self.invalidateFilter()
|
||
|
||
def filterAcceptsRow(self, source_row, source_parent):
|
||
if not self._needle:
|
||
return True
|
||
idx = self.sourceModel().index(source_row, 0, source_parent)
|
||
name = self.sourceModel().fileName(idx)
|
||
return self._needle in name.lower()
|