284 lines
11 KiB
Python
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
|