Add a basic project tree model
This commit is contained in:
@@ -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 <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
|
||||
@@ -329,6 +329,7 @@ class NWProject:
|
||||
# ============
|
||||
|
||||
self._tree.unpack(projContent)
|
||||
self._tree.buildModel()
|
||||
self._options.loadSettings()
|
||||
self._loadProjectLocalisation()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user