"""A read-only tree model exposing an ISO image's directory structure. The model lazily fetches children from :class:`IsoHandler` as the user expands nodes. Each item carries its full path (in the active naming convention) and the :class:`IsoRecord` describing it. """ # 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 from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt from PySide6.QtGui import QFont, QIcon from .iso_handler import IsoHandler from .iso_record import IsoRecord, NameType # Sentinel for "no parent" passed to Qt model methods. QModelIndex() is a # cheap value type (an invalid index), but ruff B008 forbids calling it in # default-argument position; a module-level singleton keeps the Qt-idiomatic # signature `parent=...` without re-evaluating the call on every invocation. _NO_PARENT = QModelIndex() class _Item: """Internal tree node.""" __slots__ = ("children", "loaded", "parent", "record", "row") def __init__(self, record: IsoRecord | None, parent: _Item | None, row: int = 0): self.record = record self.parent = parent self.children: list[_Item] = [] self.loaded = False self.row = row def path(self) -> str: if self.record is None: return "/" return self.record.path def is_dir(self) -> bool: return self.record is None or self.record.is_dir class IsoTreeModel(QAbstractItemModel): """Qt tree model backed by an :class:`IsoHandler`.""" COLUMNS = ("Name", "Size", "Type", "Date") def __init__(self, handler: IsoHandler, parent=None): super().__init__(parent) self._handler = handler self._name_type: NameType = NameType.ISO9660 self._root: _Item | None = None self._folder_icon: QIcon | None = None self._file_icon: QIcon | None = None self._init_icons() self.rebuild() # ------------------------------------------------------------------ icons def _init_icons(self) -> None: try: from PySide6.QtWidgets import QApplication, QStyle st = QApplication.instance().style() if QApplication.instance() else None if st is not None: self._folder_icon = st.standardIcon(QStyle.SP_DirIcon) self._file_icon = st.standardIcon(QStyle.SP_FileIcon) except (ImportError, RuntimeError, AttributeError): # No QApplication yet, or the platform plugin is missing -- fall # back to empty icons so the model still constructs. self._folder_icon = QIcon() self._file_icon = QIcon() # ------------------------------------------------------------------ public def set_name_type(self, name_type: NameType) -> None: self.beginResetModel() self._name_type = name_type self._root = None self.endResetModel() self.rebuild() def name_type(self) -> NameType: return self._name_type def rebuild(self) -> None: """Drop all caches and reload from the handler.""" self.beginResetModel() if self._handler.is_open: root_rec = IsoRecord(name="/", raw_name="/", is_dir=True, is_file=False, size=0, path="/", name_type=self._name_type) self._root = _Item(root_rec, None) self._root.loaded = False else: self._root = None self.endResetModel() def refresh_parent(self, parent_path: str) -> None: """Reload the children of ``parent_path`` (e.g. after an add/remove). The ISO root ``"/"`` maps to the model root (an invalid index whose internal pointer is ``None``); resolve it to ``self._root`` so the virtual root's children are dropped and re-fetched. """ idx = self.index_from_path(parent_path) if not idx.isValid(): self.rebuild() return item = idx.internalPointer() or self._root if item is None: self.rebuild() return self.beginResetModel() item.children = [] item.loaded = False self.endResetModel() def index_from_path(self, path: str) -> QModelIndex: """Return the model index for ``path``. The ISO root ``"/"`` maps to the model root (an invalid :class:`QModelIndex`) -- its children are the entries of ``/`` and the view displays them directly without requiring the user to expand a "/" placeholder row. """ if self._root is None: return QModelIndex() if path in ("/", ""): return QModelIndex() # the model root == ISO "/" # walk parts = [p for p in path.split("/") if p] parent = QModelIndex() for part in parts: self.fetchMore(parent) found = False for r in range(self.rowCount(parent)): idx = self.index(r, 0, parent) item: _Item = idx.internalPointer() if item.record and item.record.name == part: parent = idx found = True break if not found: return QModelIndex() return parent # ------------------------------------------------------------------ model API def columnCount(self, parent=_NO_PARENT) -> int: return len(self.COLUMNS) def headerData(self, section, orientation, role=Qt.DisplayRole): if role != Qt.DisplayRole or orientation != Qt.Horizontal: return None return self.COLUMNS[section] def rowCount(self, parent=_NO_PARENT) -> int: if self._root is None: return 0 if not parent.isValid(): # Model root: its children are the entries of the ISO "/" # directory. rowCount() never calls fetchMore() -- Qt drives # canFetchMore()/fetchMore() itself, and calling fetchMore() here # would recurse through beginInsertRows. An unloaded root reports # 0, which prompts the view to call canFetchMore() (True) and then # fetchMore() to populate the children. if not self._root.loaded: return 0 return len(self._root.children) item: _Item = parent.internalPointer() if not item.is_dir(): return 0 if not item.loaded: return 0 return len(item.children) def canFetchMore(self, parent): if self._root is None: return False # Model root: can fetch if the virtual root isn't loaded yet. if not parent.isValid(): return not self._root.loaded item: _Item = parent.internalPointer() if item is None: return False return item.is_dir() and not item.loaded def fetchMore(self, parent): # An invalid parent denotes the model root, which holds the ISO's # "/" directory; a valid parent carries its _Item via the pointer. item = self._root if not parent.isValid() else parent.internalPointer() # Step-down: nothing to do for a non-directory, an already-loaded # node, or a closed image (root is None). if item is None or item.loaded or not item.is_dir(): return path = item.path() try: records = self._handler.list_dir(path, self._name_type) except (OSError, ValueError, KeyError): # pragma: no cover - defensive records = [] self.beginInsertRows(parent, 0, max(0, len(records) - 1)) for i, rec in enumerate(records): item.children.append(_Item(rec, item, i)) item.loaded = True self.endInsertRows() def index(self, row, column, parent=_NO_PARENT): if not self.hasIndex(row, column, parent): return QModelIndex() parent_item: _Item = parent.internalPointer() if parent.isValid() else self._root if parent_item is None: return QModelIndex() if not parent_item.loaded: self.fetchMore(parent) if row < 0 or row >= len(parent_item.children): return QModelIndex() return self.createIndex(row, column, parent_item.children[row]) def parent(self, index): if not index.isValid(): return QModelIndex() item: _Item = index.internalPointer() parent = item.parent if parent is None or parent is self._root: return QModelIndex() return self.createIndex(parent.row, 0, parent) def data(self, index, role=Qt.DisplayRole): if not index.isValid(): return None item: _Item = index.internalPointer() rec = item.record if rec is None: return None col = index.column() # DisplayRole: dispatch on column via a tuple lookup so adding a # column is a one-line edit instead of another if-branch. if role == Qt.DisplayRole: display = ( rec.name, rec.size_label, "Folder" if rec.is_dir else "File", rec.date_label, ) return display[col] if 0 <= col < len(display) else None # Non-display roles are column-specific; guard each with `col == 0` # where the role only applies to the name column. if role == Qt.DecorationRole and col == 0: return self._folder_icon if rec.is_dir else self._file_icon if role == Qt.FontRole and col == 0: font = QFont() font.setBold(rec.is_dir) return font if role == Qt.UserRole: return rec if role == Qt.ToolTipRole: tip = f"{rec.name}\n{rec.size_label} ({rec.size} bytes)" if rec.modified: tip += f"\n{rec.date_label}" return tip return None def flags(self, index): if not index.isValid(): return Qt.NoItemFlags return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled