iso-scalpel/iso_scalpel/widgets/fs_pane.py

330 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()