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.unpack(projContent)
|
||||||
|
self._tree.buildModel()
|
||||||
self._options.loadSettings()
|
self._options.loadSettings()
|
||||||
self._loadProjectLocalisation()
|
self._loadProjectLocalisation()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user