""" novelWriter – Project Item Model ================================ File History: Created: 2024-11-16 [2.6b2] ProjectNode Created: 2024-11-16 [2.6b2] ProjectModel This file is a part of novelWriter Copyright (C) 2024 Veronica Berglyd Olsen and novelWriter contributors 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 3 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 . """ # noqa from __future__ import annotations import logging from typing import TYPE_CHECKING from PyQt6.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt from PyQt6.QtGui import QFont, QIcon from novelwriter.common import decodeMimeHandles, encodeMimeHandles, minmax from novelwriter.constants import nwConst from novelwriter.core.item import NWItem from novelwriter.enum import nwItemClass from novelwriter.types import QtAlignRight if TYPE_CHECKING: from novelwriter.core.tree import NWTree logger = logging.getLogger(__name__) INV_ROOT = "invisibleRoot" C_FACTOR = 0x0100 C_LABEL_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole C_LABEL_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole C_LABEL_FONT = 0x0000 | Qt.ItemDataRole.FontRole C_COUNT_TEXT = 0x0100 | Qt.ItemDataRole.DisplayRole C_COUNT_ICON = 0x0100 | Qt.ItemDataRole.DecorationRole C_COUNT_ALIGN = 0x0100 | Qt.ItemDataRole.TextAlignmentRole C_ACTIVE_ICON = 0x0200 | Qt.ItemDataRole.DecorationRole C_ACTIVE_TIP = 0x0200 | Qt.ItemDataRole.ToolTipRole C_ACTIVE_ACCESS = 0x0200 | Qt.ItemDataRole.AccessibleTextRole C_STATUS_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole C_STATUS_TIP = 0x0300 | Qt.ItemDataRole.ToolTipRole C_STATUS_ACCESS = 0x0300 | Qt.ItemDataRole.AccessibleTextRole NODE_FLAGS = Qt.ItemFlag.ItemIsEnabled NODE_FLAGS |= Qt.ItemFlag.ItemIsSelectable NODE_FLAGS |= Qt.ItemFlag.ItemIsDropEnabled T_NodeData = str | QIcon | QFont | Qt.AlignmentFlag | None class ProjectNode: """Core: Project Model Node Class. The project tree structure is saved as nodes in a tree, starting from a root node. This class makes up these nodes. Each node is a wrapper around an NWItem object. The NWItem is the object representing a single item in the project, and it only contains a reference to its parent as well as it top level root, but is itself not structured in a hierarchy in memory. This class provides the necessary hierarchical structure, as well as the data entries needed for populating the GUI project tree. It also handles pushing and pulling information from its NWItem when necessary. The data to be displayed could in principle be pulled from the NWItem whenever it is needed, but for performance reason it is cached, as the GUI will pull this information often. """ C_NAME = 0 C_COUNT = 1 C_ACTIVE = 2 C_STATUS = 3 __slots__ = ("_cache", "_children", "_count", "_flags", "_item", "_parent", "_row") def __init__(self, item: NWItem) -> None: self._item = item self._children: list[ProjectNode] = [] self._parent: ProjectNode | None = None self._row = 0 self._cache: dict[int, T_NodeData] = {} self._flags = NODE_FLAGS self._count = 0 self.refresh() self.updateCount() def __repr__(self) -> str: return ( f"" ) def __bool__(self) -> bool: # A node should always evaluate to True. return True ## # Properties ## @property def item(self) -> NWItem: """The project item of the node.""" return self._item @property def children(self) -> list[ProjectNode]: """All children of the node.""" return self._children @property def count(self) -> int: """The count of the node.""" return self._count ## # Data Maintenance ## def refresh(self) -> None: """Refresh data values.""" # Label self._cache[C_LABEL_ICON] = self._item.getMainIcon() self._cache[C_LABEL_TEXT] = self._item.itemName self._cache[C_LABEL_FONT] = self._item.getMainFont() # Count self._cache[C_COUNT_ALIGN] = QtAlignRight # Active aText, aIcon = self._item.getActiveStatus() self._cache[C_ACTIVE_ICON] = aIcon self._cache[C_ACTIVE_TIP] = aText self._cache[C_ACTIVE_ACCESS] = aText # Status sText, sIcon = self._item.getImportStatus() self._cache[C_STATUS_ICON] = sIcon self._cache[C_STATUS_TIP] = sText self._cache[C_STATUS_ACCESS] = sText def updateCount(self, propagate: bool = True) -> None: """Update counts, and propagate upwards in the tree.""" self._count = self._item.mainCount + sum(c._count for c in self._children) # noqa: SLF001 self._cache[C_COUNT_TEXT] = f"{self._count:n}" if propagate and (parent := self._parent): parent.updateCount() ## # Data Access ## def row(self) -> int: """Return the node's row number.""" return self._row def childCount(self) -> int: """Return the number of children of the node.""" return len(self._children) def data(self, column: int, role: Qt.ItemDataRole) -> T_NodeData: """Return cached node data.""" return self._cache.get(C_FACTOR*column | role) def flags(self) -> Qt.ItemFlag: """Return cached node flags.""" return self._flags def parent(self) -> ProjectNode | None: """Return the parent of the node.""" return self._parent def child(self, row: int) -> ProjectNode | None: """Return a child of the node.""" if 0 <= row < len(self._children): return self._children[row] return None def allChildren(self) -> list[ProjectNode]: """Return a recursive list of all children.""" nodes: list[ProjectNode] = [] self._recursiveAppendChildren(nodes) return nodes ## # Data Edit ## def addChild(self, child: ProjectNode, pos: int = -1) -> None: """Add a child item to this item.""" child._parent = self self._updateRelationships(child) if 0 <= pos < len(self._children): self._children.insert(pos, child) else: child._row = len(self._children) self._children.append(child) self._refreshChildrenPos() self._item.notifyNovelStructureChange() def takeChild(self, pos: int) -> ProjectNode | None: """Remove a child item and return it.""" if 0 <= pos < len(self._children): node = self._children.pop(pos) self._refreshChildrenPos() self.updateCount() self._item.notifyNovelStructureChange() return node return None def moveChild(self, source: int, target: int) -> None: """Move a child internally.""" count = len(self._children) if (source != target) and (0 <= source < count) and (0 <= target <= count): node = self._children.pop(source) self._children.insert(target, node) self._refreshChildrenPos() self._item.notifyNovelStructureChange() def setExpanded(self, state: bool) -> None: """Set the node's expanded state.""" if state and self._children: self._item.setExpanded(True) else: self._item.setExpanded(False) ## # Internal Functions ## def _recursiveAppendChildren(self, children: list[ProjectNode]) -> None: """Recursively add all nodes to a list.""" for node in self._children: children.append(node) node._recursiveAppendChildren(children) # noqa: SLF001 def _refreshChildrenPos(self) -> None: """Update the row value on all children.""" for n, child in enumerate(self._children): child._row = n # noqa: SLF001 child.item.setOrder(n) def _updateRelationships(self, child: ProjectNode) -> None: """Update a child item's relationships.""" if self._parent: child.item.setParent(self._item.itemHandle) child.item.setRoot(self._item.itemRoot) child.item.setClassDefaults(self._item.itemClass) child._flags = NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled else: child.item.setParent(None) child.item.setRoot(child.item.itemHandle) child.item.setClassDefaults(child.item.itemClass) class ProjectModel(QAbstractItemModel): """Core: Project Model Class. This class provides the interface for the tree widget used on the GUI. It implements the QModelIndex based interface required, adds support for drag and drop, and a few other novelWriter-specific methods needed primarily by the project tree GUI component. """ __slots__ = ("_root", "_tree") def __init__(self, tree: NWTree) -> None: super().__init__() self._tree = tree self._root = ProjectNode(NWItem(tree.project, INV_ROOT)) self._root.item.setName("Invisible Root") logger.debug("Ready: ProjectModel") def __del__(self) -> None: # pragma: no cover logger.debug("Delete: ProjectModel") ## # Properties ## @property def root(self) -> ProjectNode: """Return the model root item.""" return self._root ## # Model Interface ## def rowCount(self, index: QModelIndex) -> int: """Return the number of rows for an entry.""" if index.isValid(): return index.internalPointer().childCount() return self._root.childCount() def columnCount(self, index: QModelIndex) -> int: """Return the number of columns for an entry.""" return 4 def parent(self, index: QModelIndex) -> QModelIndex: """Get the parent model index of another index.""" if index.isValid() and (node := index.internalPointer()) and (parent := node.parent()): return self.createIndex(parent.row(), 0, parent) return QModelIndex() def index(self, row: int, column: int, parent: QModelIndex | None = None) -> QModelIndex: """Get the index of a child item of a parent.""" parent = parent or QModelIndex() if self.hasIndex(row, column, parent): node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root if child := node.child(row): return self.createIndex(row, column, child) return QModelIndex() def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData: """Return display data for a project node.""" if index.isValid(): return index.internalPointer().data(index.column(), role) return None def flags(self, index: QModelIndex) -> Qt.ItemFlag: """Return flags for a project node.""" if index.isValid(): return index.internalPointer().flags() return Qt.ItemFlag.NoItemFlags ## # Drag and Drop ## def supportedDropActions(self) -> Qt.DropAction: """Return supported drop actions.""" return Qt.DropAction.MoveAction def mimeTypes(self) -> list[str]: """Return the supported mime types of the model.""" return [nwConst.MIME_HANDLE] def mimeData(self, indices: list[QModelIndex]) -> QMimeData: """Encode mime data about a selection.""" handles = [ i.internalPointer().item.itemHandle for i in indices if i.isValid() and i.column() == 0 ] mime = QMimeData() encodeMimeHandles(mime, handles) return mime def canDropMimeData( self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex ) -> bool: """Check if mime data can be dropped on the current location.""" if parent.isValid() and parent.internalPointer() is not self._root: return data.hasFormat(nwConst.MIME_HANDLE) and action == Qt.DropAction.MoveAction return False def dropMimeData( self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex ) -> bool: """Process mime data drop.""" if self.canDropMimeData(data, action, row, column, parent): items = [ index for handle in decodeMimeHandles(data) if (index := self.indexFromHandle(handle)).isValid() ] self.multiMove(items, parent, row) return True return False ## # Data Access ## def row(self, index: QModelIndex) -> int: """Return the row number of the index.""" if index.isValid(): return index.internalPointer().row() return -1 def node(self, index: QModelIndex) -> ProjectNode | None: """Return the node for a given model index.""" if index.isValid(): return index.internalPointer() return None def nodes(self, indices: list[QModelIndex]) -> list[ProjectNode]: """Return the nodes for a list of model indices.""" return [i.internalPointer() for i in indices if i.isValid() and i.column() == 0] def indexFromHandle(self, handle: str | None) -> QModelIndex: """Get the index representing a node in the model.""" if handle and (node := self._tree.nodes.get(handle)): return self.createIndex(node.row(), 0, node) return QModelIndex() def indexFromNode(self, node: ProjectNode, column: int = 0) -> QModelIndex: """Get the index representing a node in the model.""" return self.createIndex(node.row(), column, node) ## # Model Edit ## def insertChild(self, child: ProjectNode, parent: QModelIndex, pos: int) -> None: """Insert a node into the model at a given position.""" node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root count = node.childCount() row = minmax(pos, 0, count) if pos >= 0 else count self.beginInsertRows(parent, row, row) node.addChild(child, row) self.endInsertRows() def removeChild(self, parent: QModelIndex, pos: int) -> ProjectNode | None: """Remove a node from the model and return it.""" node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root if 0 <= pos < node.childCount(): self.beginRemoveRows(parent, pos, pos) child = node.takeChild(pos) self.endRemoveRows() return child return None def internalMove(self, index: QModelIndex, step: int) -> None: """Move an item internally among its siblings.""" if index.isValid(): node: ProjectNode = index.internalPointer() if parent := node.parent(): pos = index.row() new = minmax(pos + step, 0, parent.childCount() - 1) if new != pos: end = new if new < pos else new + 1 self.beginMoveRows(index.parent(), pos, pos, index.parent(), end) parent.moveChild(pos, new) self.endMoveRows() def multiMove(self, indices: list[QModelIndex], target: QModelIndex, pos: int = -1) -> None: """Move multiple items to a new location.""" if target.isValid(): # This is a two pass process. First we only select unique # non-root items for move, then we do a second pass and only # move those items that don't have a parent also scheduled # for moving or have already been moved. Child items are # moved with the parent. pruned = [] handles = set() for index in indices: if index.isValid(): node: ProjectNode = index.internalPointer() handle = node.item.itemHandle if node.item.isRootType() is False and handle not in handles: pruned.append(node) handles.add(handle) for node in (reversed(pruned) if pos >= 0 else pruned): if node.item.itemParent not in handles: index = self.indexFromNode(node) if temp := self.removeChild(index.parent(), index.row()): self.insertChild(temp, target, pos) for child in reversed(node.allChildren()): node._updateRelationships(child) # noqa: SLF001 child.item.notifyToRefresh() node.item.notifyToRefresh() ## # Other Methods ## def clear(self) -> None: """Clear the project model.""" self._root.children.clear() def allExpanded(self) -> list[QModelIndex]: """Return a list of all expanded items.""" return [ self.createIndex(node.row(), 0, node) for node in self._root.allChildren() if node.item.isExpanded ] def trashSelection(self, indices: list[QModelIndex]) -> bool: """Check if a selection of indices are all in trash or not.""" for index in indices: if index.isValid(): node: ProjectNode = index.internalPointer() if node.item.itemClass != nwItemClass.TRASH: return False return True