iso-scalpel/iso_scalpel/widgets/iso_pane.py

441 lines
18 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.

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