441 lines
18 KiB
Python
441 lines
18 KiB
Python
"""ISO image pane with breadcrumb navigation, history, view-switch and filter.
|
||
|
||
The other half of ISO Scalpel's split-nav interface. It browses an open
|
||
ISO image (using :class:`IsoTreeModel`) and emits operation requests
|
||
when the user drags host files in, or invokes context-menu actions.
|
||
|
||
The pane provides:
|
||
* a clickable breadcrumb path bar (jump to any ancestor directory),
|
||
* back / forward / up navigation with a per-pane history of visited
|
||
ISO directories,
|
||
* a live filter box that narrows the current listing,
|
||
* a view dropdown to switch between ISO9660 / Rock Ridge / Joliet / UDF
|
||
naming conventions on the same image,
|
||
* drop-accept so host files can be dragged in from the filesystem 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 QModelIndex, Qt, Signal
|
||
from PySide6.QtGui import QAction, QDragEnterEvent, QDropEvent, QKeySequence
|
||
from PySide6.QtWidgets import (
|
||
QAbstractItemView,
|
||
QComboBox,
|
||
QFrame,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QLabel,
|
||
QLineEdit,
|
||
QMenu,
|
||
QPushButton,
|
||
QSizePolicy,
|
||
QToolButton,
|
||
QTreeView,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from ..config import Settings
|
||
from ..iso_handler import IsoHandler
|
||
from ..iso_model import IsoTreeModel
|
||
from ..iso_record import NAME_TYPE_LABELS, IsoRecord
|
||
|
||
|
||
class IsoPane(QWidget):
|
||
"""Browse an ISO image; request operations on its contents."""
|
||
|
||
addFilesRequested = Signal(str, list) # (dest_dir, [local paths])
|
||
extractRequested = Signal(list, str) # ([(path, is_dir)], dest_dir)
|
||
deleteRequested = Signal(list) # [(path, is_dir)]
|
||
renameRequested = Signal(str, bool) # (path, is_dir)
|
||
newFolderRequested = Signal(str) # parent_dir
|
||
propertiesRequested = Signal(str) # path
|
||
nameTypeChanged = Signal(object) # NameType
|
||
navChanged = Signal() # back/forward availability
|
||
|
||
def __init__(self, handler: IsoHandler, settings: Settings, parent=None):
|
||
super().__init__(parent)
|
||
self._handler = handler
|
||
self._settings = settings
|
||
self._history: list[str] = []
|
||
self._history_idx = -1
|
||
|
||
self._model = IsoTreeModel(handler)
|
||
|
||
self.view = QTreeView()
|
||
self.view.setModel(self._model)
|
||
self.view.setRootIsDecorated(True)
|
||
self.view.setAlternatingRowColors(True)
|
||
self.view.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||
self.view.setUniformRowHeights(True)
|
||
self.view.setAcceptDrops(True)
|
||
self.view.setDragDropMode(QAbstractItemView.DropOnly)
|
||
self.view.setDropIndicatorShown(True)
|
||
self.view.setColumnWidth(0, 280)
|
||
self.view.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.view.customContextMenuRequested.connect(self._on_context)
|
||
self.view.doubleClicked.connect(self._on_double_click)
|
||
self.view.header().setStretchLastSection(False)
|
||
self.view.header().setSectionResizeMode(0, QHeaderView.Stretch)
|
||
self.view.selectionModel().selectionChanged.connect(
|
||
lambda *_: self.navChanged.emit())
|
||
|
||
# --- nav 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)
|
||
|
||
# --- view selector + filter ------------------------------------
|
||
self.name_type = QComboBox()
|
||
self.name_type.setToolTip("Display names as")
|
||
self.name_type.currentIndexChanged.connect(self._on_name_type_changed)
|
||
self._populate_name_types()
|
||
# Cap the combo width so it doesn't stretch on large windows.
|
||
self.name_type.setMaximumWidth(160)
|
||
self.name_type.setMinimumWidth(100)
|
||
|
||
self.filter_edit = QLineEdit()
|
||
self.filter_edit.setPlaceholderText("Filter…")
|
||
self.filter_edit.setClearButtonEnabled(True)
|
||
self.filter_edit.textChanged.connect(self._apply_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, with the filter box keeping a
|
||
# comfortable fixed maximum.
|
||
self.filter_edit.setMaximumWidth(220)
|
||
self.filter_edit.setMinimumWidth(120)
|
||
|
||
# --- nav bar ---------------------------------------------------
|
||
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(QLabel("View:"))
|
||
bar.addWidget(self.name_type)
|
||
bar.addWidget(self.filter_edit)
|
||
|
||
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)
|
||
|
||
# ------------------------------------------------------------------ name types
|
||
def _populate_name_types(self) -> None:
|
||
self.name_type.blockSignals(True)
|
||
self.name_type.clear()
|
||
if self._handler.is_open:
|
||
default = self._handler.default_name_type()
|
||
for nt in self._handler.available_name_types():
|
||
self.name_type.addItem(NAME_TYPE_LABELS[nt], nt)
|
||
idx = self.name_type.findData(default)
|
||
if idx >= 0:
|
||
self.name_type.setCurrentIndex(idx)
|
||
self.name_type.blockSignals(False)
|
||
|
||
def _on_name_type_changed(self, _idx: int) -> None:
|
||
nt = self.name_type.currentData()
|
||
if nt is None:
|
||
return
|
||
self._model.set_name_type(nt)
|
||
self._history.clear()
|
||
self._history_idx = -1
|
||
self._visit("/")
|
||
self.nameTypeChanged.emit(nt)
|
||
|
||
# ------------------------------------------------------------------ refresh
|
||
def refresh(self) -> None:
|
||
"""Reload after structural changes."""
|
||
self._populate_name_types()
|
||
self._model.rebuild()
|
||
self._history.clear()
|
||
self._history_idx = -1
|
||
self._visit("/")
|
||
self.navChanged.emit()
|
||
|
||
def refresh_parent(self, parent_path: str) -> None:
|
||
self._model.refresh_parent(parent_path)
|
||
self._rebuild_breadcrumbs(self.current_dir())
|
||
|
||
# ------------------------------------------------------------------ selection
|
||
def current_index(self) -> QModelIndex:
|
||
rows = self.view.selectionModel().selectedRows()
|
||
return rows[0] if rows else QModelIndex()
|
||
|
||
def selected_records(self) -> list[IsoRecord]:
|
||
"""Records backing the selected rows (rows without a record are skipped)."""
|
||
return [
|
||
rec for idx in self.view.selectionModel().selectedRows()
|
||
if (rec := idx.data(Qt.UserRole)) is not None
|
||
]
|
||
|
||
def selection_count(self) -> int:
|
||
return len(self.view.selectionModel().selectedRows())
|
||
|
||
def current_dir(self) -> str:
|
||
idx = self.current_index()
|
||
if not idx.isValid():
|
||
return self._current_history_path() or "/"
|
||
rec: IsoRecord | None = idx.data(Qt.UserRole)
|
||
if rec is None:
|
||
return self._current_history_path() or "/"
|
||
if rec.is_dir:
|
||
return rec.path
|
||
return rec.path.rpartition("/")[0] or "/"
|
||
|
||
def _current_history_path(self) -> str | None:
|
||
if 0 <= self._history_idx < len(self._history):
|
||
return self._history[self._history_idx]
|
||
return None
|
||
|
||
# ------------------------------------------------------------------ navigation
|
||
def _on_double_click(self, idx: QModelIndex) -> None:
|
||
rec: IsoRecord | None = idx.data(Qt.UserRole)
|
||
if rec and rec.is_dir:
|
||
self.view.expand(idx)
|
||
self._visit(rec.path)
|
||
|
||
def _visit(self, path: str) -> None:
|
||
if not path:
|
||
return
|
||
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._rebuild_breadcrumbs(path)
|
||
self.navChanged.emit()
|
||
|
||
def go_up(self) -> None:
|
||
cur = self.current_dir()
|
||
parent = cur.rpartition("/")[0] or "/"
|
||
if parent != cur:
|
||
self._visit(parent)
|
||
# try to select the dir we came from
|
||
for r in range(self._model.rowCount()):
|
||
idx = self._model.index(r, 0)
|
||
rec = idx.data(Qt.UserRole)
|
||
if rec and rec.path == cur:
|
||
self.view.setCurrentIndex(idx)
|
||
break
|
||
|
||
def go_back(self) -> None:
|
||
if self._history_idx > 0:
|
||
self._history_idx -= 1
|
||
p = self._history[self._history_idx]
|
||
self._rebuild_breadcrumbs(p)
|
||
self.navChanged.emit()
|
||
|
||
def go_forward(self) -> None:
|
||
if self._history_idx < len(self._history) - 1:
|
||
self._history_idx += 1
|
||
p = self._history[self._history_idx]
|
||
self._rebuild_breadcrumbs(p)
|
||
self.navChanged.emit()
|
||
|
||
def can_back(self) -> bool:
|
||
return self._history_idx > 0
|
||
|
||
def can_forward(self) -> bool:
|
||
return self._history_idx < len(self._history) - 1
|
||
|
||
def go_to_path(self, path: str) -> None:
|
||
self._visit(path or "/")
|
||
|
||
def focus_filter(self) -> None:
|
||
self.filter_edit.setFocus()
|
||
self.filter_edit.selectAll()
|
||
|
||
def clear_filter(self) -> None:
|
||
self.filter_edit.clear()
|
||
|
||
# ------------------------------------------------------------------ breadcrumbs
|
||
def _rebuild_breadcrumbs(self, path: str) -> None:
|
||
while self._crumb_layout.count():
|
||
it = self._crumb_layout.takeAt(0)
|
||
w = it.widget()
|
||
if w is not None:
|
||
w.deleteLater()
|
||
if not self._handler.is_open:
|
||
placeholder = QLabel("ISO image — (none)")
|
||
placeholder.setStyleSheet("padding:2px 6px; color:#999;")
|
||
self._crumb_layout.addWidget(placeholder)
|
||
self._crumb_layout.addStretch(1)
|
||
return
|
||
# root label = filename
|
||
fname = os.path.basename(self._handler.filename or "untitled.iso")
|
||
dirty = " *" if self._handler.is_dirty else ""
|
||
root_btn = QPushButton(f"💿 {fname}{dirty}")
|
||
root_btn.setFlat(True)
|
||
root_btn.setStyleSheet(
|
||
"QPushButton { border:0; padding:2px 4px; text-align:left; "
|
||
"color:#1a73e8; font-weight:600; } QPushButton:hover { text-decoration:underline; }")
|
||
root_btn.clicked.connect(lambda _=False: self._visit("/"))
|
||
self._crumb_layout.addWidget(root_btn)
|
||
parts = [p for p in path.split("/") if p and p != fname]
|
||
for seg in parts:
|
||
sep = QLabel("›") # noqa: RUF001 -- breadcrumb separator glyph
|
||
sep.setStyleSheet("color:#999; padding:0 1px;")
|
||
self._crumb_layout.addWidget(sep)
|
||
btn = QPushButton(seg)
|
||
btn.setFlat(True)
|
||
btn.setStyleSheet(
|
||
"QPushButton { border:0; padding:2px 4px; text-align:left; "
|
||
"color:#1a73e8; } QPushButton:hover { text-decoration:underline; }")
|
||
# compute the full path up to this segment
|
||
acc = "/"
|
||
for s in parts[:parts.index(seg) + 1]:
|
||
acc = (acc.rstrip("/") + "/" + s)
|
||
btn.clicked.connect(lambda _=False, t=acc: self._visit(t))
|
||
self._crumb_layout.addWidget(btn)
|
||
self._crumb_layout.addStretch(1)
|
||
|
||
# ------------------------------------------------------------------ filter
|
||
def _apply_filter(self, text: str) -> None:
|
||
"""Live filter by walking the model and hiding non-matching rows."""
|
||
needle = (text or "").lower()
|
||
if not needle:
|
||
# unhide everything
|
||
self._set_recursive_visible(self._model.index(0, 0), True)
|
||
return
|
||
self._filter_recursive(QModelIndex(), needle)
|
||
|
||
def _filter_recursive(self, parent: QModelIndex, needle: str) -> bool:
|
||
"""Hide rows whose name doesn't match; returns True if any child shown."""
|
||
model = self._model
|
||
any_shown = False
|
||
for r in range(model.rowCount(parent)):
|
||
idx = model.index(r, 0, parent)
|
||
rec = idx.data(Qt.UserRole)
|
||
name = (rec.name if rec else "").lower()
|
||
child_match = self._filter_recursive(idx, needle) if model.rowCount(idx) else False
|
||
match = needle in name or child_match
|
||
self.view.setRowHidden(r, parent, not match)
|
||
if match:
|
||
any_shown = True
|
||
return any_shown
|
||
|
||
def _set_recursive_visible(self, idx: QModelIndex, visible: bool) -> None:
|
||
if not idx.isValid():
|
||
return
|
||
parent = idx.parent()
|
||
row = idx.row()
|
||
if parent.isValid():
|
||
self.view.setRowHidden(row, parent, not visible)
|
||
for r in range(self._model.rowCount(idx)):
|
||
child = self._model.index(r, 0, idx)
|
||
self._set_recursive_visible(child, visible)
|
||
|
||
# ------------------------------------------------------------------ drag & drop
|
||
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
return
|
||
event.ignore()
|
||
|
||
def dragMoveEvent(self, event) -> None:
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
return
|
||
event.ignore()
|
||
|
||
def dropEvent(self, event: QDropEvent) -> None:
|
||
if not event.mimeData().hasUrls():
|
||
event.ignore()
|
||
return
|
||
target = self.current_dir()
|
||
idx = (self.view.indexAt(event.position().toPoint())
|
||
if hasattr(event, "position") else self.view.indexAt(event.pos()))
|
||
if idx.isValid():
|
||
rec: IsoRecord | None = idx.data(Qt.UserRole)
|
||
if rec:
|
||
target = rec.path if rec.is_dir else (rec.path.rpartition("/")[0] or "/")
|
||
# Comprehension over the URL list, keeping only local file:// drops.
|
||
paths = [u.toLocalFile() for u in event.mimeData().urls() if u.toLocalFile()]
|
||
if not paths:
|
||
return
|
||
self.addFilesRequested.emit(target, paths)
|
||
event.acceptProposedAction()
|
||
|
||
# ------------------------------------------------------------------ context menu
|
||
def _on_context(self, pos) -> None:
|
||
menu = QMenu(self)
|
||
if not self._handler.is_open:
|
||
menu.addAction("(no image open)").setEnabled(False)
|
||
menu.exec(self.view.viewport().mapToGlobal(pos))
|
||
return
|
||
|
||
sel = self.selected_records()
|
||
act_new = QAction("New folder…", self)
|
||
act_new.setShortcut(QKeySequence.New)
|
||
act_new.triggered.connect(lambda: self.newFolderRequested.emit(self.current_dir()))
|
||
menu.addAction(act_new)
|
||
|
||
if sel:
|
||
menu.addSeparator()
|
||
act_extract = QAction("Extract…", self)
|
||
act_extract.triggered.connect(lambda: self._emit_extract(sel))
|
||
menu.addAction(act_extract)
|
||
act_del = QAction("Delete", self)
|
||
act_del.setShortcut(QKeySequence.Delete)
|
||
act_del.triggered.connect(
|
||
lambda: self.deleteRequested.emit([(r.path, r.is_dir) for r in sel]))
|
||
menu.addAction(act_del)
|
||
if len(sel) == 1:
|
||
act_rename = QAction("Rename…", self)
|
||
act_rename.triggered.connect(
|
||
lambda: self.renameRequested.emit(sel[0].path, sel[0].is_dir))
|
||
menu.addAction(act_rename)
|
||
menu.addSeparator()
|
||
act_props = QAction("Properties…", self)
|
||
act_props.triggered.connect(lambda: self.propertiesRequested.emit(sel[0].path))
|
||
menu.addAction(act_props)
|
||
menu.addSeparator()
|
||
act_refresh = QAction("Refresh", self)
|
||
act_refresh.setShortcut(QKeySequence.Refresh)
|
||
act_refresh.triggered.connect(self.refresh)
|
||
menu.addAction(act_refresh)
|
||
menu.exec(self.view.viewport().mapToGlobal(pos))
|
||
|
||
def _emit_extract(self, records: list[IsoRecord]) -> None:
|
||
dest = self._settings.last_dir
|
||
self.extractRequested.emit([(r.path, r.is_dir) for r in records], dest)
|