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