From cbe557507ff93b3a6e57f85090c865ba6d3d080e Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 17 Nov 2024 19:25:55 +0100 Subject: [PATCH] Add a basic project tree model --- novelwriter/core/itemmodel.py | 187 ++++++++++++++++++++++++++++++++++ novelwriter/core/project.py | 1 + novelwriter/core/tree.py | 31 +++++- novelwriter/gui/projtree.py | 54 +++++++++- 4 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 novelwriter/core/itemmodel.py diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py new file mode 100644 index 00000000..4b8a574e --- /dev/null +++ b/novelwriter/core/itemmodel.py @@ -0,0 +1,187 @@ +""" +novelWriter – Project Item Model +================================ + +File History: +Created: 2024-11-16 [2.7b1] ProjectNode +Created: 2024-11-16 [2.7b1] ProjectModel + +This file is a part of novelWriter +Copyright 2018–2024, Veronica Berglyd Olsen + +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 . +""" +from __future__ import annotations + +import logging + +from typing import TYPE_CHECKING, Any + +from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt +from PyQt5.QtGui import QIcon + +from novelwriter import SHARED +from novelwriter.core.item import NWItem +from novelwriter.types import QtAlignRight + +if TYPE_CHECKING: # pragma: no cover + from novelwriter.core.tree import NWTree + +logger = logging.getLogger(__name__) + +COL_MASK = 0x0100 + +C_LABEL_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole +C_LABEL_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole +C_LABEL_TIP = 0x0000 | Qt.ItemDataRole.ToolTipRole +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_STATUS_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole +C_STATUS_TIP = 0x0300 | Qt.ItemDataRole.ToolTipRole + + +class ProjectNode: + + __slots__ = ("_item", "_children", "_parent", "_row", "_cache", "_count") + + def __init__(self, item: NWItem) -> None: + self._item = item + self._children = [] + self._parent: ProjectNode | None = None + self._row = 0 + self._cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {} + self.refresh() + return + + def refresh(self) -> None: + cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {} + + # Label + cache[C_LABEL_ICON] = SHARED.theme.getItemIcon( + self._item.itemType, self._item.itemClass, + self._item.itemLayout, self._item.mainHeading + ) + cache[C_LABEL_TEXT] = self._item.itemName + cache[C_LABEL_TIP] = self._item.itemName + + # Count + cache[C_COUNT_ALIGN] = QtAlignRight + + # Active + if self._item.isFileType(): + if self._item.isActive: + cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("checked") + else: + cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("unchecked") + else: + cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("noncheckable") + + # Status + sText, sIcon = self._item.getImportStatus() + cache[C_STATUS_ICON] = sIcon + cache[C_STATUS_TIP] = sText + + self._cache = cache + self.updateCount() + + return + + def updateCount(self) -> None: + self._count = self._item.wordCount + sum(c._count for c in self._children) + self._cache[C_COUNT_TEXT] = f"{self._count:n}" + if parent := self._parent: + parent.updateCount() + return + + def row(self) -> int: + return self._row + + def childCount(self) -> int: + return len(self._children) + + def data(self, column: int, role: Qt.ItemDataRole) -> str | QIcon | Qt.AlignmentFlag | None: + """""" + return self._cache.get(COL_MASK*column | role) + + def parent(self) -> ProjectNode | None: + return self._parent + + def child(self, row: int) -> ProjectNode | None: + if 0 <= row < len(self._children): + return self._children[row] + return None + + def addChild(self, child: ProjectNode) -> None: + child._parent = self + child._row = len(self._children) + self._children.append(child) + self.refresh() + return + + +class ProjectModel(QAbstractItemModel): + + def __init__(self, tree: NWTree) -> None: + super().__init__(None) + self._root = ProjectNode(NWItem(tree._project, "")) + return + + def setRoot(self, root: ProjectNode) -> None: + self._root = root + return + + def rowCount(self, index: QModelIndex) -> int: + if index.isValid(): + return index.internalPointer().childCount() + return self._root.childCount() + + def columnCount(self, index: QModelIndex) -> int: + return 4 + + def parent(self, index: QModelIndex) -> QModelIndex: + if index.isValid(): + if parent := index.internalPointer().parent(): + return self.createIndex(parent.row(), 0, parent) + return QModelIndex() + + def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: + if parent and parent.isValid(): + item = parent.internalPointer() + else: + item = self._root + + if not self.hasIndex(row, column, parent): + return QModelIndex() + + if child := item.child(row): + return self.createIndex(row, column, child) + + return QModelIndex() + + def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> Any: + if not index.isValid(): + return None + node = index.internalPointer() + return node.data(index.column(), role) + + def addChild(self, node: ProjectNode, parent: QModelIndex) -> None: + if parent and parent.isValid(): + item = parent.internalPointer() + else: + item = self._root + item.addChild(node) + return diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 69aaadcf..e7025af6 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -329,6 +329,7 @@ class NWProject: # ============ self._tree.unpack(projContent) + self._tree.buildModel() self._options.loadSettings() self._loadProjectLocalisation() diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 01131b93..08ca284f 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -33,6 +33,7 @@ from typing import TYPE_CHECKING, Literal, overload from novelwriter.common import isHandle from novelwriter.constants import nwFiles from novelwriter.core.item import NWItem +from novelwriter.core.itemmodel import ProjectModel, ProjectNode from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.error import logException @@ -62,7 +63,7 @@ class NWTree: also used for file names. """ - __slots__ = ("_project", "_tree", "_order", "_roots", "_trash", "_changed") + __slots__ = ("_project", "_tree", "_order", "_roots", "_model", "_nodes", "_trash", "_changed") def __init__(self, project: NWProject) -> None: @@ -72,6 +73,9 @@ class NWTree: self._order: list[str] = [] # The order of the tree items in the tree view self._roots: dict[str, NWItem] = {} # The root items of the tree + self._model = ProjectModel(self) + self._nodes: dict[str, ProjectNode] = {} + self._trash = None # The handle of the trash root folder self._changed = False # True if tree structure has changed @@ -86,6 +90,10 @@ class NWTree: """Return the handle of the trash folder, or None.""" return self._trash + @property + def model(self) -> ProjectModel: + return self._model + ## # Class Methods ## @@ -196,6 +204,27 @@ class NWTree: nwItem.saveInitialCount() return + def buildModel(self) -> None: + """""" + root = ProjectNode(NWItem(self._project, "")) + for item in self._tree.values(): + node = ProjectNode(item) + self._nodes[item.itemHandle] = node + if pHandle := item.itemParent: + if parent := self._nodes.get(pHandle): + parent.addChild(node) + else: + logger.error("Could not add item '%s'", item.itemHandle) + else: + root.addChild(node) + + self._model.beginInsertRows(self._model.index(0, 0), 0, 0) + self._model.setRoot(root) + self._model.endInsertRows() + self._model.layoutChanged.emit() + + return + def checkConsistency(self, prefix: str) -> tuple[int, int]: """Check the project tree consistency. Also check the content folder and add back files that were discovered but were not diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 5aaa48b4..295993b3 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -34,7 +34,8 @@ from PyQt5.QtCore import QPoint, Qt, QTimer, pyqtSignal, pyqtSlot from PyQt5.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QIcon, QMouseEvent, QPalette from PyQt5.QtWidgets import ( QAbstractItemView, QAction, QFrame, QHBoxLayout, QHeaderView, QLabel, - QMenu, QShortcut, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget + QMenu, QShortcut, QTreeView, QTreeWidget, QTreeWidgetItem, QVBoxLayout, + QWidget ) from novelwriter import CONFIG, SHARED @@ -80,12 +81,14 @@ class GuiProjectView(QWidget): # Build GUI self.projTree = GuiProjectTree(self) + self.projTree2 = GuiProjectTree2(self) self.projBar = GuiProjectToolBar(self) self.projBar.setEnabled(False) # Assemble self.outerBox = QVBoxLayout() self.outerBox.addWidget(self.projBar, 0) + self.outerBox.addWidget(self.projTree2, 3) self.outerBox.addWidget(self.projTree, 1) self.outerBox.setContentsMargins(0, 0, 0, 0) self.outerBox.setSpacing(0) @@ -177,6 +180,7 @@ class GuiProjectView(QWidget): def populateTree(self) -> None: """Build the tree structure from project data.""" self.projTree.buildTree() + self.projTree2.loadModel() return def setTreeFocus(self) -> None: @@ -486,6 +490,54 @@ class GuiProjectToolBar(QWidget): return +class GuiProjectTree2(QTreeView): + + C_NAME = 0 + C_COUNT = 1 + C_ACTIVE = 2 + C_STATUS = 3 + + def __init__(self, projView: GuiProjectView) -> None: + super().__init__(parent=projView) + + logger.debug("Create: GuiProjectTree") + + # Tree Settings + iPx = SHARED.theme.baseIconHeight + + self.setIconSize(SHARED.theme.baseIconSize) + self.setFrameStyle(QFrame.Shape.NoFrame) + self.setUniformRowHeights(True) + self.setAllColumnsShowFocus(True) + self.setExpandsOnDoubleClick(False) + self.setAutoExpandDelay(1000) + self.setHeaderHidden(True) + self.setIndentation(iPx) + + logger.debug("Ready: GuiProjectTree") + + return + + def loadModel(self) -> None: + self.setModel(SHARED.project.tree.model) + + # Lock the column sizes + iPx = SHARED.theme.baseIconHeight + cMg = CONFIG.pxInt(6) + + treeHeader = self.header() + treeHeader.setStretchLastSection(False) + treeHeader.setMinimumSectionSize(iPx + cMg) + treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch) + treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeMode.ResizeToContents) + treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.ResizeMode.Fixed) + treeHeader.setSectionResizeMode(self.C_STATUS, QHeaderView.ResizeMode.Fixed) + treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg) + treeHeader.resizeSection(self.C_STATUS, iPx + cMg) + + return + + class GuiProjectTree(QTreeWidget): C_DATA = 0