Add a basic project tree model

This commit is contained in:
Veronica Berglyd Olsen
2024-11-17 19:25:55 +01:00
parent 7a9da5f219
commit cbe557507f
4 changed files with 271 additions and 2 deletions
+187
View File
@@ -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 20182024, 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 <https://www.gnu.org/licenses/>.
"""
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
+1
View File
@@ -329,6 +329,7 @@ class NWProject:
# ============
self._tree.unpack(projContent)
self._tree.buildModel()
self._options.loadSettings()
self._loadProjectLocalisation()
+30 -1
View File
@@ -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
+53 -1
View File
@@ -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