iso-scalpel/iso_scalpel/iso_model.py

284 lines
11 KiB
Python

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