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.unpack(projContent)
self._tree.buildModel()
self._options.loadSettings() self._options.loadSettings()
self._loadProjectLocalisation() self._loadProjectLocalisation()
+30 -1
View File
@@ -33,6 +33,7 @@ from typing import TYPE_CHECKING, Literal, overload
from novelwriter.common import isHandle from novelwriter.common import isHandle
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.item import NWItem from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException from novelwriter.error import logException
@@ -62,7 +63,7 @@ class NWTree:
also used for file names. 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: 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._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._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._trash = None # The handle of the trash root folder
self._changed = False # True if tree structure has changed 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 the handle of the trash folder, or None."""
return self._trash return self._trash
@property
def model(self) -> ProjectModel:
return self._model
## ##
# Class Methods # Class Methods
## ##
@@ -196,6 +204,27 @@ class NWTree:
nwItem.saveInitialCount() nwItem.saveInitialCount()
return 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]: def checkConsistency(self, prefix: str) -> tuple[int, int]:
"""Check the project tree consistency. Also check the content """Check the project tree consistency. Also check the content
folder and add back files that were discovered but were not 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.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QIcon, QMouseEvent, QPalette
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QAbstractItemView, QAction, QFrame, QHBoxLayout, QHeaderView, QLabel, QAbstractItemView, QAction, QFrame, QHBoxLayout, QHeaderView, QLabel,
QMenu, QShortcut, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget QMenu, QShortcut, QTreeView, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
QWidget
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
@@ -80,12 +81,14 @@ class GuiProjectView(QWidget):
# Build GUI # Build GUI
self.projTree = GuiProjectTree(self) self.projTree = GuiProjectTree(self)
self.projTree2 = GuiProjectTree2(self)
self.projBar = GuiProjectToolBar(self) self.projBar = GuiProjectToolBar(self)
self.projBar.setEnabled(False) self.projBar.setEnabled(False)
# Assemble # Assemble
self.outerBox = QVBoxLayout() self.outerBox = QVBoxLayout()
self.outerBox.addWidget(self.projBar, 0) self.outerBox.addWidget(self.projBar, 0)
self.outerBox.addWidget(self.projTree2, 3)
self.outerBox.addWidget(self.projTree, 1) self.outerBox.addWidget(self.projTree, 1)
self.outerBox.setContentsMargins(0, 0, 0, 0) self.outerBox.setContentsMargins(0, 0, 0, 0)
self.outerBox.setSpacing(0) self.outerBox.setSpacing(0)
@@ -177,6 +180,7 @@ class GuiProjectView(QWidget):
def populateTree(self) -> None: def populateTree(self) -> None:
"""Build the tree structure from project data.""" """Build the tree structure from project data."""
self.projTree.buildTree() self.projTree.buildTree()
self.projTree2.loadModel()
return return
def setTreeFocus(self) -> None: def setTreeFocus(self) -> None:
@@ -486,6 +490,54 @@ class GuiProjectToolBar(QWidget):
return 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): class GuiProjectTree(QTreeWidget):
C_DATA = 0 C_DATA = 0