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 01/39] 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
From 27b3dde6f6463cb5d62e1461e54275d317c18071 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 17 Nov 2024 21:24:55 +0100
Subject: [PATCH 02/39] Complete build initial tree and save tree logic
---
novelwriter/core/item.py | 2 +
novelwriter/core/itemmodel.py | 61 +++++++++++++++++++++------
novelwriter/core/tree.py | 79 +++++++++++++++++++++++++----------
novelwriter/gui/projtree.py | 5 +++
tests/mocked.py | 8 +++-
5 files changed, 121 insertions(+), 34 deletions(-)
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index c89af5ee..efa6de18 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -256,6 +256,8 @@ class NWItem:
self._paraCount = 0
self._cursorPos = 0
+ self._initCount = self._wordCount
+
return True
@classmethod
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 4b8a574e..eb93d9cb 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -60,13 +60,17 @@ class ProjectNode:
def __init__(self, item: NWItem) -> None:
self._item = item
- self._children = []
+ self._children: list[ProjectNode] = []
self._parent: ProjectNode | None = None
self._row = 0
self._cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {}
self.refresh()
return
+ @property
+ def item(self) -> NWItem:
+ return self._item
+
def refresh(self) -> None:
cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {}
@@ -132,17 +136,38 @@ class ProjectNode:
self.refresh()
return
+ def allChildren(self) -> list[ProjectNode]:
+ nodes: list[ProjectNode] = []
+ self._recursiveAppendChildren(nodes)
+ return nodes
+
+ ##
+ # Internal Functions
+ ##
+
+ def _recursiveAppendChildren(self, children: list[ProjectNode]) -> None:
+ for node in self._children:
+ children.append(node)
+ node._recursiveAppendChildren(children)
+ return
+
class ProjectModel(QAbstractItemModel):
+ __slots__ = ("_root", "_map")
+
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
+ @property
+ def root(self) -> ProjectNode:
+ return self._root
+
+ ##
+ # Model Access
+ ##
def rowCount(self, index: QModelIndex) -> int:
if index.isValid():
@@ -159,7 +184,7 @@ class ProjectModel(QAbstractItemModel):
return QModelIndex()
def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex:
- if parent and parent.isValid():
+ if parent.isValid():
item = parent.internalPointer()
else:
item = self._root
@@ -178,10 +203,22 @@ class ProjectModel(QAbstractItemModel):
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
+ # def addChild(self, node: ProjectNode, parent: QModelIndex) -> None:
+ # if parent.isValid():
+ # item = parent.internalPointer()
+ # else:
+ # item = self._root
+ # item.addChild(node)
+ # return
+
+ ##
+ # Methods
+ ##
+
+ def allExpanded(self) -> list[QModelIndex]:
+ """Return a list of all expanded items."""
+ expanded = []
+ for node in self._root.allChildren():
+ if node._item.isExpanded:
+ expanded.append(self.createIndex(node.row(), 0, node))
+ return expanded
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 08ca284f..ff8a5292 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -63,7 +63,10 @@ class NWTree:
also used for file names.
"""
- __slots__ = ("_project", "_tree", "_order", "_roots", "_model", "_nodes", "_trash", "_changed")
+ __slots__ = (
+ "_project", "_tree", "_order", "_roots",
+ "_model", "_items", "_nodes", "_trash", "_changed",
+ )
def __init__(self, project: NWProject) -> None:
@@ -74,6 +77,7 @@ class NWTree:
self._roots: dict[str, NWItem] = {} # The root items of the tree
self._model = ProjectModel(self)
+ self._items: dict[str, NWItem] = {}
self._nodes: dict[str, ProjectNode] = {}
self._trash = None # The handle of the trash root folder
@@ -103,6 +107,11 @@ class NWTree:
self._tree = {}
self._order = []
self._roots = {}
+
+ self._model = ProjectModel(self)
+ self._items = {}
+ self._nodes = {}
+
self._trash = None
self._changed = False
return
@@ -185,12 +194,20 @@ class NWTree:
"""Pack the content of the tree into a list of dictionaries of
items. In the order defined by the _treeOrder list.
"""
- tree = []
- for tHandle in self._order:
- tItem = self.__getitem__(tHandle)
- if tItem:
- tree.append(tItem.pack())
- return tree
+ nodes = self._model.root.allChildren()
+ if len(nodes) != len(self._nodes):
+ logger.warning(
+ "Model tree is inconsitent with nodes map, %d != %d",
+ len(nodes), len(self._nodes)
+ )
+
+ # tree = []
+ # for tHandle in self._order:
+ # tItem = self.__getitem__(tHandle)
+ # if tItem:
+ # tree.append(tItem.pack())
+
+ return [node.item.pack() for node in nodes]
def unpack(self, data: list[dict]) -> None:
"""Iterate through all items of a list and add them to the
@@ -200,31 +217,51 @@ class NWTree:
for item in data:
nwItem = NWItem(self._project, "") # Handle is set by unpack()
if nwItem.unpack(item):
+ self._items[nwItem.itemHandle] = nwItem
self.append(nwItem)
- 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)
+ later: dict[str, NWItem] = self._items.copy()
+ for _ in range(999):
+ later = self._buildTree(later)
+ if len(later) == 0:
+ break
+ else:
+ logger.error("Not all items could be added to project tree")
+ for item in later.values():
+ item.setParent(None)
+
self._model.endInsertRows()
self._model.layoutChanged.emit()
return
+ def _buildTree(self, items: dict[str, NWItem]) -> dict[str, NWItem]:
+ """"""
+ remains: dict[str, NWItem] = {}
+ for handle, item in items.items():
+ if pHandle := item.itemParent:
+ if parent := self._nodes.get(pHandle):
+ node = ProjectNode(item)
+ parent.addChild(node)
+ self._nodes[handle] = node
+ elif pHandle in items:
+ remains[handle] = item
+ logger.warning("Item '%s' found before its parent", handle)
+ else:
+ item.setParent(None)
+ logger.error("Item '%s' has no parent in current tree", handle)
+ elif item.isRootType():
+ node = ProjectNode(item)
+ self._model.root.addChild(node)
+ self._nodes[handle] = node
+
+ return remains
+
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 295993b3..6dc3c0c2 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -535,6 +535,11 @@ class GuiProjectTree2(QTreeView):
treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg)
treeHeader.resizeSection(self.C_STATUS, iPx + cMg)
+ self.blockSignals(True)
+ for index in SHARED.project.tree.model.allExpanded():
+ self.setExpanded(index, True)
+ self.blockSignals(False)
+
return
diff --git a/tests/mocked.py b/tests/mocked.py
index afa62d25..986fd244 100644
--- a/tests/mocked.py
+++ b/tests/mocked.py
@@ -20,7 +20,7 @@ along with this program. If not, see .
"""
from __future__ import annotations
-from PyQt5.QtGui import QPixmap
+from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtWidgets import QWidget
@@ -73,6 +73,12 @@ class MockTheme:
def getPixmap(self, *a):
return QPixmap()
+ def getIcon(self, *a) -> QIcon:
+ return QIcon()
+
+ def getItemIcon(self, *a, **k) -> QIcon:
+ return QIcon()
+
class MockApp:
From ef42e1956cce95da8f5860fc1699057abee37644 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 17 Nov 2024 22:07:37 +0100
Subject: [PATCH 03/39] Merge new and old project tree
---
novelwriter/core/itemmodel.py | 11 +-
novelwriter/gui/projtree.py | 1570 ++++++++++++++++-----------------
2 files changed, 777 insertions(+), 804 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index eb93d9cb..e504444e 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -166,7 +166,7 @@ class ProjectModel(QAbstractItemModel):
return self._root
##
- # Model Access
+ # Model Interface
##
def rowCount(self, index: QModelIndex) -> int:
@@ -211,6 +211,15 @@ class ProjectModel(QAbstractItemModel):
# item.addChild(node)
# return
+ ##
+ # Data Access
+ ##
+
+ def node(self, index: QModelIndex) -> ProjectNode | None:
+ if index.isValid():
+ return index.internalPointer()
+ return None
+
##
# Methods
##
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 6dc3c0c2..cb1d0354 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -30,30 +30,23 @@ import logging
from enum import Enum
-from PyQt5.QtCore import QPoint, Qt, QTimer, pyqtSignal, pyqtSlot
-from PyQt5.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QIcon, QMouseEvent, QPalette
+from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
+from PyQt5.QtGui import QIcon, QPalette
from PyQt5.QtWidgets import (
QAbstractItemView, QAction, QFrame, QHBoxLayout, QHeaderView, QLabel,
- QMenu, QShortcut, QTreeView, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
- QWidget
+ QMenu, QShortcut, QTreeView, QTreeWidgetItem, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
-from novelwriter.common import minmax, qtLambda
-from novelwriter.constants import nwLabels, nwStyles, nwUnicode, trConst
-from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter
+from novelwriter.common import qtLambda
+from novelwriter.constants import nwLabels, nwUnicode, trConst
from novelwriter.core.item import NWItem
-from novelwriter.dialogs.docmerge import GuiDocMerge
-from novelwriter.dialogs.docsplit import GuiDocSplit
-from novelwriter.dialogs.editlabel import GuiEditLabel
+from novelwriter.core.itemmodel import ProjectModel
from novelwriter.dialogs.projectsettings import GuiProjectSettings
from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
-from novelwriter.types import (
- QtAlignLeft, QtAlignRight, QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff,
- QtScrollAsNeeded, QtSizeExpanding, QtUserRole
-)
+from novelwriter.types import QtScrollAlwaysOff, QtScrollAsNeeded, QtSizeExpanding
logger = logging.getLogger(__name__)
@@ -81,14 +74,12 @@ 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)
@@ -179,8 +170,8 @@ class GuiProjectView(QWidget):
def populateTree(self) -> None:
"""Build the tree structure from project data."""
- self.projTree.buildTree()
- self.projTree2.loadModel()
+ self.projTree.loadModel()
+ # self.projTree.buildTree()
return
def setTreeFocus(self) -> None:
@@ -490,69 +481,16 @@ class GuiProjectToolBar(QWidget):
return
-class GuiProjectTree2(QTreeView):
+class GuiProjectTree(QTreeView):
+ # C_DATA = 0
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)
-
- self.blockSignals(True)
- for index in SHARED.project.tree.model.allExpanded():
- self.setExpanded(index, True)
- self.blockSignals(False)
-
- return
-
-
-class GuiProjectTree(QTreeWidget):
-
- C_DATA = 0
- C_NAME = 0
- C_COUNT = 1
- C_ACTIVE = 2
- C_STATUS = 3
-
- D_HANDLE = QtUserRole
- D_WORDS = QtUserRole + 1
+ # D_HANDLE = QtUserRole
+ # D_WORDS = QtUserRole + 1
itemRefreshed = pyqtSignal(str, NWItem, QIcon)
@@ -564,9 +502,9 @@ class GuiProjectTree(QTreeWidget):
self.projView = projView
# Internal Variables
- self._treeMap: dict[str, QTreeWidgetItem] = {}
- self._popAlert = None
- self._actHandle = None
+ # self._treeMap: dict[str, QTreeWidgetItem] = {}
+ # self._popAlert = None
+ # self._actHandle = None
# Cached Translations
self.trActive = self.tr("Active")
@@ -577,12 +515,12 @@ class GuiProjectTree(QTreeWidget):
# =========
# Context Menu
- self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self.customContextMenuRequested.connect(self.openContextMenu)
+ # self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ # self.customContextMenuRequested.connect(self.openContextMenu)
# Tree Settings
iPx = SHARED.theme.baseIconHeight
- cMg = CONFIG.pxInt(6)
+ # cMg = CONFIG.pxInt(6)
self.setIconSize(SHARED.theme.baseIconSize)
self.setFrameStyle(QFrame.Shape.NoFrame)
@@ -592,47 +530,49 @@ class GuiProjectTree(QTreeWidget):
self.setAutoExpandDelay(1000)
self.setHeaderHidden(True)
self.setIndentation(iPx)
- self.setColumnCount(4)
+ # self.setColumnCount(4)
# Lock the column sizes
- 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)
+ # 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)
# Allow Move by Drag & Drop
- self.setDragEnabled(True)
- self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
+ # self.setDragEnabled(True)
+ # self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
# Disable built-in auto scroll as it isn't working in some Qt
# releases (see #1561) and instead use our own implementation
- self.setAutoScroll(False)
+ # self.setAutoScroll(False)
# But don't allow drop on root level
# Due to a bug, this stops working somewhere between Qt 5.15.3
# and 5.15.8, so this is also blocked in dropEvent (see #1569)
- trRoot = self.invisibleRootItem()
- trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled)
+ # trRoot = self.invisibleRootItem()
+ # trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled)
# Set selection options
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# Connect signals
- self.itemDoubleClicked.connect(self._treeDoubleClick)
- self.itemSelectionChanged.connect(self._treeSelectionChange)
+ # self.itemDoubleClicked.connect(self._treeDoubleClick)
+ # self.itemSelectionChanged.connect(self._treeSelectionChange)
+ self.clicked.connect(self._treeSingleClick)
+ self.doubleClicked.connect(self._treeDoubleClick)
# Auto Scroll
- self._scrollMargin = SHARED.theme.baseIconHeight
- self._scrollDirection = 0
- self._scrollTimer = QTimer(self)
- self._scrollTimer.timeout.connect(self._doAutoScroll)
- self._scrollTimer.setInterval(250)
+ # self._scrollMargin = SHARED.theme.baseIconHeight
+ # self._scrollDirection = 0
+ # self._scrollTimer = QTimer(self)
+ # self._scrollTimer.timeout.connect(self._doAutoScroll)
+ # self._scrollTimer.setInterval(250)
# Set custom settings
self.initSettings()
@@ -658,23 +598,47 @@ class GuiProjectTree(QTreeWidget):
# Class Methods
##
+ 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)
+
+ self.blockSignals(True)
+ for index in SHARED.project.tree.model.allExpanded():
+ self.setExpanded(index, True)
+ self.blockSignals(False)
+
+ return
+
def clearTree(self) -> None:
"""Clear the GUI content and the related map."""
- self.clear()
- self._treeMap = {}
+ # self.clear()
+ # self._treeMap = {}
return
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Create a new note. This function is used by the document
editor to create note files for unknown tags.
"""
- if itemClass != nwItemClass.NO_CLASS:
- if not (rHandle := SHARED.project.tree.findRoot(itemClass)):
- self.newTreeItem(nwItemType.ROOT, itemClass)
- rHandle = SHARED.project.tree.findRoot(itemClass)
- if rHandle and (tHandle := SHARED.project.newFile(tag, rHandle)):
- SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
- self.revealNewTreeItem(tHandle, wordCount=True)
+ # if itemClass != nwItemClass.NO_CLASS:
+ # if not (rHandle := SHARED.project.tree.findRoot(itemClass)):
+ # self.newTreeItem(nwItemType.ROOT, itemClass)
+ # rHandle = SHARED.project.tree.findRoot(itemClass)
+ # if rHandle and (tHandle := SHARED.project.newFile(tag, rHandle)):
+ # SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
+ # self.revealNewTreeItem(tHandle, wordCount=True)
return
def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None,
@@ -684,194 +648,194 @@ class GuiProjectTree(QTreeWidget):
make sure the item is added in a place it can be added, and that
other meta data is set correctly to ensure a valid project tree.
"""
- if not SHARED.hasProject:
- logger.error("No project open")
- return False
+ # if not SHARED.hasProject:
+ # logger.error("No project open")
+ # return False
- nHandle = None
- tHandle = None
+ # nHandle = None
+ # tHandle = None
- if itemType == nwItemType.ROOT and isinstance(itemClass, nwItemClass):
+ # if itemType == nwItemType.ROOT and isinstance(itemClass, nwItemClass):
- tHandle = SHARED.project.newRoot(itemClass)
- sHandle = self.getSelectedHandle()
- pItem = SHARED.project.tree[sHandle] if sHandle else None
- nHandle = pItem.itemRoot if pItem else None
+ # tHandle = SHARED.project.newRoot(itemClass)
+ # sHandle = self.getSelectedHandle()
+ # pItem = SHARED.project.tree[sHandle] if sHandle else None
+ # nHandle = pItem.itemRoot if pItem else None
- elif itemType in (nwItemType.FILE, nwItemType.FOLDER):
+ # elif itemType in (nwItemType.FILE, nwItemType.FOLDER):
- sHandle = self.getSelectedHandle()
- pItem = SHARED.project.tree[sHandle] if sHandle else None
- if sHandle is None or pItem is None:
- SHARED.error(self.tr("Did not find anywhere to add the file or folder!"))
- return False
+ # sHandle = self.getSelectedHandle()
+ # pItem = SHARED.project.tree[sHandle] if sHandle else None
+ # if sHandle is None or pItem is None:
+ # SHARED.error(self.tr("Did not find anywhere to add the file or folder!"))
+ # return False
- # Collect some information about the selected item
- qItem = self._getTreeItem(sHandle)
- sLevel = nwStyles.H_LEVEL.get(pItem.mainHeading, 0)
- sIsParent = False if qItem is None else qItem.childCount() > 0
+ # # Collect some information about the selected item
+ # qItem = self._getTreeItem(sHandle)
+ # sLevel = nwStyles.H_LEVEL.get(pItem.mainHeading, 0)
+ # sIsParent = False if qItem is None else qItem.childCount() > 0
- if SHARED.project.tree.isTrash(sHandle):
- SHARED.error(self.tr("Cannot add new files or folders to the Trash folder."))
- return False
+ # if SHARED.project.tree.isTrash(sHandle):
+ # SHARED.error(self.tr("Cannot add new files or folders to the Trash folder."))
+ # return False
- # Set default label and determine if new item is to be added
- # as child or sibling to the selected item
- if itemType == nwItemType.FILE:
- if copyDoc and (cItem := SHARED.project.tree[copyDoc]):
- newLabel = cItem.itemName
- asChild = sIsParent and pItem.isDocumentLayout()
- elif isNote:
- newLabel = self.tr("New Note")
- asChild = sIsParent
- elif hLevel == 2:
- newLabel = self.tr("New Chapter")
- asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 2
- elif hLevel == 3:
- newLabel = self.tr("New Scene")
- asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 3
- else:
- newLabel = self.tr("New Document")
- asChild = sIsParent and pItem.isDocumentLayout()
- else:
- newLabel = self.tr("New Folder")
- asChild = False
+ # # Set default label and determine if new item is to be added
+ # # as child or sibling to the selected item
+ # if itemType == nwItemType.FILE:
+ # if copyDoc and (cItem := SHARED.project.tree[copyDoc]):
+ # newLabel = cItem.itemName
+ # asChild = sIsParent and pItem.isDocumentLayout()
+ # elif isNote:
+ # newLabel = self.tr("New Note")
+ # asChild = sIsParent
+ # elif hLevel == 2:
+ # newLabel = self.tr("New Chapter")
+ # asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 2
+ # elif hLevel == 3:
+ # newLabel = self.tr("New Scene")
+ # asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 3
+ # else:
+ # newLabel = self.tr("New Document")
+ # asChild = sIsParent and pItem.isDocumentLayout()
+ # else:
+ # newLabel = self.tr("New Folder")
+ # asChild = False
- if not (asChild or pItem.isFolderType() or pItem.isRootType()):
- # Move to the parent item so that the new item is added
- # as a sibling instead
- nHandle = sHandle
- sHandle = pItem.itemParent
- if sHandle is None:
- # Bug: We have a condition that is unhandled
- logger.error("Internal error")
- return False
+ # if not (asChild or pItem.isFolderType() or pItem.isRootType()):
+ # # Move to the parent item so that the new item is added
+ # # as a sibling instead
+ # nHandle = sHandle
+ # sHandle = pItem.itemParent
+ # if sHandle is None:
+ # # Bug: We have a condition that is unhandled
+ # logger.error("Internal error")
+ # return False
- # Ask for label
- newLabel, dlgOk = GuiEditLabel.getLabel(self, text=newLabel)
- if not dlgOk:
- logger.info("New item creation cancelled by user")
- return False
+ # # Ask for label
+ # newLabel, dlgOk = GuiEditLabel.getLabel(self, text=newLabel)
+ # if not dlgOk:
+ # logger.info("New item creation cancelled by user")
+ # return False
- # Add the file or folder
- if itemType == nwItemType.FILE:
- tHandle = SHARED.project.newFile(newLabel, sHandle)
- else:
- tHandle = SHARED.project.newFolder(newLabel, sHandle)
+ # # Add the file or folder
+ # if itemType == nwItemType.FILE:
+ # tHandle = SHARED.project.newFile(newLabel, sHandle)
+ # else:
+ # tHandle = SHARED.project.newFolder(newLabel, sHandle)
- else:
- logger.error("Failed to add new item")
- return False
+ # else:
+ # logger.error("Failed to add new item")
+ # return False
- # If there is no handle set, return here. This is a bug.
- if tHandle is None: # pragma: no cover
- logger.error("Internal error")
- return True
+ # # If there is no handle set, return here. This is a bug.
+ # if tHandle is None: # pragma: no cover
+ # logger.error("Internal error")
+ # return True
- # Handle new file creation
- if itemType == nwItemType.FILE and copyDoc:
- SHARED.project.copyFileContent(tHandle, copyDoc)
- elif itemType == nwItemType.FILE and hLevel > 0:
- SHARED.project.writeNewFile(tHandle, hLevel, not isNote)
+ # # Handle new file creation
+ # if itemType == nwItemType.FILE and copyDoc:
+ # SHARED.project.copyFileContent(tHandle, copyDoc)
+ # elif itemType == nwItemType.FILE and hLevel > 0:
+ # SHARED.project.writeNewFile(tHandle, hLevel, not isNote)
- # Add the new item to the project tree
- self.revealNewTreeItem(tHandle, nHandle=nHandle, wordCount=True)
- self.projView.setTreeFocus() # See issue #1376
+ # # Add the new item to the project tree
+ # self.revealNewTreeItem(tHandle, nHandle=nHandle, wordCount=True)
+ # self.projView.setTreeFocus() # See issue #1376
return True
def revealNewTreeItem(self, tHandle: str | None, nHandle: str | None = None,
wordCount: bool = False) -> bool:
"""Reveal a newly added project item in the project tree."""
- nwItem = SHARED.project.tree[tHandle] if tHandle else None
- if tHandle is None or nwItem is None:
- return False
+ # nwItem = SHARED.project.tree[tHandle] if tHandle else None
+ # if tHandle is None or nwItem is None:
+ # return False
- trItem = self._addTreeItem(nwItem, nHandle)
- if trItem is None:
- return False
+ # trItem = self._addTreeItem(nwItem, nHandle)
+ # if trItem is None:
+ # return False
- if nwItem.isFileType() and wordCount:
- wC = SHARED.project.index.getCounts(tHandle)[1]
- self.propagateCount(tHandle, wC)
- self.projView.wordCountsChanged.emit()
+ # if nwItem.isFileType() and wordCount:
+ # wC = SHARED.project.index.getCounts(tHandle)[1]
+ # self.propagateCount(tHandle, wC)
+ # self.projView.wordCountsChanged.emit()
- pHandle = nwItem.itemParent
- if pHandle is not None and pHandle in self._treeMap:
- self._treeMap[pHandle].setExpanded(True)
+ # pHandle = nwItem.itemParent
+ # if pHandle is not None and pHandle in self._treeMap:
+ # self._treeMap[pHandle].setExpanded(True)
- self._alertTreeChange(tHandle, flush=True)
- self.setCurrentItem(trItem)
+ # self._alertTreeChange(tHandle, flush=True)
+ # self.setCurrentItem(trItem)
return True
def moveTreeItem(self, step: int) -> bool:
"""Move an item up or down in the tree."""
- tHandle = self.getSelectedHandle()
- tItem = self._getTreeItem(tHandle)
- if tItem is None:
- logger.debug("No item selected")
- return False
+ # tHandle = self.getSelectedHandle()
+ # tItem = self._getTreeItem(tHandle)
+ # if tItem is None:
+ # logger.debug("No item selected")
+ # return False
- pItem = tItem.parent()
- isExp = tItem.isExpanded()
- if pItem is None:
- tIndex = self.indexOfTopLevelItem(tItem)
- nChild = self.topLevelItemCount()
+ # pItem = tItem.parent()
+ # isExp = tItem.isExpanded()
+ # if pItem is None:
+ # tIndex = self.indexOfTopLevelItem(tItem)
+ # nChild = self.topLevelItemCount()
- nIndex = tIndex + step
- if nIndex < 0 or nIndex >= nChild:
- return False
+ # nIndex = tIndex + step
+ # if nIndex < 0 or nIndex >= nChild:
+ # return False
- cItem = self.takeTopLevelItem(tIndex)
- self.insertTopLevelItem(nIndex, cItem)
+ # cItem = self.takeTopLevelItem(tIndex)
+ # self.insertTopLevelItem(nIndex, cItem)
- else:
- tIndex = pItem.indexOfChild(tItem)
- nChild = pItem.childCount()
+ # else:
+ # tIndex = pItem.indexOfChild(tItem)
+ # nChild = pItem.childCount()
- nIndex = tIndex + step
- if nIndex < 0 or nIndex >= nChild:
- return False
+ # nIndex = tIndex + step
+ # if nIndex < 0 or nIndex >= nChild:
+ # return False
- cItem = pItem.takeChild(tIndex)
- pItem.insertChild(nIndex, cItem)
+ # cItem = pItem.takeChild(tIndex)
+ # pItem.insertChild(nIndex, cItem)
- self._alertTreeChange(tHandle, flush=True)
- self.setCurrentItem(tItem)
- tItem.setExpanded(isExp)
+ # self._alertTreeChange(tHandle, flush=True)
+ # self.setCurrentItem(tItem)
+ # tItem.setExpanded(isExp)
return True
def moveToNextItem(self, step: int) -> None:
"""Move to the next item of the same tree level."""
- tHandle = self.getSelectedHandle()
- tItem = self._getTreeItem(tHandle) if tHandle else None
- if tItem:
- pItem = tItem.parent() or self.invisibleRootItem()
- next = minmax(pItem.indexOfChild(tItem) + step, 0, pItem.childCount() - 1)
- self.setCurrentItem(pItem.child(next))
+ # tHandle = self.getSelectedHandle()
+ # tItem = self._getTreeItem(tHandle) if tHandle else None
+ # if tItem:
+ # pItem = tItem.parent() or self.invisibleRootItem()
+ # next = minmax(pItem.indexOfChild(tItem) + step, 0, pItem.childCount() - 1)
+ # self.setCurrentItem(pItem.child(next))
return
def moveToLevel(self, step: int) -> None:
"""Move to the next item in the parent/child chain."""
- tHandle = self.getSelectedHandle()
- tItem = self._getTreeItem(tHandle) if tHandle else None
- if tItem:
- if step < 0 and tItem.parent():
- self.setCurrentItem(tItem.parent())
- elif step > 0 and tItem.childCount() > 0:
- self.setCurrentItem(tItem.child(0))
+ # tHandle = self.getSelectedHandle()
+ # tItem = self._getTreeItem(tHandle) if tHandle else None
+ # if tItem:
+ # if step < 0 and tItem.parent():
+ # self.setCurrentItem(tItem.parent())
+ # elif step > 0 and tItem.childCount() > 0:
+ # self.setCurrentItem(tItem.child(0))
return
def renameTreeItem(self, tHandle: str, name: str = "") -> None:
"""Open a dialog to edit the label of an item."""
- if nwItem := SHARED.project.tree[tHandle]:
- newLabel, dlgOk = GuiEditLabel.getLabel(self, text=name or nwItem.itemName)
- if dlgOk:
- nwItem.setName(newLabel)
- self.setTreeItemValues(nwItem)
- self._alertTreeChange(tHandle, flush=False)
+ # if nwItem := SHARED.project.tree[tHandle]:
+ # newLabel, dlgOk = GuiEditLabel.getLabel(self, text=name or nwItem.itemName)
+ # if dlgOk:
+ # nwItem.setName(newLabel)
+ # self.setTreeItemValues(nwItem)
+ # self._alertTreeChange(tHandle, flush=False)
return
def saveTreeOrder(self) -> None:
@@ -880,13 +844,13 @@ class GuiProjectTree(QTreeWidget):
project structure, and must be called before any code that
depends on this order to be up to date.
"""
- items = []
- for i in range(self.topLevelItemCount()):
- item = self.topLevelItem(i)
- if isinstance(item, QTreeWidgetItem):
- items = self._scanChildren(items, item, i)
- logger.debug("Saving project tree item order")
- SHARED.project.setTreeOrder(items)
+ # items = []
+ # for i in range(self.topLevelItemCount()):
+ # item = self.topLevelItem(i)
+ # if isinstance(item, QTreeWidgetItem):
+ # items = self._scanChildren(items, item, i)
+ # logger.debug("Saving project tree item order")
+ # SHARED.project.setTreeOrder(items)
return
def getTreeFromHandle(self, tHandle: str) -> list[str]:
@@ -894,9 +858,9 @@ class GuiProjectTree(QTreeWidget):
item handle.
"""
result = []
- tIten = self._getTreeItem(tHandle)
- if tIten is not None:
- result = self._scanChildren(result, tIten, 0)
+ # tIten = self._getTreeItem(tHandle)
+ # if tIten is not None:
+ # result = self._scanChildren(result, tIten, 0)
return result
def requestDeleteItem(self, tHandle: str | None = None) -> bool:
@@ -904,36 +868,38 @@ class GuiProjectTree(QTreeWidget):
can be called on any item, and will check whether to attempt a
permanent deletion or moving the item to Trash.
"""
- if not SHARED.hasProject:
- logger.error("No project open")
- return False
+ # if not SHARED.hasProject:
+ # logger.error("No project open")
+ # return False
- if not self.hasFocus():
- logger.info("Delete action blocked due to no widget focus")
- return False
+ # if not self.hasFocus():
+ # logger.info("Delete action blocked due to no widget focus")
+ # return False
- if tHandle is None:
- tHandle = self.getSelectedHandle()
+ # if tHandle is None:
+ # tHandle = self.getSelectedHandle()
- if tHandle is None:
- logger.error("There is no item to delete")
- return False
+ # if tHandle is None:
+ # logger.error("There is no item to delete")
+ # return False
- trashHandle = SHARED.project.tree.trashRoot
- if tHandle == trashHandle:
- logger.error("Cannot delete the Trash folder")
- return False
+ # trashHandle = SHARED.project.tree.trashRoot
+ # if tHandle == trashHandle:
+ # logger.error("Cannot delete the Trash folder")
+ # return False
- nwItem = SHARED.project.tree[tHandle]
- if nwItem is None:
- return False
+ # nwItem = SHARED.project.tree[tHandle]
+ # if nwItem is None:
+ # return False
- if SHARED.project.tree.isTrash(tHandle) or nwItem.isRootType():
- status = self.permDeleteItem(tHandle)
- else:
- status = self.moveItemToTrash(tHandle)
+ # if SHARED.project.tree.isTrash(tHandle) or nwItem.isRootType():
+ # status = self.permDeleteItem(tHandle)
+ # else:
+ # status = self.moveItemToTrash(tHandle)
- return status
+ # return status
+
+ return False
@pyqtSlot()
def emptyTrash(self) -> bool:
@@ -941,38 +907,38 @@ class GuiProjectTree(QTreeWidget):
function only asks for confirmation once, and calls the regular
deleteItem function for each document in the Trash folder.
"""
- if not SHARED.hasProject:
- logger.error("No project open")
- return False
+ # if not SHARED.hasProject:
+ # logger.error("No project open")
+ # return False
- trashHandle = SHARED.project.tree.trashRoot
+ # trashHandle = SHARED.project.tree.trashRoot
- logger.debug("Emptying Trash folder")
- if trashHandle is None:
- SHARED.info(self.tr("There is currently no Trash folder in this project."))
- return False
+ # logger.debug("Emptying Trash folder")
+ # if trashHandle is None:
+ # SHARED.info(self.tr("There is currently no Trash folder in this project."))
+ # return False
- trashItems = self.getTreeFromHandle(trashHandle)
- if trashHandle in trashItems:
- trashItems.remove(trashHandle)
+ # trashItems = self.getTreeFromHandle(trashHandle)
+ # if trashHandle in trashItems:
+ # trashItems.remove(trashHandle)
- nTrash = len(trashItems)
- if nTrash == 0:
- SHARED.info(self.tr("The Trash folder is already empty."))
- return False
+ # nTrash = len(trashItems)
+ # if nTrash == 0:
+ # SHARED.info(self.tr("The Trash folder is already empty."))
+ # return False
- if not SHARED.question(self.trPermDelete.format(nTrash)):
- logger.info("Action cancelled by user")
- return False
+ # if not SHARED.question(self.trPermDelete.format(nTrash)):
+ # logger.info("Action cancelled by user")
+ # return False
- logger.debug("Deleting %d file(s) from Trash", nTrash)
- for tHandle in reversed(self.getTreeFromHandle(trashHandle)):
- if tHandle == trashHandle:
- continue
- self.permDeleteItem(tHandle, askFirst=False, flush=False)
+ # logger.debug("Deleting %d file(s) from Trash", nTrash)
+ # for tHandle in reversed(self.getTreeFromHandle(trashHandle)):
+ # if tHandle == trashHandle:
+ # continue
+ # self.permDeleteItem(tHandle, askFirst=False, flush=False)
- if nTrash > 0:
- self._alertTreeChange(trashHandle, flush=True)
+ # if nTrash > 0:
+ # self._alertTreeChange(trashHandle, flush=True)
return True
@@ -980,47 +946,47 @@ class GuiProjectTree(QTreeWidget):
"""Move an item to Trash. Root folders cannot be moved to Trash,
so such a request is cancelled.
"""
- trItemS = self._getTreeItem(tHandle)
- nwItemS = SHARED.project.tree[tHandle]
+ # trItemS = self._getTreeItem(tHandle)
+ # nwItemS = SHARED.project.tree[tHandle]
- if trItemS is None or nwItemS is None:
- logger.error("Could not find tree item for deletion")
- return False
+ # if trItemS is None or nwItemS is None:
+ # logger.error("Could not find tree item for deletion")
+ # return False
- if SHARED.project.tree.isTrash(tHandle):
- logger.error("Item is already in the Trash folder")
- return False
+ # if SHARED.project.tree.isTrash(tHandle):
+ # logger.error("Item is already in the Trash folder")
+ # return False
- if nwItemS.isRootType():
- logger.error("Root folders cannot be moved to Trash")
- return False
+ # if nwItemS.isRootType():
+ # logger.error("Root folders cannot be moved to Trash")
+ # return False
- logger.debug("User requested file or folder '%s' move to Trash", tHandle)
+ # logger.debug("User requested file or folder '%s' move to Trash", tHandle)
- trItemP = trItemS.parent()
- trItemT = self._addTrashRoot()
- if trItemP is None or trItemT is None:
- logger.error("Could not delete item")
- return False
+ # trItemP = trItemS.parent()
+ # trItemT = self._addTrashRoot()
+ # if trItemP is None or trItemT is None:
+ # logger.error("Could not delete item")
+ # return False
- if askFirst:
- msgYes = SHARED.question(
- self.tr("Move '{0}' to Trash?").format(nwItemS.itemName)
- )
- if not msgYes:
- logger.info("Action cancelled by user")
- return False
+ # if askFirst:
+ # msgYes = SHARED.question(
+ # self.tr("Move '{0}' to Trash?").format(nwItemS.itemName)
+ # )
+ # if not msgYes:
+ # logger.info("Action cancelled by user")
+ # return False
- self.propagateCount(tHandle, 0)
+ # self.propagateCount(tHandle, 0)
- tIndex = trItemP.indexOfChild(trItemS)
- trItemC = trItemP.takeChild(tIndex)
- trItemT.addChild(trItemC)
+ # tIndex = trItemP.indexOfChild(trItemS)
+ # trItemC = trItemP.takeChild(tIndex)
+ # trItemT.addChild(trItemC)
- self._postItemMove(tHandle)
- self._alertTreeChange(tHandle, flush=flush)
+ # self._postItemMove(tHandle)
+ # self._alertTreeChange(tHandle, flush=flush)
- logger.debug("Moved item '%s' to Trash", tHandle)
+ # logger.debug("Moved item '%s' to Trash", tHandle)
return True
@@ -1028,74 +994,74 @@ class GuiProjectTree(QTreeWidget):
"""Permanently delete a tree item from the project and the map.
Root items are handled a little different than other items.
"""
- trItemS = self._getTreeItem(tHandle)
- nwItemS = SHARED.project.tree[tHandle]
- if trItemS is None or nwItemS is None:
- logger.error("Could not find tree item for deletion")
- return False
+ # trItemS = self._getTreeItem(tHandle)
+ # nwItemS = SHARED.project.tree[tHandle]
+ # if trItemS is None or nwItemS is None:
+ # logger.error("Could not find tree item for deletion")
+ # return False
- if nwItemS.isRootType():
- # Only an empty ROOT folder can be deleted
- if trItemS.childCount() > 0:
- SHARED.error(self.tr("Root folders can only be deleted when they are empty."))
- return False
+ # if nwItemS.isRootType():
+ # # Only an empty ROOT folder can be deleted
+ # if trItemS.childCount() > 0:
+ # SHARED.error(self.tr("Root folders can only be deleted when they are empty."))
+ # return False
- logger.debug("Permanently deleting root folder '%s'", tHandle)
+ # logger.debug("Permanently deleting root folder '%s'", tHandle)
- tIndex = self.indexOfTopLevelItem(trItemS)
- self.takeTopLevelItem(tIndex)
- SHARED.project.removeItem(tHandle)
- self._treeMap.pop(tHandle, None)
- self._alertTreeChange(tHandle, flush=True)
+ # tIndex = self.indexOfTopLevelItem(trItemS)
+ # self.takeTopLevelItem(tIndex)
+ # SHARED.project.removeItem(tHandle)
+ # self._treeMap.pop(tHandle, None)
+ # self._alertTreeChange(tHandle, flush=True)
- # These are not emitted by the alert function because the
- # item has already been deleted
- self.projView.rootFolderChanged.emit(tHandle)
- self.projView.treeItemChanged.emit(tHandle)
+ # # These are not emitted by the alert function because the
+ # # item has already been deleted
+ # self.projView.rootFolderChanged.emit(tHandle)
+ # self.projView.treeItemChanged.emit(tHandle)
- else:
- if askFirst:
- msgYes = SHARED.question(
- self.tr("Permanently delete '{0}'?").format(nwItemS.itemName)
- )
- if not msgYes:
- logger.info("Action cancelled by user")
- return False
+ # else:
+ # if askFirst:
+ # msgYes = SHARED.question(
+ # self.tr("Permanently delete '{0}'?").format(nwItemS.itemName)
+ # )
+ # if not msgYes:
+ # logger.info("Action cancelled by user")
+ # return False
- logger.debug("Permanently deleting item '%s'", tHandle)
+ # logger.debug("Permanently deleting item '%s'", tHandle)
- self.propagateCount(tHandle, 0)
+ # self.propagateCount(tHandle, 0)
- # If a non-root item ends up on root due to a bug, allow it
- # to still be deleted from invisible root (see #2108)
- trItemP = trItemS.parent() or self.invisibleRootItem()
- tIndex = trItemP.indexOfChild(trItemS)
- trItemP.takeChild(tIndex)
+ # # If a non-root item ends up on root due to a bug, allow it
+ # # to still be deleted from invisible root (see #2108)
+ # trItemP = trItemS.parent() or self.invisibleRootItem()
+ # tIndex = trItemP.indexOfChild(trItemS)
+ # trItemP.takeChild(tIndex)
- for dHandle in reversed(self.getTreeFromHandle(tHandle)):
- SHARED.closeEditor(dHandle)
- SHARED.project.removeItem(dHandle)
- self._treeMap.pop(dHandle, None)
+ # for dHandle in reversed(self.getTreeFromHandle(tHandle)):
+ # SHARED.closeEditor(dHandle)
+ # SHARED.project.removeItem(dHandle)
+ # self._treeMap.pop(dHandle, None)
- self._alertTreeChange(tHandle, flush=flush)
- self.projView.wordCountsChanged.emit()
+ # self._alertTreeChange(tHandle, flush=flush)
+ # self.projView.wordCountsChanged.emit()
- # This is not emitted by the alert function because the item
- # has already been deleted
- self.projView.treeItemChanged.emit(tHandle)
+ # # This is not emitted by the alert function because the item
+ # # has already been deleted
+ # self.projView.treeItemChanged.emit(tHandle)
return True
def refreshUserLabels(self, kind: str) -> None:
"""Refresh status or importance labels."""
- if kind == "s":
- for nwItem in SHARED.project.tree:
- if nwItem.isNovelLike():
- self.setTreeItemValues(nwItem)
- elif kind == "i":
- for nwItem in SHARED.project.tree:
- if not nwItem.isNovelLike():
- self.setTreeItemValues(nwItem)
+ # if kind == "s":
+ # for nwItem in SHARED.project.tree:
+ # if nwItem.isNovelLike():
+ # self.setTreeItemValues(nwItem)
+ # elif kind == "i":
+ # for nwItem in SHARED.project.tree:
+ # if not nwItem.isNovelLike():
+ # self.setTreeItemValues(nwItem)
return
def setTreeItemValues(self, nwItem: NWItem | None) -> None:
@@ -1103,35 +1069,35 @@ class GuiProjectTree(QTreeWidget):
tree. Does not trigger a tree change as the data is already
coming from project data.
"""
- if isinstance(nwItem, NWItem) and (trItem := self._getTreeItem(nwItem.itemHandle)):
- itemStatus, statusIcon = nwItem.getImportStatus()
- hLevel = nwItem.mainHeading
- itemIcon = SHARED.theme.getItemIcon(
- nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
- )
+ # if isinstance(nwItem, NWItem) and (trItem := self._getTreeItem(nwItem.itemHandle)):
+ # itemStatus, statusIcon = nwItem.getImportStatus()
+ # hLevel = nwItem.mainHeading
+ # itemIcon = SHARED.theme.getItemIcon(
+ # nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
+ # )
- trItem.setIcon(self.C_NAME, itemIcon)
- trItem.setText(self.C_NAME, nwItem.itemName)
- trItem.setIcon(self.C_STATUS, statusIcon)
- trItem.setToolTip(self.C_STATUS, itemStatus)
+ # trItem.setIcon(self.C_NAME, itemIcon)
+ # trItem.setText(self.C_NAME, nwItem.itemName)
+ # trItem.setIcon(self.C_STATUS, statusIcon)
+ # trItem.setToolTip(self.C_STATUS, itemStatus)
- if nwItem.isFileType():
- iconName = "checked" if nwItem.isActive else "unchecked"
- toolTip = self.trActive if nwItem.isActive else self.trInactive
- trItem.setToolTip(self.C_ACTIVE, toolTip)
- else:
- iconName = "noncheckable"
+ # if nwItem.isFileType():
+ # iconName = "checked" if nwItem.isActive else "unchecked"
+ # toolTip = self.trActive if nwItem.isActive else self.trInactive
+ # trItem.setToolTip(self.C_ACTIVE, toolTip)
+ # else:
+ # iconName = "noncheckable"
- trItem.setIcon(self.C_ACTIVE, SHARED.theme.getIcon(iconName))
+ # trItem.setIcon(self.C_ACTIVE, SHARED.theme.getIcon(iconName))
- if CONFIG.emphLabels and nwItem.isDocumentLayout():
- trFont = trItem.font(self.C_NAME)
- trFont.setBold(hLevel == "H1" or hLevel == "H2")
- trFont.setUnderline(hLevel == "H1")
- trItem.setFont(self.C_NAME, trFont)
+ # if CONFIG.emphLabels and nwItem.isDocumentLayout():
+ # trFont = trItem.font(self.C_NAME)
+ # trFont.setBold(hLevel == "H1" or hLevel == "H2")
+ # trFont.setUnderline(hLevel == "H1")
+ # trItem.setFont(self.C_NAME, trFont)
- # Emit Refresh Signal
- self.itemRefreshed.emit(nwItem.itemHandle, nwItem, itemIcon)
+ # # Emit Refresh Signal
+ # self.itemRefreshed.emit(nwItem.itemHandle, nwItem, itemIcon)
return
@@ -1143,34 +1109,34 @@ class GuiProjectTree(QTreeWidget):
prone to diverging from the true values if the counts are not
properly reported to the function.
"""
- tItem = self._getTreeItem(tHandle)
- if tItem is None:
- return
+ # tItem = self._getTreeItem(tHandle)
+ # if tItem is None:
+ # return
- if countChildren:
- for i in range(tItem.childCount()):
- newCount += int(tItem.child(i).data(self.C_DATA, self.D_WORDS))
+ # if countChildren:
+ # for i in range(tItem.childCount()):
+ # newCount += int(tItem.child(i).data(self.C_DATA, self.D_WORDS))
- tItem.setText(self.C_COUNT, f"{newCount:n}")
- tItem.setData(self.C_DATA, self.D_WORDS, int(newCount))
+ # tItem.setText(self.C_COUNT, f"{newCount:n}")
+ # tItem.setData(self.C_DATA, self.D_WORDS, int(newCount))
- pItem = tItem.parent()
- if pItem is None:
- return
+ # pItem = tItem.parent()
+ # if pItem is None:
+ # return
- pCount = 0
- pHandle = None
- for i in range(pItem.childCount()):
- pCount += int(pItem.child(i).data(self.C_DATA, self.D_WORDS))
- pHandle = pItem.data(self.C_DATA, self.D_HANDLE)
+ # pCount = 0
+ # pHandle = None
+ # for i in range(pItem.childCount()):
+ # pCount += int(pItem.child(i).data(self.C_DATA, self.D_WORDS))
+ # pHandle = pItem.data(self.C_DATA, self.D_HANDLE)
- if pHandle:
- if SHARED.project.tree.checkType(pHandle, nwItemType.FILE):
- # A file has an internal word count we need to account
- # for, but a folder always has 0 words on its own.
- pCount += SHARED.project.index.getCounts(pHandle)[1]
+ # if pHandle:
+ # if SHARED.project.tree.checkType(pHandle, nwItemType.FILE):
+ # # A file has an internal word count we need to account
+ # # for, but a folder always has 0 words on its own.
+ # pCount += SHARED.project.index.getCounts(pHandle)[1]
- self.propagateCount(pHandle, pCount, countChildren=False)
+ # self.propagateCount(pHandle, pCount, countChildren=False)
return
@@ -1180,41 +1146,41 @@ class GuiProjectTree(QTreeWidget):
always make sure items with a parent have had their parent item
sent first.
"""
- logger.debug("Building the project tree ...")
- self.clearTree()
- for nwItem in SHARED.project.iterProjectItems():
- self._addTreeItem(nwItem)
- self.setActiveHandle(self._actHandle)
- logger.info("%d item(s) added to the project tree", len(self._treeMap))
+ # logger.debug("Building the project tree ...")
+ # self.clearTree()
+ # for nwItem in SHARED.project.iterProjectItems():
+ # self._addTreeItem(nwItem)
+ # self.setActiveHandle(self._actHandle)
+ # logger.info("%d item(s) added to the project tree", len(self._treeMap))
return
def getSelectedHandle(self) -> str | None:
"""Get the currently selected handle. If multiple items are
selected, return the first.
"""
- if items := self.selectedItems():
- return items[0].data(self.C_DATA, self.D_HANDLE)
+ # if items := self.selectedItems():
+ # return items[0].data(self.C_DATA, self.D_HANDLE)
return None
def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> None:
"""Set a specific handle as the selected item."""
- if tHandle in self._treeMap:
- self.setCurrentItem(self._treeMap[tHandle])
- if (indexes := self.selectedIndexes()) and doScroll:
- self.scrollTo(indexes[0], QAbstractItemView.ScrollHint.PositionAtCenter)
+ # if tHandle in self._treeMap:
+ # self.setCurrentItem(self._treeMap[tHandle])
+ # if (indexes := self.selectedIndexes()) and doScroll:
+ # self.scrollTo(indexes[0], QAbstractItemView.ScrollHint.PositionAtCenter)
return
def setActiveHandle(self, tHandle: str | None) -> None:
"""Highlight the rows associated with a given handle."""
- brushOn = self.palette().alternateBase()
- brushOff = self.palette().base()
- if (pHandle := self._actHandle) and (item := self._treeMap.get(pHandle)):
- for i in range(self.columnCount()):
- item.setBackground(i, brushOff)
- if tHandle and (item := self._treeMap.get(tHandle)):
- for i in range(self.columnCount()):
- item.setBackground(i, brushOn)
- self._actHandle = tHandle or None
+ # brushOn = self.palette().alternateBase()
+ # brushOff = self.palette().base()
+ # if (pHandle := self._actHandle) and (item := self._treeMap.get(pHandle)):
+ # for i in range(self.columnCount()):
+ # item.setBackground(i, brushOff)
+ # if tHandle and (item := self._treeMap.get(tHandle)):
+ # for i in range(self.columnCount()):
+ # item.setBackground(i, brushOn)
+ # self._actHandle = tHandle or None
return
def setExpandedFromHandle(self, tHandle: str | None, isExpanded: bool) -> None:
@@ -1222,8 +1188,8 @@ class GuiProjectTree(QTreeWidget):
status for all child items. If tHandle is None, it affects the
entire tree.
"""
- item = self._getTreeItem(tHandle) or self.invisibleRootItem()
- self._recursiveSetExpanded(item, isExpanded)
+ # item = self._getTreeItem(tHandle) or self.invisibleRootItem()
+ # self._recursiveSetExpanded(item, isExpanded)
return
##
@@ -1236,36 +1202,36 @@ class GuiProjectTree(QTreeWidget):
"""The user right clicked an element in the project tree, so we
open a context menu in-place.
"""
- if clickPos is None and (items := self.selectedItems()):
- clickPos = self.visualItemRect(items[0]).center()
+ # if clickPos is None and (items := self.selectedItems()):
+ # clickPos = self.visualItemRect(items[0]).center()
- if clickPos is not None:
- tItem = None
- tHandle = None
- hasChild = False
- sItem = self.itemAt(clickPos)
- sItems = self.selectedItems()
- if isinstance(sItem, QTreeWidgetItem):
- tHandle = sItem.data(self.C_DATA, self.D_HANDLE)
- tItem = SHARED.project.tree[tHandle]
- hasChild = sItem.childCount() > 0
+ # if clickPos is not None:
+ # tItem = None
+ # tHandle = None
+ # hasChild = False
+ # sItem = self.itemAt(clickPos)
+ # sItems = self.selectedItems()
+ # if isinstance(sItem, QTreeWidgetItem):
+ # tHandle = sItem.data(self.C_DATA, self.D_HANDLE)
+ # tItem = SHARED.project.tree[tHandle]
+ # hasChild = sItem.childCount() > 0
- if tItem is None or tHandle is None:
- logger.debug("No item found")
- return
+ # if tItem is None or tHandle is None:
+ # logger.debug("No item found")
+ # return
- ctxMenu = _TreeContextMenu(self, tItem)
- trashHandle = SHARED.project.tree.trashRoot
- if trashHandle and tHandle == trashHandle:
- ctxMenu.buildTrashMenu()
- elif len(sItems) > 1:
- handles = [str(x.data(self.C_DATA, self.D_HANDLE)) for x in sItems]
- ctxMenu.buildMultiSelectMenu(handles)
- else:
- ctxMenu.buildSingleSelectMenu(hasChild)
+ # ctxMenu = _TreeContextMenu(self, tItem)
+ # trashHandle = SHARED.project.tree.trashRoot
+ # if trashHandle and tHandle == trashHandle:
+ # ctxMenu.buildTrashMenu()
+ # elif len(sItems) > 1:
+ # handles = [str(x.data(self.C_DATA, self.D_HANDLE)) for x in sItems]
+ # ctxMenu.buildMultiSelectMenu(handles)
+ # else:
+ # ctxMenu.buildSingleSelectMenu(hasChild)
- ctxMenu.exec(self.viewport().mapToGlobal(clickPos))
- ctxMenu.deleteLater()
+ # ctxMenu.exec(self.viewport().mapToGlobal(clickPos))
+ # ctxMenu.deleteLater()
return
@@ -1273,147 +1239,140 @@ class GuiProjectTree(QTreeWidget):
# Private Slots
##
- @pyqtSlot()
- def _treeSelectionChange(self) -> None:
+ @pyqtSlot(QModelIndex)
+ def _treeSingleClick(self, index: QModelIndex) -> None:
"""The user changed which item is selected."""
- tHandle = self.getSelectedHandle()
- if tHandle is not None:
- self.projView.selectedItemChanged.emit(tHandle)
+ if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
+ self.projView.selectedItemChanged.emit(node.item.itemHandle)
- # When selecting multiple items, don't allow including root
- # items in the selection and instead deselect them
- items = self.selectedItems()
- if items and len(items) > 1:
- for item in items:
- if item.parent() is None:
- item.setSelected(False)
+ # # When selecting multiple items, don't allow including root
+ # # items in the selection and instead deselect them
+ # items = self.selectedItems()
+ # if items and len(items) > 1:
+ # for item in items:
+ # if item.parent() is None:
+ # item.setSelected(False)
return
- @pyqtSlot("QTreeWidgetItem*", int)
- def _treeDoubleClick(self, trItem: QTreeWidgetItem, colNo: int) -> None:
+ @pyqtSlot(QModelIndex)
+ def _treeDoubleClick(self, index: QModelIndex) -> None:
"""Capture a double-click event and either request the document
- for editing if it is a file, or expand/close the node it is not.
+ for editing if it is a file, or expand/close the node if not.
"""
- tHandle = self.getSelectedHandle()
- if tHandle is None:
- return
-
- tItem = SHARED.project.tree[tHandle]
- if tItem is None:
- return
-
- if tItem.isFileType():
- self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "", True)
- else:
- trItem.setExpanded(not trItem.isExpanded())
-
+ if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
+ if node.item.isFileType():
+ self.projView.openDocumentRequest.emit(
+ node.item.itemHandle, nwDocMode.EDIT, "", True
+ )
+ else:
+ self.setExpanded(index, not self.isExpanded(index))
return
@pyqtSlot()
def _doAutoScroll(self) -> None:
"""Scroll one item up or down based on direction value."""
- if self._scrollDirection == -1:
- self.scrollToItem(self.itemAbove(self.itemAt(1, 1)))
- elif self._scrollDirection == 1:
- self.scrollToItem(self.itemBelow(self.itemAt(1, self.height() - 1)))
- self._scrollDirection = 0
- self._scrollTimer.stop()
+ # if self._scrollDirection == -1:
+ # self.scrollToItem(self.itemAbove(self.itemAt(1, 1)))
+ # elif self._scrollDirection == 1:
+ # self.scrollToItem(self.itemBelow(self.itemAt(1, self.height() - 1)))
+ # self._scrollDirection = 0
+ # self._scrollTimer.stop()
return
##
# Events
##
- def mousePressEvent(self, event: QMouseEvent) -> None:
- """Overload mousePressEvent to clear selection if clicking the
- mouse in a blank area of the tree view, and to load a document
- for viewing if the user middle-clicked.
- """
- super().mousePressEvent(event)
- if event.button() == QtMouseLeft:
- selItem = self.indexAt(event.pos())
- if not selItem.isValid():
- self.clearSelection()
- elif event.button() == QtMouseMiddle:
- selItem = self.itemAt(event.pos())
- if selItem:
- tHandle = selItem.data(self.C_DATA, self.D_HANDLE)
- if (tItem := SHARED.project.tree[tHandle]) and tItem.isFileType():
- self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False)
- return
+ # def mousePressEvent(self, event: QMouseEvent) -> None:
+ # """Overload mousePressEvent to clear selection if clicking the
+ # mouse in a blank area of the tree view, and to load a document
+ # for viewing if the user middle-clicked.
+ # """
+ # super().mousePressEvent(event)
+ # if event.button() == QtMouseLeft:
+ # selItem = self.indexAt(event.pos())
+ # if not selItem.isValid():
+ # self.clearSelection()
+ # elif event.button() == QtMouseMiddle:
+ # selItem = self.itemAt(event.pos())
+ # if selItem:
+ # tHandle = selItem.data(self.C_DATA, self.D_HANDLE)
+ # if (tItem := SHARED.project.tree[tHandle]) and tItem.isFileType():
+ # self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False)
+ # return
- def startDrag(self, dropAction: Qt.DropAction) -> None:
- """Capture the drag and drop handling to pop alerts."""
- super().startDrag(dropAction)
- if self._popAlert:
- SHARED.error(self._popAlert)
- self._popAlert = None
- return
+ # def startDrag(self, dropAction: Qt.DropAction) -> None:
+ # """Capture the drag and drop handling to pop alerts."""
+ # super().startDrag(dropAction)
+ # if self._popAlert:
+ # SHARED.error(self._popAlert)
+ # self._popAlert = None
+ # return
- def dragEnterEvent(self, event: QDragEnterEvent) -> None:
- """Check that we're only dragging items that are siblings, and
- not a root level item.
- """
- items = self.selectedItems()
- if items and (parent := items[0].parent()) and all(x.parent() is parent for x in items):
- super().dragEnterEvent(event)
- else:
- logger.warning("Drag action is not allowed and has been cancelled")
- self._popAlert = self.tr(
- "Drag and drop is only allowed for single items, non-root "
- "items, or multiple items with the same parent."
- )
- event.mimeData().clear()
- event.ignore()
- return
+ # def dragEnterEvent(self, event: QDragEnterEvent) -> None:
+ # """Check that we're only dragging items that are siblings, and
+ # not a root level item.
+ # """
+ # items = self.selectedItems()
+ # if items and (parent := items[0].parent()) and all(x.parent() is parent for x in items):
+ # super().dragEnterEvent(event)
+ # else:
+ # logger.warning("Drag action is not allowed and has been cancelled")
+ # self._popAlert = self.tr(
+ # "Drag and drop is only allowed for single items, non-root "
+ # "items, or multiple items with the same parent."
+ # )
+ # event.mimeData().clear()
+ # event.ignore()
+ # return
- def dragMoveEvent(self, event: QDragMoveEvent) -> None:
- """Capture the drag move event to enable edge auto scroll."""
- y = event.pos().y()
- if y < self._scrollMargin:
- if not self._scrollTimer.isActive():
- self._scrollDirection = -1
- self._scrollTimer.start()
- elif y > self.height() - self._scrollMargin:
- if not self._scrollTimer.isActive():
- self._scrollDirection = 1
- self._scrollTimer.start()
- super().dragMoveEvent(event)
- return
+ # def dragMoveEvent(self, event: QDragMoveEvent) -> None:
+ # """Capture the drag move event to enable edge auto scroll."""
+ # y = event.pos().y()
+ # if y < self._scrollMargin:
+ # if not self._scrollTimer.isActive():
+ # self._scrollDirection = -1
+ # self._scrollTimer.start()
+ # elif y > self.height() - self._scrollMargin:
+ # if not self._scrollTimer.isActive():
+ # self._scrollDirection = 1
+ # self._scrollTimer.start()
+ # super().dragMoveEvent(event)
+ # return
- def dropEvent(self, event: QDropEvent) -> None:
- """Overload the drop item event to ensure the drag and drop
- action is allowed, and update relevant data.
- """
- tItem = self.itemAt(event.pos())
- onItem = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnItem
- onView = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnViewport
- # Make sure nothing can be dropped on invisible root (see #1569)
- # Make sure nothing can be dropped on the viewport (see #2108)
- if tItem is None or (tItem.parent() is None and not onItem) or onView:
- logger.error("Invalid drop location")
- event.ignore()
- return
+ # def dropEvent(self, event: QDropEvent) -> None:
+ # """Overload the drop item event to ensure the drag and drop
+ # action is allowed, and update relevant data.
+ # """
+ # tItem = self.itemAt(event.pos())
+ # onItem = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnItem
+ # onView = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnViewport
+ # # Make sure nothing can be dropped on invisible root (see #1569)
+ # # Make sure nothing can be dropped on the viewport (see #2108)
+ # if tItem is None or (tItem.parent() is None and not onItem) or onView:
+ # logger.error("Invalid drop location")
+ # event.ignore()
+ # return
- mItems: dict[str, tuple[QTreeWidgetItem, bool]] = {}
- sItems = self.selectedItems()
- if sItems and (parent := sItems[0].parent()) and all(x.parent() is parent for x in sItems):
- for sItem in sItems:
- mHandle = str(sItem.data(self.C_DATA, self.D_HANDLE))
- mItems[mHandle] = (sItem, sItem.isExpanded())
- self.propagateCount(mHandle, 0)
+ # mItems: dict[str, tuple[QTreeWidgetItem, bool]] = {}
+ # sItems = self.selectedItems()
+ # if sItems and (parent := sItems[0].parent()) and all(x.parent() is parent for x in sItems):
+ # for sItem in sItems:
+ # mHandle = str(sItem.data(self.C_DATA, self.D_HANDLE))
+ # mItems[mHandle] = (sItem, sItem.isExpanded())
+ # self.propagateCount(mHandle, 0)
- super().dropEvent(event)
+ # super().dropEvent(event)
- for mHandle, (sItem, isExpanded) in mItems.items():
- self._postItemMove(mHandle)
- sItem.setExpanded(isExpanded)
- self._alertTreeChange(mHandle, flush=False)
+ # for mHandle, (sItem, isExpanded) in mItems.items():
+ # self._postItemMove(mHandle)
+ # sItem.setExpanded(isExpanded)
+ # self._alertTreeChange(mHandle, flush=False)
- self.saveTreeOrder()
+ # self.saveTreeOrder()
- return
+ # return
##
# Internal Functions
@@ -1421,196 +1380,197 @@ class GuiProjectTree(QTreeWidget):
def _postItemMove(self, tHandle: str) -> None:
"""Run various maintenance tasks for a moved item."""
- trItemS = self._getTreeItem(tHandle)
- nwItemS = SHARED.project.tree[tHandle]
- trItemP = trItemS.parent() if trItemS else None
- if trItemP is None or nwItemS is None:
- logger.error("Failed to find new parent item of '%s'", tHandle)
- return
+ # trItemS = self._getTreeItem(tHandle)
+ # nwItemS = SHARED.project.tree[tHandle]
+ # trItemP = trItemS.parent() if trItemS else None
+ # if trItemP is None or nwItemS is None:
+ # logger.error("Failed to find new parent item of '%s'", tHandle)
+ # return
- # Update item parent handle in the project
- pHandle = trItemP.data(self.C_DATA, self.D_HANDLE)
- nwItemS.setParent(pHandle)
- trItemP.setExpanded(True)
- logger.debug("The parent of item '%s' has been changed to '%s'", tHandle, pHandle)
+ # # Update item parent handle in the project
+ # pHandle = trItemP.data(self.C_DATA, self.D_HANDLE)
+ # nwItemS.setParent(pHandle)
+ # trItemP.setExpanded(True)
+ # logger.debug("The parent of item '%s' has been changed to '%s'", tHandle, pHandle)
- mHandles = self.getTreeFromHandle(tHandle)
- logger.debug("A total of %d item(s) were moved", len(mHandles))
- for mHandle in mHandles:
- logger.debug("Updating item '%s'", mHandle)
- SHARED.project.tree.updateItemData(mHandle)
- if nwItemS.isInactiveClass():
- SHARED.project.index.deleteHandle(mHandle)
- else:
- SHARED.project.index.reIndexHandle(mHandle)
- if mItem := SHARED.project.tree[mHandle]:
- self.setTreeItemValues(mItem)
+ # mHandles = self.getTreeFromHandle(tHandle)
+ # logger.debug("A total of %d item(s) were moved", len(mHandles))
+ # for mHandle in mHandles:
+ # logger.debug("Updating item '%s'", mHandle)
+ # SHARED.project.tree.updateItemData(mHandle)
+ # if nwItemS.isInactiveClass():
+ # SHARED.project.index.deleteHandle(mHandle)
+ # else:
+ # SHARED.project.index.reIndexHandle(mHandle)
+ # if mItem := SHARED.project.tree[mHandle]:
+ # self.setTreeItemValues(mItem)
- # Update word count
- self.propagateCount(tHandle, nwItemS.wordCount, countChildren=True)
+ # # Update word count
+ # self.propagateCount(tHandle, nwItemS.wordCount, countChildren=True)
return
def _getTreeItem(self, tHandle: str | None) -> QTreeWidgetItem | None:
"""Return the QTreeWidgetItem of a given item handle."""
- return self._treeMap.get(tHandle, None) if tHandle else None
+ # return self._treeMap.get(tHandle, None) if tHandle else None
+ return None
def _recursiveSetExpanded(self, trItem: QTreeWidgetItem, isExpanded: bool) -> None:
"""Recursive function to set expanded status starting from (and
not including) a given item.
"""
- if isinstance(trItem, QTreeWidgetItem):
- for i in range(trItem.childCount()):
- chItem = trItem.child(i)
- chItem.setExpanded(isExpanded)
- self._recursiveSetExpanded(chItem, isExpanded)
+ # if isinstance(trItem, QTreeWidgetItem):
+ # for i in range(trItem.childCount()):
+ # chItem = trItem.child(i)
+ # chItem.setExpanded(isExpanded)
+ # self._recursiveSetExpanded(chItem, isExpanded)
return
def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
"""Merge an item's child documents into a single document."""
- logger.info("Request to merge items under handle '%s'", tHandle)
- itemList = self.getTreeFromHandle(tHandle)
+ # logger.info("Request to merge items under handle '%s'", tHandle)
+ # itemList = self.getTreeFromHandle(tHandle)
- tItem = SHARED.project.tree[tHandle]
- if tItem is None:
- return False
+ # tItem = SHARED.project.tree[tHandle]
+ # if tItem is None:
+ # return False
- if tItem.isRootType():
- logger.error("Cannot merge root item")
- return False
+ # if tItem.isRootType():
+ # logger.error("Cannot merge root item")
+ # return False
- if not newFile:
- itemList.remove(tHandle)
+ # if not newFile:
+ # itemList.remove(tHandle)
- data, status = GuiDocMerge.getData(SHARED.mainGui, tHandle, itemList)
- if status:
- items = data.get("finalItems", [])
- if not items:
- SHARED.info(self.tr("No documents selected for merging."))
- return False
+ # data, status = GuiDocMerge.getData(SHARED.mainGui, tHandle, itemList)
+ # if status:
+ # items = data.get("finalItems", [])
+ # if not items:
+ # SHARED.info(self.tr("No documents selected for merging."))
+ # return False
- # Save the open document first, in case it's part of merge
- SHARED.saveEditor()
+ # # Save the open document first, in case it's part of merge
+ # SHARED.saveEditor()
- # Create merge object, and append docs
- docMerger = DocMerger(SHARED.project)
- mLabel = self.tr("Merged")
+ # # Create merge object, and append docs
+ # docMerger = DocMerger(SHARED.project)
+ # mLabel = self.tr("Merged")
- if newFile:
- docLabel = f"[{mLabel}] {tItem.itemName}"
- mHandle = docMerger.newTargetDoc(tHandle, docLabel)
- elif tItem.isFileType():
- docMerger.setTargetDoc(tHandle)
- mHandle = tHandle
- else:
- return False
+ # if newFile:
+ # docLabel = f"[{mLabel}] {tItem.itemName}"
+ # mHandle = docMerger.newTargetDoc(tHandle, docLabel)
+ # elif tItem.isFileType():
+ # docMerger.setTargetDoc(tHandle)
+ # mHandle = tHandle
+ # else:
+ # return False
- for sHandle in items:
- docMerger.appendText(sHandle, True, mLabel)
+ # for sHandle in items:
+ # docMerger.appendText(sHandle, True, mLabel)
- if not docMerger.writeTargetDoc():
- SHARED.error(
- self.tr("Could not write document content."),
- info=docMerger.getError()
- )
- return False
+ # if not docMerger.writeTargetDoc():
+ # SHARED.error(
+ # self.tr("Could not write document content."),
+ # info=docMerger.getError()
+ # )
+ # return False
- SHARED.project.index.reIndexHandle(mHandle)
- if newFile:
- self.revealNewTreeItem(mHandle, nHandle=tHandle, wordCount=True)
+ # SHARED.project.index.reIndexHandle(mHandle)
+ # if newFile:
+ # self.revealNewTreeItem(mHandle, nHandle=tHandle, wordCount=True)
- self.projView.openDocumentRequest.emit(mHandle, nwDocMode.EDIT, "", False)
- self.projView.setSelectedHandle(mHandle, doScroll=True)
+ # self.projView.openDocumentRequest.emit(mHandle, nwDocMode.EDIT, "", False)
+ # self.projView.setSelectedHandle(mHandle, doScroll=True)
- if data.get("moveToTrash", False):
- for sHandle in reversed(data.get("finalItems", [])):
- trItem = self._getTreeItem(sHandle)
- if isinstance(trItem, QTreeWidgetItem) and trItem.childCount() == 0:
- self.moveItemToTrash(sHandle, askFirst=False, flush=False)
+ # if data.get("moveToTrash", False):
+ # for sHandle in reversed(data.get("finalItems", [])):
+ # trItem = self._getTreeItem(sHandle)
+ # if isinstance(trItem, QTreeWidgetItem) and trItem.childCount() == 0:
+ # self.moveItemToTrash(sHandle, askFirst=False, flush=False)
- self._alertTreeChange(mHandle, flush=True)
- self.projView.wordCountsChanged.emit()
+ # self._alertTreeChange(mHandle, flush=True)
+ # self.projView.wordCountsChanged.emit()
- else:
- logger.info("Action cancelled by user")
- return False
+ # else:
+ # logger.info("Action cancelled by user")
+ # return False
return True
def _splitDocument(self, tHandle: str) -> bool:
"""Split a document into multiple documents."""
- logger.info("Request to split items with handle '%s'", tHandle)
+ # logger.info("Request to split items with handle '%s'", tHandle)
- tItem = SHARED.project.tree[tHandle]
- if tItem is None:
- return False
+ # tItem = SHARED.project.tree[tHandle]
+ # if tItem is None:
+ # return False
- if not tItem.isFileType() or tItem.itemParent is None:
- logger.error("Only valid document items can be split")
- return False
+ # if not tItem.isFileType() or tItem.itemParent is None:
+ # logger.error("Only valid document items can be split")
+ # return False
- data, text, status = GuiDocSplit.getData(SHARED.mainGui, tHandle)
- if status:
- headerList = data.get("headerList", [])
- intoFolder = data.get("intoFolder", False)
- docHierarchy = data.get("docHierarchy", False)
+ # data, text, status = GuiDocSplit.getData(SHARED.mainGui, tHandle)
+ # if status:
+ # headerList = data.get("headerList", [])
+ # intoFolder = data.get("intoFolder", False)
+ # docHierarchy = data.get("docHierarchy", False)
- docSplit = DocSplitter(SHARED.project, tHandle)
- if intoFolder:
- fHandle = docSplit.newParentFolder(tItem.itemParent, tItem.itemName)
- self.revealNewTreeItem(fHandle, nHandle=tHandle)
- self._alertTreeChange(fHandle, flush=False)
- else:
- docSplit.setParentItem(tItem.itemParent)
+ # docSplit = DocSplitter(SHARED.project, tHandle)
+ # if intoFolder:
+ # fHandle = docSplit.newParentFolder(tItem.itemParent, tItem.itemName)
+ # self.revealNewTreeItem(fHandle, nHandle=tHandle)
+ # self._alertTreeChange(fHandle, flush=False)
+ # else:
+ # docSplit.setParentItem(tItem.itemParent)
- docSplit.splitDocument(headerList, text)
- for writeOk, dHandle, nHandle in docSplit.writeDocuments(docHierarchy):
- SHARED.project.index.reIndexHandle(dHandle)
- self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
- self._alertTreeChange(dHandle, flush=False)
- if not writeOk:
- SHARED.error(
- self.tr("Could not write document content."),
- info=docSplit.getError()
- )
+ # docSplit.splitDocument(headerList, text)
+ # for writeOk, dHandle, nHandle in docSplit.writeDocuments(docHierarchy):
+ # SHARED.project.index.reIndexHandle(dHandle)
+ # self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
+ # self._alertTreeChange(dHandle, flush=False)
+ # if not writeOk:
+ # SHARED.error(
+ # self.tr("Could not write document content."),
+ # info=docSplit.getError()
+ # )
- if data.get("moveToTrash", False):
- self.moveItemToTrash(tHandle, askFirst=False, flush=True)
+ # if data.get("moveToTrash", False):
+ # self.moveItemToTrash(tHandle, askFirst=False, flush=True)
- self.saveTreeOrder()
+ # self.saveTreeOrder()
- else:
- logger.info("Action cancelled by user")
- return False
+ # else:
+ # logger.info("Action cancelled by user")
+ # return False
return True
def _duplicateFromHandle(self, tHandle: str) -> bool:
"""Duplicate the item hierarchy from a given item."""
- itemTree = self.getTreeFromHandle(tHandle)
- nItems = len(itemTree)
- if nItems == 0:
- return False
- elif nItems == 1:
- question = self.tr("Do you want to duplicate this document?")
- else:
- question = self.tr("Do you want to duplicate this item and all child items?")
+ # itemTree = self.getTreeFromHandle(tHandle)
+ # nItems = len(itemTree)
+ # if nItems == 0:
+ # return False
+ # elif nItems == 1:
+ # question = self.tr("Do you want to duplicate this document?")
+ # else:
+ # question = self.tr("Do you want to duplicate this item and all child items?")
- if not SHARED.question(question):
- return False
+ # if not SHARED.question(question):
+ # return False
- docDup = DocDuplicator(SHARED.project)
- dupCount = 0
- for dHandle, nHandle in docDup.duplicate(itemTree):
- SHARED.project.index.reIndexHandle(dHandle)
- self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
- self._alertTreeChange(dHandle, flush=False)
- dupCount += 1
+ # docDup = DocDuplicator(SHARED.project)
+ # dupCount = 0
+ # for dHandle, nHandle in docDup.duplicate(itemTree):
+ # SHARED.project.index.reIndexHandle(dHandle)
+ # self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
+ # self._alertTreeChange(dHandle, flush=False)
+ # dupCount += 1
- if dupCount != nItems:
- SHARED.warn(self.tr("Could not duplicate all items."))
+ # if dupCount != nItems:
+ # SHARED.warn(self.tr("Could not duplicate all items."))
- self.saveTreeOrder()
+ # self.saveTreeOrder()
return True
@@ -1618,18 +1578,18 @@ class GuiProjectTree(QTreeWidget):
"""This is a recursive function returning all items in a tree
starting at a given QTreeWidgetItem.
"""
- tHandle = tItem.data(self.C_DATA, self.D_HANDLE)
- cCount = tItem.childCount()
+ # tHandle = tItem.data(self.C_DATA, self.D_HANDLE)
+ # cCount = tItem.childCount()
- # Update tree-related meta data
- nwItem = SHARED.project.tree[tHandle]
- if nwItem is not None:
- nwItem.setExpanded(tItem.isExpanded() and cCount > 0)
- nwItem.setOrder(tIndex)
+ # # Update tree-related meta data
+ # nwItem = SHARED.project.tree[tHandle]
+ # if nwItem is not None:
+ # nwItem.setExpanded(tItem.isExpanded() and cCount > 0)
+ # nwItem.setOrder(tIndex)
- itemList.append(tHandle)
- for i in range(cCount):
- self._scanChildren(itemList, tItem.child(i), i)
+ # itemList.append(tHandle)
+ # for i in range(cCount):
+ # self._scanChildren(itemList, tItem.child(i), i)
return itemList
@@ -1639,86 +1599,90 @@ class GuiProjectTree(QTreeWidget):
project tree. Returns the widget if the item is valid, otherwise
a None is returned.
"""
- if not nwItem:
- logger.error("Invalid item cannot be added to project tree")
- return None
+ # if not nwItem:
+ # logger.error("Invalid item cannot be added to project tree")
+ # return None
- tHandle = nwItem.itemHandle
- pHandle = nwItem.itemParent
- newItem = QTreeWidgetItem()
+ # tHandle = nwItem.itemHandle
+ # pHandle = nwItem.itemParent
+ # newItem = QTreeWidgetItem()
- newItem.setText(self.C_NAME, "")
- newItem.setText(self.C_COUNT, "0")
- newItem.setText(self.C_ACTIVE, "")
- newItem.setText(self.C_STATUS, "")
+ # newItem.setText(self.C_NAME, "")
+ # newItem.setText(self.C_COUNT, "0")
+ # newItem.setText(self.C_ACTIVE, "")
+ # newItem.setText(self.C_STATUS, "")
- newItem.setTextAlignment(self.C_NAME, QtAlignLeft)
- newItem.setTextAlignment(self.C_COUNT, QtAlignRight)
- newItem.setTextAlignment(self.C_ACTIVE, QtAlignLeft)
- newItem.setTextAlignment(self.C_STATUS, QtAlignLeft)
+ # newItem.setTextAlignment(self.C_NAME, QtAlignLeft)
+ # newItem.setTextAlignment(self.C_COUNT, QtAlignRight)
+ # newItem.setTextAlignment(self.C_ACTIVE, QtAlignLeft)
+ # newItem.setTextAlignment(self.C_STATUS, QtAlignLeft)
- newItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
- newItem.setData(self.C_DATA, self.D_WORDS, 0)
+ # newItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
+ # newItem.setData(self.C_DATA, self.D_WORDS, 0)
- if pHandle is None and nwItem.isRootType():
- pItem = self.invisibleRootItem()
- elif pHandle and pHandle in self._treeMap:
- pItem = self._treeMap[pHandle]
- else:
- SHARED.error(self.tr(
- "There is nowhere to add item with name '{0}'."
- ).format(nwItem.itemName))
- return None
+ # if pHandle is None and nwItem.isRootType():
+ # pItem = self.invisibleRootItem()
+ # elif pHandle and pHandle in self._treeMap:
+ # pItem = self._treeMap[pHandle]
+ # else:
+ # SHARED.error(self.tr(
+ # "There is nowhere to add item with name '{0}'."
+ # ).format(nwItem.itemName))
+ # return None
- byIndex = -1
- if nHandle is not None and nHandle in self._treeMap:
- byIndex = pItem.indexOfChild(self._treeMap[nHandle])
- if byIndex >= 0:
- pItem.insertChild(byIndex + 1, newItem)
- else:
- pItem.addChild(newItem)
+ # byIndex = -1
+ # if nHandle is not None and nHandle in self._treeMap:
+ # byIndex = pItem.indexOfChild(self._treeMap[nHandle])
+ # if byIndex >= 0:
+ # pItem.insertChild(byIndex + 1, newItem)
+ # else:
+ # pItem.addChild(newItem)
- self._treeMap[tHandle] = newItem
- self.propagateCount(tHandle, nwItem.wordCount, countChildren=True)
- self.setTreeItemValues(nwItem)
- newItem.setExpanded(nwItem.isExpanded)
+ # self._treeMap[tHandle] = newItem
+ # self.propagateCount(tHandle, nwItem.wordCount, countChildren=True)
+ # self.setTreeItemValues(nwItem)
+ # newItem.setExpanded(nwItem.isExpanded)
- return newItem
+ # return newItem
+
+ return None
def _addTrashRoot(self) -> QTreeWidgetItem | None:
"""Adds the trash root folder if it doesn't already exist in the
project tree.
"""
- trashHandle = SHARED.project.trashFolder()
- if trashHandle is None:
- return None
+ # trashHandle = SHARED.project.trashFolder()
+ # if trashHandle is None:
+ # return None
- trItem = self._getTreeItem(trashHandle)
- if trItem is None:
- trItem = self._addTreeItem(SHARED.project.tree[trashHandle])
- if trItem is not None:
- trItem.setExpanded(True)
- self._alertTreeChange(trashHandle, flush=True)
+ # trItem = self._getTreeItem(trashHandle)
+ # if trItem is None:
+ # trItem = self._addTreeItem(SHARED.project.tree[trashHandle])
+ # if trItem is not None:
+ # trItem.setExpanded(True)
+ # self._alertTreeChange(trashHandle, flush=True)
- return trItem
+ # return trItem
+
+ return None
def _alertTreeChange(self, tHandle: str | None, flush: bool = False) -> None:
"""Update information on tree change state, and emit necessary
signals. A flush is only needed if an item is moved, created or
deleted.
"""
- SHARED.project.setProjectChanged(True)
- if flush:
- self.saveTreeOrder()
+ # SHARED.project.setProjectChanged(True)
+ # if flush:
+ # self.saveTreeOrder()
- if tHandle is None or tHandle not in SHARED.project.tree:
- return
+ # if tHandle is None or tHandle not in SHARED.project.tree:
+ # return
- tItem = SHARED.project.tree[tHandle]
- if tItem and tItem.isRootType():
- self.projView.rootFolderChanged.emit(tHandle)
+ # tItem = SHARED.project.tree[tHandle]
+ # if tItem and tItem.isRootType():
+ # self.projView.rootFolderChanged.emit(tHandle)
- self.projView.treeItemChanged.emit(tHandle)
+ # self.projView.treeItemChanged.emit(tHandle)
return
From 7991b9b27c87a81960dcdec65c30a5dcf05eeaf9 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 17 Nov 2024 22:41:45 +0100
Subject: [PATCH 04/39] Re-enable item selection
---
novelwriter/core/itemmodel.py | 18 ++-
novelwriter/core/tree.py | 4 +
novelwriter/gui/projtree.py | 211 +++++++++++++++++++++-------------
3 files changed, 152 insertions(+), 81 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index e504444e..8c872245 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -154,13 +154,19 @@ class ProjectNode:
class ProjectModel(QAbstractItemModel):
- __slots__ = ("_root", "_map")
+ __slots__ = ("_tree", "_root")
def __init__(self, tree: NWTree) -> None:
super().__init__(None)
+ logger.debug("Create: ProjectModel")
+ self._tree = tree
self._root = ProjectNode(NWItem(tree._project, ""))
return
+ def __del__(self) -> None:
+ logger.debug("Delete: ProjectModel")
+ return
+
@property
def root(self) -> ProjectNode:
return self._root
@@ -220,6 +226,16 @@ class ProjectModel(QAbstractItemModel):
return index.internalPointer()
return None
+ def indexFromHandle(self, handle: str | None) -> QModelIndex:
+ """Get the index representing a node in the model."""
+ if handle and (node := self._tree.nodes.get(handle)):
+ return self.createIndex(node.row(), 0, node)
+ return QModelIndex()
+
+ def indexFromNode(self, node: ProjectNode) -> QModelIndex:
+ """Get the index representing a node in the model."""
+ return self.createIndex(node.row(), 0, node)
+
##
# Methods
##
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index ff8a5292..5ce9b554 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -98,6 +98,10 @@ class NWTree:
def model(self) -> ProjectModel:
return self._model
+ @property
+ def nodes(self) -> dict[str, ProjectNode]:
+ return self._nodes
+
##
# Class Methods
##
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index cb1d0354..159ce44d 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -41,7 +41,7 @@ from novelwriter import CONFIG, SHARED
from novelwriter.common import qtLambda
from novelwriter.constants import nwLabels, nwUnicode, trConst
from novelwriter.core.item import NWItem
-from novelwriter.core.itemmodel import ProjectModel
+from novelwriter.core.itemmodel import ProjectModel, ProjectNode
from novelwriter.dialogs.projectsettings import GuiProjectSettings
from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType
from novelwriter.extensions.modified import NIconToolButton
@@ -165,13 +165,12 @@ class GuiProjectView(QWidget):
def saveProjectTasks(self) -> None:
"""Run save project tasks."""
- self.projTree.saveTreeOrder()
+ # self.projTree.saveTreeOrder()
return
def populateTree(self) -> None:
"""Build the tree structure from project data."""
self.projTree.loadModel()
- # self.projTree.buildTree()
return
def setTreeFocus(self) -> None:
@@ -562,8 +561,6 @@ class GuiProjectTree(QTreeView):
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# Connect signals
- # self.itemDoubleClicked.connect(self._treeDoubleClick)
- # self.itemSelectionChanged.connect(self._treeSelectionChange)
self.clicked.connect(self._treeSingleClick)
self.doubleClicked.connect(self._treeDoubleClick)
@@ -595,10 +592,26 @@ class GuiProjectTree(QTreeView):
return
##
- # Class Methods
+ # External Methods
##
+ def getSelectedHandle(self) -> str | None:
+ """Get the currently selected handle."""
+ if (indexes := self.selectedIndexes()) and (node := self._getNode(indexes[0])):
+ return node.item.itemHandle
+ return None
+
+ ##
+ # Module Internal Methods
+ ##
+
+ def clearTree(self) -> None:
+ """Clear the tree view."""
+ self.setModel(None)
+ return
+
def loadModel(self) -> None:
+ """Load and prepare a new project model."""
self.setModel(SHARED.project.tree.model)
# Lock the column sizes
@@ -622,12 +635,64 @@ class GuiProjectTree(QTreeView):
return
- def clearTree(self) -> None:
- """Clear the GUI content and the related map."""
- # self.clear()
- # self._treeMap = {}
+ def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> None:
+ """Set a specific handle as the selected item."""
+ if (model := self._getModel()) and (index := model.indexFromHandle(tHandle)).isValid():
+ self.setCurrentIndex(index)
+ if doScroll:
+ self.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)
+ self.projView.selectedItemChanged.emit(tHandle)
return
+ ##
+ # Private Slots
+ ##
+
+ ##
+ # Private Slots
+ ##
+
+ @pyqtSlot(QModelIndex)
+ def _treeSingleClick(self, index: QModelIndex) -> None:
+ """The user changed which item is selected."""
+ if node := self._getNode(index):
+ self.projView.selectedItemChanged.emit(node.item.itemHandle)
+ return
+
+ @pyqtSlot(QModelIndex)
+ def _treeDoubleClick(self, index: QModelIndex) -> None:
+ """Capture a double-click event and either request the document
+ for editing if it is a file, or expand/close the node if not.
+ """
+ if node := self._getNode(index):
+ if node.item.isFileType():
+ self.projView.openDocumentRequest.emit(
+ node.item.itemHandle, nwDocMode.EDIT, "", True
+ )
+ else:
+ self.setExpanded(index, not self.isExpanded(index))
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _getModel(self) -> ProjectModel | None:
+ """Return a project node corresponding to a model index."""
+ if isinstance(model := self.model(), ProjectModel):
+ return model
+ return None
+
+ def _getNode(self, index: QModelIndex) -> ProjectNode | None:
+ """Return a project node corresponding to a model index."""
+ if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
+ return node
+ return None
+
+ # =========================================================================================== #
+ # Old Code
+ # =========================================================================================== #
+
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Create a new note. This function is used by the document
editor to create note files for unknown tags.
@@ -838,20 +903,20 @@ class GuiProjectTree(QTreeView):
# self._alertTreeChange(tHandle, flush=False)
return
- def saveTreeOrder(self) -> None:
- """Build a list of the items in the project tree and send them
- to the project class. This syncs up the two versions of the
- project structure, and must be called before any code that
- depends on this order to be up to date.
- """
- # items = []
- # for i in range(self.topLevelItemCount()):
- # item = self.topLevelItem(i)
- # if isinstance(item, QTreeWidgetItem):
- # items = self._scanChildren(items, item, i)
- # logger.debug("Saving project tree item order")
- # SHARED.project.setTreeOrder(items)
- return
+ # def saveTreeOrder(self) -> None:
+ # """Build a list of the items in the project tree and send them
+ # to the project class. This syncs up the two versions of the
+ # project structure, and must be called before any code that
+ # depends on this order to be up to date.
+ # """
+ # # items = []
+ # # for i in range(self.topLevelItemCount()):
+ # # item = self.topLevelItem(i)
+ # # if isinstance(item, QTreeWidgetItem):
+ # # items = self._scanChildren(items, item, i)
+ # # logger.debug("Saving project tree item order")
+ # # SHARED.project.setTreeOrder(items)
+ # return
def getTreeFromHandle(self, tHandle: str) -> list[str]:
"""Recursively return all the child items starting from a given
@@ -1140,35 +1205,21 @@ class GuiProjectTree(QTreeView):
return
- def buildTree(self) -> None:
- """Build the entire project tree from scratch. This depends on
- the save project item iterator in the project class which will
- always make sure items with a parent have had their parent item
- sent first.
- """
- # logger.debug("Building the project tree ...")
- # self.clearTree()
- # for nwItem in SHARED.project.iterProjectItems():
- # self._addTreeItem(nwItem)
- # self.setActiveHandle(self._actHandle)
- # logger.info("%d item(s) added to the project tree", len(self._treeMap))
- return
+ # def getSelectedHandle(self) -> str | None:
+ # """Get the currently selected handle. If multiple items are
+ # selected, return the first.
+ # """
+ # # if items := self.selectedItems():
+ # # return items[0].data(self.C_DATA, self.D_HANDLE)
+ # return None
- def getSelectedHandle(self) -> str | None:
- """Get the currently selected handle. If multiple items are
- selected, return the first.
- """
- # if items := self.selectedItems():
- # return items[0].data(self.C_DATA, self.D_HANDLE)
- return None
-
- def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> None:
- """Set a specific handle as the selected item."""
- # if tHandle in self._treeMap:
- # self.setCurrentItem(self._treeMap[tHandle])
- # if (indexes := self.selectedIndexes()) and doScroll:
- # self.scrollTo(indexes[0], QAbstractItemView.ScrollHint.PositionAtCenter)
- return
+ # def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> None:
+ # """Set a specific handle as the selected item."""
+ # # if tHandle in self._treeMap:
+ # # self.setCurrentItem(self._treeMap[tHandle])
+ # # if (indexes := self.selectedIndexes()) and doScroll:
+ # # self.scrollTo(indexes[0], QAbstractItemView.ScrollHint.PositionAtCenter)
+ # return
def setActiveHandle(self, tHandle: str | None) -> None:
"""Highlight the rows associated with a given handle."""
@@ -1239,35 +1290,35 @@ class GuiProjectTree(QTreeView):
# Private Slots
##
- @pyqtSlot(QModelIndex)
- def _treeSingleClick(self, index: QModelIndex) -> None:
- """The user changed which item is selected."""
- if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
- self.projView.selectedItemChanged.emit(node.item.itemHandle)
+ # @pyqtSlot(QModelIndex)
+ # def _treeSingleClick(self, index: QModelIndex) -> None:
+ # """The user changed which item is selected."""
+ # if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
+ # self.projView.selectedItemChanged.emit(node.item.itemHandle)
- # # When selecting multiple items, don't allow including root
- # # items in the selection and instead deselect them
- # items = self.selectedItems()
- # if items and len(items) > 1:
- # for item in items:
- # if item.parent() is None:
- # item.setSelected(False)
+ # # # When selecting multiple items, don't allow including root
+ # # # items in the selection and instead deselect them
+ # # items = self.selectedItems()
+ # # if items and len(items) > 1:
+ # # for item in items:
+ # # if item.parent() is None:
+ # # item.setSelected(False)
- return
+ # return
- @pyqtSlot(QModelIndex)
- def _treeDoubleClick(self, index: QModelIndex) -> None:
- """Capture a double-click event and either request the document
- for editing if it is a file, or expand/close the node if not.
- """
- if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
- if node.item.isFileType():
- self.projView.openDocumentRequest.emit(
- node.item.itemHandle, nwDocMode.EDIT, "", True
- )
- else:
- self.setExpanded(index, not self.isExpanded(index))
- return
+ # @pyqtSlot(QModelIndex)
+ # def _treeDoubleClick(self, index: QModelIndex) -> None:
+ # """Capture a double-click event and either request the document
+ # for editing if it is a file, or expand/close the node if not.
+ # """
+ # if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
+ # if node.item.isFileType():
+ # self.projView.openDocumentRequest.emit(
+ # node.item.itemHandle, nwDocMode.EDIT, "", True
+ # )
+ # else:
+ # self.setExpanded(index, not self.isExpanded(index))
+ # return
@pyqtSlot()
def _doAutoScroll(self) -> None:
@@ -2007,7 +2058,7 @@ class _TreeContextMenu(QMenu):
for tItem in self._items:
if tItem.isFileType() and tItem.itemClass != nwItemClass.TRASH:
self.projTree.moveItemToTrash(tItem.itemHandle, askFirst=False, flush=False)
- self.projTree.saveTreeOrder()
+ # self.projTree.saveTreeOrder()
return
@pyqtSlot()
@@ -2017,7 +2068,7 @@ class _TreeContextMenu(QMenu):
for tItem in self._items:
if tItem.isFileType() and tItem.itemClass == nwItemClass.TRASH:
self.projTree.permDeleteItem(tItem.itemHandle, askFirst=False, flush=False)
- self.projTree.saveTreeOrder()
+ # self.projTree.saveTreeOrder()
return
@pyqtSlot()
From 4f8e898440681266a9a646719bfe62bd1cc6b6d6 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 17 Nov 2024 23:24:49 +0100
Subject: [PATCH 05/39] Switch data tree to new model structure
---
novelwriter/core/itemmodel.py | 8 ++
novelwriter/core/tree.py | 255 ++++++++++++++++------------------
2 files changed, 126 insertions(+), 137 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 8c872245..17763ff0 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -67,10 +67,18 @@ class ProjectNode:
self.refresh()
return
+ ##
+ # Properties
+ ##
+
@property
def item(self) -> NWItem:
return self._item
+ @property
+ def children(self) -> list[ProjectNode]:
+ return self._children
+
def refresh(self) -> None:
cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {}
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 5ce9b554..8b5a6226 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -30,7 +30,6 @@ from collections.abc import Iterable, Iterator
from pathlib import Path
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
@@ -72,9 +71,9 @@ class NWTree:
self._project = project
- self._tree: dict[str, NWItem] = {} # Holds all the items of the project
- 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._tree: dict[str, NWItem] = {} # Holds all the items of the project
+ # 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._items: dict[str, NWItem] = {}
@@ -108,9 +107,9 @@ class NWTree:
def clear(self) -> None:
"""Clear the item tree entirely."""
- self._tree = {}
- self._order = []
- self._roots = {}
+ # self._tree = {}
+ # self._order = []
+ # self._roots = {}
self._model = ProjectModel(self)
self._items = {}
@@ -122,7 +121,7 @@ class NWTree:
def handles(self) -> list[str]:
"""Returns a copy of the list of all the active handles."""
- return self._order.copy()
+ return list(self._items.keys())
@overload # pragma: no cover
def create(self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
@@ -134,53 +133,53 @@ class NWTree:
itemClass: nwItemClass = nwItemClass.NO_CLASS) -> str | None:
pass
- def create(self, label, parent, itemType, itemClass=nwItemClass.NO_CLASS):
+ def create(self, label, parent, itemType, itemClass=nwItemClass.NO_CLASS) -> str | None:
"""Create a new item in the project tree, and return its handle.
If the item cannot be added to the project because of an invalid
parent, None is returned. For root elements, this cannot occur.
"""
- parent = None if itemType == nwItemType.ROOT else parent
- if parent is None or parent in self._order:
- tHandle = self._makeHandle()
- newItem = NWItem(self._project, tHandle)
- newItem.setName(label)
- newItem.setParent(parent)
- newItem.setType(itemType)
- newItem.setClass(itemClass)
- self.append(newItem)
- self.updateItemData(tHandle)
- return tHandle
+ # parent = None if itemType == nwItemType.ROOT else parent
+ # if parent is None or parent in self._order:
+ # tHandle = self._makeHandle()
+ # newItem = NWItem(self._project, tHandle)
+ # newItem.setName(label)
+ # newItem.setParent(parent)
+ # newItem.setType(itemType)
+ # newItem.setClass(itemClass)
+ # self.append(newItem)
+ # self.updateItemData(tHandle)
+ # return tHandle
return None
def append(self, nwItem: NWItem) -> bool:
"""Add a new item to the end of the tree."""
- tHandle = nwItem.itemHandle
- pHandle = nwItem.itemParent
+ # tHandle = nwItem.itemHandle
+ # pHandle = nwItem.itemParent
- if not isHandle(tHandle):
- logger.warning("Invalid item handle '%s' detected, skipping", tHandle)
- return False
+ # if not isHandle(tHandle):
+ # logger.warning("Invalid item handle '%s' detected, skipping", tHandle)
+ # return False
- if tHandle in self._tree:
- logger.warning("Duplicate handle '%s' detected, skipping", tHandle)
- return False
+ # if tHandle in self._tree:
+ # logger.warning("Duplicate handle '%s' detected, skipping", tHandle)
+ # return False
- logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle))
+ # logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle))
- if nwItem.isRootType():
- logger.debug("Item '%s' is a root item", str(tHandle))
- self._roots[tHandle] = nwItem
- if nwItem.itemClass == nwItemClass.TRASH:
- if self._trash is None:
- logger.debug("Item '%s' is the trash folder", str(tHandle))
- self._trash = tHandle
- else:
- logger.error("Only one trash folder allowed")
- return False
+ # if nwItem.isRootType():
+ # logger.debug("Item '%s' is a root item", str(tHandle))
+ # self._roots[tHandle] = nwItem
+ # if nwItem.itemClass == nwItemClass.TRASH:
+ # if self._trash is None:
+ # logger.debug("Item '%s' is the trash folder", str(tHandle))
+ # self._trash = tHandle
+ # else:
+ # logger.error("Only one trash folder allowed")
+ # return False
- self._tree[tHandle] = nwItem
- self._order.append(tHandle)
- self._setTreeChanged(True)
+ # self._tree[tHandle] = nwItem
+ # self._order.append(tHandle)
+ # self._setTreeChanged(True)
return True
@@ -204,13 +203,6 @@ class NWTree:
"Model tree is inconsitent with nodes map, %d != %d",
len(nodes), len(self._nodes)
)
-
- # tree = []
- # for tHandle in self._order:
- # tItem = self.__getitem__(tHandle)
- # if tItem:
- # tree.append(tItem.pack())
-
return [node.item.pack() for node in nodes]
def unpack(self, data: list[dict]) -> None:
@@ -219,11 +211,12 @@ class NWTree:
"""
self.clear()
for item in data:
- nwItem = NWItem(self._project, "") # Handle is set by unpack()
+ nwItem = NWItem(self._project, "")
if nwItem.unpack(item):
self._items[nwItem.itemHandle] = nwItem
- self.append(nwItem)
-
+ if nwItem.itemClass == nwItemClass.TRASH:
+ logger.debug("Item '%s' is the trash folder", str(nwItem.itemHandle))
+ self._trash = nwItem.itemHandle
return
def buildModel(self) -> None:
@@ -276,7 +269,7 @@ class NWTree:
"""
storage = self._project.storage
files = set(storage.scanContent())
- for tHandle in self._order:
+ for tHandle in self._nodes:
if self.updateItemData(tHandle):
logger.debug("Checking item '%s' ... OK", tHandle)
files.discard(tHandle) # Remove it from the record
@@ -297,7 +290,7 @@ class NWTree:
oName, oParent, oClass, oLayout = aDoc.getMeta()
oName = oName or cHandle
- oParent = oParent if oParent in self._order else None
+ oParent = oParent if oParent in self._nodes else None
oClass = oClass or nwItemClass.NOVEL
oLayout = oLayout or nwItemLayout.NOTE
@@ -333,38 +326,33 @@ class NWTree:
if not (isinstance(contentPath, Path) and isinstance(runtimePath, Path)):
return False
- tocList = []
- tocLen = 0
- for tHandle in self._order:
- tItem = self.__getitem__(tHandle)
- if tItem is None:
- continue
-
- tFile = tHandle+".nwd"
- if (contentPath / tFile).is_file():
+ entries = []
+ maxLen = 0
+ for node in self._model.root.allChildren():
+ item = node.item
+ file = f"{item.itemHandle}.nwd"
+ if (contentPath / file).is_file():
tocLine = "{0:<25s} {1:<9s} {2:<8s} {3:s}".format(
- str(Path("content") / tFile),
- tItem.itemClass.name,
- tItem.itemLayout.name,
- tItem.itemName,
+ str(Path("content") / file),
+ item.itemClass.name,
+ item.itemLayout.name,
+ item.itemName,
)
- tocList.append(tocLine)
- tocLen = max(tocLen, len(tocLine))
+ entries.append(tocLine)
+ maxLen = max(maxLen, len(tocLine))
try:
- # Dump the text
- tocText = runtimePath / nwFiles.TOC_TXT
- with open(tocText, mode="w", encoding="utf-8") as outFile:
- outFile.write("\n")
- outFile.write("Table of Contents\n")
- outFile.write("=================\n")
- outFile.write("\n")
- outFile.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format(
+ with open(runtimePath / nwFiles.TOC_TXT, mode="w", encoding="utf-8") as toc:
+ toc.write("\n")
+ toc.write("Table of Contents\n")
+ toc.write("=================\n")
+ toc.write("\n")
+ toc.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format(
"File Name", "Class", "Layout", "Document Label"
))
- outFile.write("-"*max(tocLen, 62) + "\n")
- outFile.write("\n".join(tocList))
- outFile.write("\n")
+ toc.write("-"*max(maxLen, 62) + "\n")
+ toc.write("\n".join(entries))
+ toc.write("\n")
except Exception:
logger.error("Could not write ToC file")
@@ -377,16 +365,11 @@ class NWTree:
"""Loop over all entries and add up the word counts."""
noteWords = 0
novelWords = 0
- for tHandle in self._order:
- tItem = self.__getitem__(tHandle)
- if tItem is None:
- continue
- if tItem.itemLayout == nwItemLayout.NO_LAYOUT:
- pass
- elif tItem.itemLayout == nwItemLayout.NOTE:
- noteWords += tItem.wordCount
- else:
- novelWords += tItem.wordCount
+ for item in self._items.values():
+ if item.itemLayout == nwItemLayout.NOTE:
+ noteWords += item.wordCount
+ elif item.itemLayout == nwItemLayout.DOCUMENT:
+ novelWords += item.wordCount
return novelWords, noteWords
##
@@ -453,17 +436,16 @@ class NWTree:
def rootClasses(self) -> set[nwItemClass]:
"""Return a set of all root classes in use by the project."""
rootClasses = set()
- for nwItem in self._roots.values():
- rootClasses.add(nwItem.itemClass)
+ for node in self._model.root.children:
+ rootClasses.add(node.item.itemClass)
return rootClasses
def iterRoots(self, itemClass: nwItemClass | None) -> Iterable[tuple[str, NWItem]]:
"""Iterate over all root items of a given class in order."""
- for tHandle in self._order:
- nwItem = self.__getitem__(tHandle)
- if isinstance(nwItem, NWItem) and nwItem.isRootType():
- if itemClass is None or nwItem.itemClass == itemClass:
- yield tHandle, nwItem
+ for node in self._model.root.children:
+ if node.item.isRootType():
+ if itemClass is None or node.item.itemClass == itemClass:
+ yield node.item.itemHandle, node.item
return
def isTrash(self, tHandle: str) -> bool:
@@ -484,12 +466,9 @@ class NWTree:
def findRoot(self, itemClass: nwItemClass | None) -> str | None:
"""Find the first root item for a given class."""
- for aRoot in self._roots:
- tItem = self.__getitem__(aRoot)
- if tItem is None:
- continue
- if itemClass == tItem.itemClass:
- return tItem.itemHandle
+ for node in self._model.root.children:
+ if node.item.itemClass == itemClass:
+ return node.item.itemHandle
return None
##
@@ -498,20 +477,20 @@ class NWTree:
def setOrder(self, newOrder: list[str]) -> None:
"""Reorders the tree based on a list of items."""
- tmpOrder = [tHandle for tHandle in newOrder if tHandle in self._tree]
- if not (len(tmpOrder) == len(newOrder) == len(self._order)):
- # Something is wrong, so let's debug it
- for tHandle in newOrder:
- if tHandle not in self._tree:
- logger.error("Handle '%s' in new tree order is not in old order", tHandle)
- for tHandle in self._order:
- if tHandle not in tmpOrder:
- logger.warning("Handle '%s' in old tree order is not in new order", tHandle)
+ # tmpOrder = [tHandle for tHandle in newOrder if tHandle in self._tree]
+ # if not (len(tmpOrder) == len(newOrder) == len(self._order)):
+ # # Something is wrong, so let's debug it
+ # for tHandle in newOrder:
+ # if tHandle not in self._tree:
+ # logger.error("Handle '%s' in new tree order is not in old order", tHandle)
+ # for tHandle in self._order:
+ # if tHandle not in tmpOrder:
+ # logger.warning("Handle '%s' in old tree order is not in new order", tHandle)
- # Save the temp list
- self._order = tmpOrder
- self._setTreeChanged(True)
- logger.debug("Project tree order updated")
+ # # Save the temp list
+ # self._order = tmpOrder
+ # self._setTreeChanged(True)
+ # logger.debug("Project tree order updated")
return
@@ -521,49 +500,51 @@ class NWTree:
def __len__(self) -> int:
"""The number of items in the project."""
- return len(self._order)
+ return len(self._items)
def __bool__(self) -> bool:
"""True if there are any items in the project."""
- return bool(self._order)
+ return bool(self._items)
def __getitem__(self, tHandle: str | None) -> NWItem | None:
"""Return a project item based on its handle. Returns None if
the handle doesn't exist in the project.
"""
- if tHandle and tHandle in self._tree:
- return self._tree[tHandle]
+ if tHandle and tHandle in self._items:
+ return self._items[tHandle]
logger.error("No tree item with handle '%s'", str(tHandle))
return None
def __delitem__(self, tHandle: str) -> None:
"""Remove an item from the internal lists and dictionaries."""
- if tHandle in self._order and tHandle in self._tree:
- self._order.remove(tHandle)
- del self._tree[tHandle]
- else:
- logger.warning("Failed to delete item '%s': item not found", tHandle)
- return
+ # if tHandle in self._order and tHandle in self._tree:
+ # self._order.remove(tHandle)
+ # del self._tree[tHandle]
+ # else:
+ # logger.warning("Failed to delete item '%s': item not found", tHandle)
+ # return
- if tHandle in self._roots:
- del self._roots[tHandle]
- if tHandle == self._trash:
- self._trash = None
+ # if tHandle in self._roots:
+ # del self._roots[tHandle]
+ # if tHandle == self._trash:
+ # self._trash = None
- self._setTreeChanged(True)
+ # self._setTreeChanged(True)
return
def __contains__(self, tHandle: str) -> bool:
"""Checks if a handle exists in the tree."""
- return tHandle in self._order
+ return tHandle in self._items
def __iter__(self) -> Iterator[NWItem]:
"""Iterate through project items."""
- for tHandle in self._order:
- tItem = self._tree.get(tHandle)
- if isinstance(tItem, NWItem):
- yield tItem
+ for node in self._model.root.allChildren():
+ yield node.item
+ # for tHandle in self._order:
+ # tItem = self._tree.get(tHandle)
+ # if isinstance(tItem, NWItem):
+ # yield tItem
return
##
@@ -585,7 +566,7 @@ class NWTree:
"""
logger.debug("Generating new handle")
handle = f"{random.getrandbits(52):013x}"
- if handle in self._tree:
+ if handle in self._items:
logger.warning("Duplicate handle encountered! Retrying ...")
handle = self._makeHandle()
From 337f05f60ebcefdde34e0635852cb6eba0b13d87 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 18 Nov 2024 00:01:40 +0100
Subject: [PATCH 06/39] Re-enable item rename
---
novelwriter/core/item.py | 17 ++++++++++++++++-
novelwriter/core/status.py | 2 +-
novelwriter/core/tree.py | 31 ++++++++++++++-----------------
novelwriter/gui/projtree.py | 23 ++++++++---------------
novelwriter/guimain.py | 8 ++++----
novelwriter/shared.py | 17 ++++++++++-------
6 files changed, 53 insertions(+), 45 deletions(-)
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index efa6de18..b5915c1c 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -55,7 +55,7 @@ class NWItem:
"_project", "_name", "_handle", "_parent", "_root", "_order",
"_type", "_class", "_layout", "_status", "_import", "_active",
"_expanded", "_heading", "_charCount", "_wordCount",
- "_paraCount", "_cursorPos", "_initCount",
+ "_paraCount", "_cursorPos", "_initCount", "_blocked",
)
def __init__(self, project: NWProject, handle: str) -> None:
@@ -82,6 +82,8 @@ class NWItem:
self._cursorPos = 0 # Last cursor position
self._initCount = 0 # Initial word count
+ self._blocked = True
+
return
def __repr__(self) -> str:
@@ -257,6 +259,7 @@ class NWItem:
self._cursorPos = 0
self._initCount = self._wordCount
+ self._blocked = False
return True
@@ -281,6 +284,7 @@ class NWItem:
cls._paraCount = source._paraCount
cls._cursorPos = source._cursorPos
cls._initCount = source._initCount
+ cls._blocked = source._blocked
return cls
##
@@ -415,6 +419,7 @@ class NWItem:
self._name = simplified(name)
else:
self._name = ""
+ self._notifyChange()
return
def setParent(self, handle: Any) -> None:
@@ -560,3 +565,13 @@ class NWItem:
"""Save the initial word count."""
self._initCount = self._wordCount
return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _notifyChange(self) -> None:
+ """Notify project tree on user changes to the item."""
+ if not self._blocked:
+ self._project.tree.refreshNode(self._handle)
+ return
diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py
index de017f51..dc0ef397 100644
--- a/novelwriter/core/status.py
+++ b/novelwriter/core/status.py
@@ -131,7 +131,7 @@ class NWStatus:
self._default = next(iter(self._store)) if self._store else None
# Emit the change signal
- SHARED.projectSingalProxy({"event": "statusLabels", "kind": self._prefix})
+ SHARED.projectSignalProxy({"event": "statusLabels", "kind": self._prefix})
return
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 8b5a6226..36d1dbfc 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -30,6 +30,7 @@ from collections.abc import Iterable, Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Literal, overload
+from novelwriter import SHARED
from novelwriter.constants import nwFiles
from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
@@ -68,20 +69,12 @@ class NWTree:
)
def __init__(self, project: NWProject) -> None:
-
self._project = project
-
- # self._tree: dict[str, NWItem] = {} # Holds all the items of the project
- # 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._items: dict[str, NWItem] = {}
self._nodes: dict[str, ProjectNode] = {}
-
self._trash = None # The handle of the trash root folder
self._changed = False # True if tree structure has changed
-
return
##
@@ -107,15 +100,10 @@ class NWTree:
def clear(self) -> None:
"""Clear the item tree entirely."""
- # self._tree = {}
- # self._order = []
- # self._roots = {}
-
- self._model = ProjectModel(self)
- self._items = {}
- self._nodes = {}
-
- self._trash = None
+ self._model = ProjectModel(self)
+ self._items = {}
+ self._nodes = {}
+ self._trash = None
self._changed = False
return
@@ -237,6 +225,15 @@ class NWTree:
return
+ def refreshNode(self, tHandle: str) -> None:
+ """Refresh node data on item change."""
+ if node := self._nodes.get(tHandle):
+ node.refresh()
+ index = self._model.indexFromNode(node)
+ SHARED.projectSignalProxy({"event": "projectItem", "handle": tHandle})
+ self._model.dataChanged.emit(index, index)
+ return
+
def _buildTree(self, items: dict[str, NWItem]) -> dict[str, NWItem]:
""""""
remains: dict[str, NWItem] = {}
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 159ce44d..4017b3f5 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -42,6 +42,7 @@ from novelwriter.common import qtLambda
from novelwriter.constants import nwLabels, nwUnicode, trConst
from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
+from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.dialogs.projectsettings import GuiProjectSettings
from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType
from novelwriter.extensions.modified import NIconToolButton
@@ -58,7 +59,7 @@ class GuiProjectView(QWidget):
"""
# Signals triggered when the meta data values of items change
- treeItemChanged = pyqtSignal(str)
+ # treeItemChanged = pyqtSignal(str)
rootFolderChanged = pyqtSignal(str)
wordCountsChanged = pyqtSignal()
@@ -193,8 +194,10 @@ class GuiProjectView(QWidget):
"""
if tHandle is None:
tHandle = self.projTree.getSelectedHandle()
- if tHandle:
- self.projTree.renameTreeItem(tHandle, name=name)
+ if nwItem := SHARED.project.tree[tHandle]:
+ newLabel, dlgOk = GuiEditLabel.getLabel(self, text=name or nwItem.itemName)
+ if dlgOk:
+ nwItem.setName(newLabel)
return
@pyqtSlot(str, bool)
@@ -893,16 +896,6 @@ class GuiProjectTree(QTreeView):
# self.setCurrentItem(tItem.child(0))
return
- def renameTreeItem(self, tHandle: str, name: str = "") -> None:
- """Open a dialog to edit the label of an item."""
- # if nwItem := SHARED.project.tree[tHandle]:
- # newLabel, dlgOk = GuiEditLabel.getLabel(self, text=name or nwItem.itemName)
- # if dlgOk:
- # nwItem.setName(newLabel)
- # self.setTreeItemValues(nwItem)
- # self._alertTreeChange(tHandle, flush=False)
- return
-
# def saveTreeOrder(self) -> None:
# """Build a list of the items in the project tree and send them
# to the project class. This syncs up the two versions of the
@@ -1848,7 +1841,7 @@ class _TreeContextMenu(QMenu):
# Edit Item Settings
action = self.addAction(self.tr("Rename"))
- action.triggered.connect(qtLambda(self.projTree.renameTreeItem, self._handle))
+ action.triggered.connect(qtLambda(self.projView.renameTreeItem, self._handle))
if isFile:
self._itemHeader()
self._itemActive(False)
@@ -1911,7 +1904,7 @@ class _TreeContextMenu(QMenu):
if hItem := SHARED.project.index.getItemHeading(self._handle, "T0001"):
action = self.addAction(self.tr("Rename to Heading"))
action.triggered.connect(
- qtLambda(self.projTree.renameTreeItem, self._handle, hItem.title)
+ qtLambda(self.projView.renameTreeItem, self._handle, hItem.title)
)
return
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 1bb744f6..fc7cd532 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -216,6 +216,10 @@ class GuiMain(QMainWindow):
SHARED.indexScannedText.connect(self.itemDetails.updateViewBox)
SHARED.indexScannedText.connect(self.projView.updateItemValues)
SHARED.mainClockTick.connect(self._timeTick)
+ SHARED.projectItemChanged.connect(self.docEditor.updateDocInfo)
+ SHARED.projectItemChanged.connect(self.docViewer.updateDocInfo)
+ SHARED.projectItemChanged.connect(self.docViewerPanel.projectItemChanged)
+ SHARED.projectItemChanged.connect(self.itemDetails.updateViewBox)
SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus)
SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage)
SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage)
@@ -237,10 +241,6 @@ class GuiMain(QMainWindow):
self.projView.rootFolderChanged.connect(self.outlineView.updateRootItem)
self.projView.rootFolderChanged.connect(self.projView.updateRootItem)
self.projView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
- self.projView.treeItemChanged.connect(self.docEditor.updateDocInfo)
- self.projView.treeItemChanged.connect(self.docViewer.updateDocInfo)
- self.projView.treeItemChanged.connect(self.docViewerPanel.projectItemChanged)
- self.projView.treeItemChanged.connect(self.itemDetails.updateViewBox)
self.projView.wordCountsChanged.connect(self._updateStatusWordCount)
self.novelView.openDocumentRequest.connect(self._openDocument)
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index a42c0ffa..5e42fd1b 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -55,15 +55,16 @@ class SharedData(QObject):
"_idleTime", "_idleRefTime",
)
+ focusModeChanged = pyqtSignal(bool)
+ indexAvailable = pyqtSignal()
+ indexChangedTags = pyqtSignal(list, list)
+ indexCleared = pyqtSignal()
+ indexScannedText = pyqtSignal(str)
+ mainClockTick = pyqtSignal()
+ projectItemChanged = pyqtSignal(str)
projectStatusChanged = pyqtSignal(bool)
projectStatusMessage = pyqtSignal(str)
spellLanguageChanged = pyqtSignal(str, str)
- focusModeChanged = pyqtSignal(bool)
- indexScannedText = pyqtSignal(str)
- indexChangedTags = pyqtSignal(list, list)
- indexCleared = pyqtSignal()
- indexAvailable = pyqtSignal()
- mainClockTick = pyqtSignal()
statusLabelsChanged = pyqtSignal(str)
def __init__(self) -> None:
@@ -320,12 +321,14 @@ class SharedData(QObject):
self.indexAvailable.emit()
return
- def projectSingalProxy(self, data: dict) -> None:
+ def projectSignalProxy(self, data: dict) -> None:
"""Emit signals on project data change."""
event = data.get("event")
logger.debug("Received '%s' event from project data", event)
if event == "statusLabels":
self.statusLabelsChanged.emit(data.get("kind", ""))
+ elif event == "projectItem":
+ self.projectItemChanged.emit(data.get("handle", ""))
return
##
From df0244095f5d698b9cfc77c80ad01b7aa73f5cc3 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 18 Nov 2024 17:51:05 +0100
Subject: [PATCH 07/39] Rewrite how project items are refreshed on the GUI
---
novelwriter/core/item.py | 31 ++++++++++---------------------
novelwriter/core/itemmodel.py | 21 +++++++++++++++------
novelwriter/core/tree.py | 22 +++++++++++++++-------
novelwriter/gui/projtree.py | 1 +
tests/test_core/test_core_item.py | 5 -----
5 files changed, 41 insertions(+), 39 deletions(-)
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index b5915c1c..cdfd4a38 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -55,7 +55,7 @@ class NWItem:
"_project", "_name", "_handle", "_parent", "_root", "_order",
"_type", "_class", "_layout", "_status", "_import", "_active",
"_expanded", "_heading", "_charCount", "_wordCount",
- "_paraCount", "_cursorPos", "_initCount", "_blocked",
+ "_paraCount", "_cursorPos", "_initCount",
)
def __init__(self, project: NWProject, handle: str) -> None:
@@ -82,8 +82,6 @@ class NWItem:
self._cursorPos = 0 # Last cursor position
self._initCount = 0 # Initial word count
- self._blocked = True
-
return
def __repr__(self) -> str:
@@ -259,7 +257,6 @@ class NWItem:
self._cursorPos = 0
self._initCount = self._wordCount
- self._blocked = False
return True
@@ -284,9 +281,17 @@ class NWItem:
cls._paraCount = source._paraCount
cls._cursorPos = source._cursorPos
cls._initCount = source._initCount
- cls._blocked = source._blocked
return cls
+ ##
+ # Action Methods
+ ##
+
+ def notifyToRefresh(self) -> None:
+ """Notify GUI that item info needs to be refreshed."""
+ self._project.tree.refreshItems([self._handle])
+ return
+
##
# Lookup Methods
##
@@ -419,7 +424,6 @@ class NWItem:
self._name = simplified(name)
else:
self._name = ""
- self._notifyChange()
return
def setParent(self, handle: Any) -> None:
@@ -560,18 +564,3 @@ class NWItem:
else:
self._cursorPos = 0
return
-
- def saveInitialCount(self) -> None:
- """Save the initial word count."""
- self._initCount = self._wordCount
- return
-
- ##
- # Internal Functions
- ##
-
- def _notifyChange(self) -> None:
- """Notify project tree on user changes to the item."""
- if not self._blocked:
- self._project.tree.refreshNode(self._handle)
- return
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 17763ff0..dd14c46b 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -26,7 +26,7 @@ from __future__ import annotations
import logging
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt
from PyQt5.QtGui import QIcon
@@ -53,6 +53,8 @@ C_ACTIVE_TIP = 0x0200 | Qt.ItemDataRole.ToolTipRole
C_STATUS_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole
C_STATUS_TIP = 0x0300 | Qt.ItemDataRole.ToolTipRole
+T_NodeData = str | QIcon | Qt.AlignmentFlag | None
+
class ProjectNode:
@@ -125,7 +127,7 @@ class ProjectNode:
def childCount(self) -> int:
return len(self._children)
- def data(self, column: int, role: Qt.ItemDataRole) -> str | QIcon | Qt.AlignmentFlag | None:
+ def data(self, column: int, role: Qt.ItemDataRole) -> T_NodeData:
""""""
return self._cache.get(COL_MASK*column | role)
@@ -168,7 +170,7 @@ class ProjectModel(QAbstractItemModel):
super().__init__(None)
logger.debug("Create: ProjectModel")
self._tree = tree
- self._root = ProjectNode(NWItem(tree._project, ""))
+ self._root = ProjectNode(NWItem(tree._project, "invisibleRoot"))
return
def __del__(self) -> None:
@@ -177,6 +179,7 @@ class ProjectModel(QAbstractItemModel):
@property
def root(self) -> ProjectNode:
+ """Return the model root item."""
return self._root
##
@@ -184,22 +187,26 @@ class ProjectModel(QAbstractItemModel):
##
def rowCount(self, index: QModelIndex) -> int:
+ """Return the number of rows for an entry."""
if index.isValid():
return index.internalPointer().childCount()
return self._root.childCount()
def columnCount(self, index: QModelIndex) -> int:
+ """Return the number of columns for an entry."""
return 4
def parent(self, index: QModelIndex) -> QModelIndex:
+ """Get the parent model index of another index."""
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:
+ """get the index of a child item of a parent."""
if parent.isValid():
- item = parent.internalPointer()
+ item: ProjectNode = parent.internalPointer()
else:
item = self._root
@@ -211,10 +218,11 @@ class ProjectModel(QAbstractItemModel):
return QModelIndex()
- def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> Any:
+ def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData:
+ """Return display data for a project node."""
if not index.isValid():
return None
- node = index.internalPointer()
+ node: ProjectNode = index.internalPointer()
return node.data(index.column(), role)
# def addChild(self, node: ProjectNode, parent: QModelIndex) -> None:
@@ -230,6 +238,7 @@ class ProjectModel(QAbstractItemModel):
##
def node(self, index: QModelIndex) -> ProjectNode | None:
+ """Return the node for a given model index."""
if index.isValid():
return index.internalPointer()
return None
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 36d1dbfc..728a3000 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -225,13 +225,21 @@ class NWTree:
return
- def refreshNode(self, tHandle: str) -> None:
- """Refresh node data on item change."""
- if node := self._nodes.get(tHandle):
- node.refresh()
- index = self._model.indexFromNode(node)
- SHARED.projectSignalProxy({"event": "projectItem", "handle": tHandle})
- self._model.dataChanged.emit(index, index)
+ def refreshItems(self, items: list[str], isRange: bool = False) -> None:
+ """Refresh these items on the GUI. If they are an ordered range,
+ also set the isRange flag to True.
+ """
+ indices = []
+ for tHandle in items:
+ if node := self._nodes.get(tHandle):
+ node.refresh()
+ SHARED.projectSignalProxy({"event": "projectItem", "handle": tHandle})
+ indices.append(self._model.indexFromNode(node))
+ if isRange and len(indices) >= 2:
+ self._model.dataChanged.emit(indices[0], indices[-1])
+ else:
+ for index in indices:
+ self._model.dataChanged.emit(index, index)
return
def _buildTree(self, items: dict[str, NWItem]) -> dict[str, NWItem]:
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 4017b3f5..7e2643a7 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -198,6 +198,7 @@ class GuiProjectView(QWidget):
newLabel, dlgOk = GuiEditLabel.getLabel(self, text=name or nwItem.itemName)
if dlgOk:
nwItem.setName(newLabel)
+ nwItem.notifyToRefresh()
return
@pyqtSlot(str, bool)
diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py
index 3e0fd0bb..b27ab593 100644
--- a/tests/test_core/test_core_item.py
+++ b/tests/test_core/test_core_item.py
@@ -176,11 +176,6 @@ def testCoreItem_Setters(mockGUI, mockRnd, fncPath):
item.setCursorPos(1)
assert item.cursorPos == 1
- # Initial Count
- item.setWordCount(234)
- item.saveInitialCount()
- assert item.initCount == 234
-
@pytest.mark.core
def testCoreItem_Methods(mockGUI, mockRnd, fncPath):
From a5575992d6e8a5d0208fd435938a6ad3100b12f6 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 18 Nov 2024 18:04:25 +0100
Subject: [PATCH 08/39] Re-enable project tree move item and navigation
---
novelwriter/core/itemmodel.py | 66 ++++++++++--
novelwriter/core/tree.py | 8 ++
novelwriter/gui/mainmenu.py | 6 +-
novelwriter/gui/projtree.py | 191 ++++++++++++++++------------------
4 files changed, 162 insertions(+), 109 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index dd14c46b..d7e5f987 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -69,6 +69,14 @@ class ProjectNode:
self.refresh()
return
+ def __repr__(self) -> str:
+ return (
+ f""
+ )
+
##
# Properties
##
@@ -81,6 +89,10 @@ class ProjectNode:
def children(self) -> list[ProjectNode]:
return self._children
+ ##
+ # Data Maintenance
+ ##
+
def refresh(self) -> None:
cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {}
@@ -121,6 +133,10 @@ class ProjectNode:
parent.updateCount()
return
+ ##
+ # Data Access
+ ##
+
def row(self) -> int:
return self._row
@@ -139,6 +155,15 @@ class ProjectNode:
return self._children[row]
return None
+ def allChildren(self) -> list[ProjectNode]:
+ nodes: list[ProjectNode] = []
+ self._recursiveAppendChildren(nodes)
+ return nodes
+
+ ##
+ # Data Edit
+ ##
+
def addChild(self, child: ProjectNode) -> None:
child._parent = self
child._row = len(self._children)
@@ -146,10 +171,18 @@ class ProjectNode:
self.refresh()
return
- def allChildren(self) -> list[ProjectNode]:
- nodes: list[ProjectNode] = []
- self._recursiveAppendChildren(nodes)
- return nodes
+ def moveChild(self, source: int, step: int) -> int:
+ """Move a child internally."""
+ count = len(self._children)
+ if 0 <= source < count:
+ target = max(min(source + step, count - 1), 0)
+ if source != target:
+ node = self._children.pop(source)
+ self._children.insert(target, node)
+ for n, child in enumerate(self._children):
+ child._row = n
+ return target + 1 if target > source else target
+ return -1
##
# Internal Functions
@@ -167,10 +200,10 @@ class ProjectModel(QAbstractItemModel):
__slots__ = ("_tree", "_root")
def __init__(self, tree: NWTree) -> None:
- super().__init__(None)
- logger.debug("Create: ProjectModel")
+ super().__init__()
self._tree = tree
self._root = ProjectNode(NWItem(tree._project, "invisibleRoot"))
+ logger.debug("Ready: ProjectModel")
return
def __del__(self) -> None:
@@ -254,7 +287,26 @@ class ProjectModel(QAbstractItemModel):
return self.createIndex(node.row(), 0, node)
##
- # Methods
+ # Model Edit
+ ##
+
+ def internalMove(self, index: QModelIndex, step: int) -> None:
+ """Move an item internally among its siblings."""
+ if index.isValid():
+ node: ProjectNode = index.internalPointer()
+ if parent := node.parent():
+ pos = index.row()
+ if (new := parent.moveChild(index.row(), step)) > -1:
+ self.beginMoveRows(index.parent(), pos, pos, index.parent(), new)
+ self.endMoveRows()
+ return
+
+ def moveRows(self, indices: list[QModelIndex], destination: QModelIndex, row: int) -> bool:
+ """Move indices to destination."""
+ return False
+
+ ##
+ # Other Methods
##
def allExpanded(self) -> list[QModelIndex]:
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 728a3000..d0b1ad42 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -75,6 +75,11 @@ class NWTree:
self._nodes: dict[str, ProjectNode] = {}
self._trash = None # The handle of the trash root folder
self._changed = False # True if tree structure has changed
+ logger.debug("Ready: NWTree")
+ return
+
+ def __del__(self) -> None:
+ logger.debug("Delete: NWTree")
return
##
@@ -100,11 +105,14 @@ class NWTree:
def clear(self) -> None:
"""Clear the item tree entirely."""
+ oldModel = self._model
self._model = ProjectModel(self)
self._items = {}
self._nodes = {}
self._trash = None
self._changed = False
+ oldModel.deleteLater()
+ del oldModel
return
def handles(self) -> list[str]:
diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py
index afecb30a..7637893a 100644
--- a/novelwriter/gui/mainmenu.py
+++ b/novelwriter/gui/mainmenu.py
@@ -28,7 +28,7 @@ import logging
from pathlib import Path
from typing import TYPE_CHECKING
-from PyQt5.QtCore import pyqtSignal, pyqtSlot
+from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QAction, QMenuBar
from novelwriter import CONFIG, SHARED
@@ -337,12 +337,16 @@ class GuiMainMenu(QMenuBar):
# View > Go Backward
self.aViewPrev = self.viewMenu.addAction(self.tr("Navigate Backward"))
self.aViewPrev.setShortcut("Alt+Left")
+ self.aViewPrev.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
self.aViewPrev.triggered.connect(self.mainGui.docViewer.navBackward)
+ self.mainGui.docViewer.addAction(self.aViewPrev)
# View > Go Forward
self.aViewNext = self.viewMenu.addAction(self.tr("Navigate Forward"))
self.aViewNext.setShortcut("Alt+Right")
+ self.aViewNext.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
self.aViewNext.triggered.connect(self.mainGui.docViewer.navForward)
+ self.mainGui.docViewer.addAction(self.aViewNext)
# View > Separator
self.viewMenu.addSeparator()
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 7e2643a7..10b37618 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -31,7 +31,7 @@ import logging
from enum import Enum
from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
-from PyQt5.QtGui import QIcon, QPalette
+from PyQt5.QtGui import QIcon, QMouseEvent, QPalette
from PyQt5.QtWidgets import (
QAbstractItemView, QAction, QFrame, QHBoxLayout, QHeaderView, QLabel,
QMenu, QShortcut, QTreeView, QTreeWidgetItem, QVBoxLayout, QWidget
@@ -47,7 +47,10 @@ from novelwriter.dialogs.projectsettings import GuiProjectSettings
from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
-from novelwriter.types import QtScrollAlwaysOff, QtScrollAsNeeded, QtSizeExpanding
+from novelwriter.types import (
+ QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded,
+ QtSizeExpanding
+)
logger = logging.getLogger(__name__)
@@ -91,32 +94,32 @@ class GuiProjectView(QWidget):
self.keyMoveUp = QShortcut(self.projTree)
self.keyMoveUp.setKey("Ctrl+Up")
self.keyMoveUp.setContext(Qt.ShortcutContext.WidgetShortcut)
- self.keyMoveUp.activated.connect(qtLambda(self.projTree.moveTreeItem, -1))
+ self.keyMoveUp.activated.connect(self.projTree.moveItemUp)
self.keyMoveDn = QShortcut(self.projTree)
self.keyMoveDn.setKey("Ctrl+Down")
self.keyMoveDn.setContext(Qt.ShortcutContext.WidgetShortcut)
- self.keyMoveDn.activated.connect(qtLambda(self.projTree.moveTreeItem, 1))
+ self.keyMoveDn.activated.connect(self.projTree.moveItemDown)
self.keyGoPrev = QShortcut(self.projTree)
self.keyGoPrev.setKey("Alt+Up")
self.keyGoPrev.setContext(Qt.ShortcutContext.WidgetShortcut)
- self.keyGoPrev.activated.connect(qtLambda(self.projTree.moveToNextItem, -1))
+ self.keyGoPrev.activated.connect(self.projTree.moveSiblingUp)
self.keyGoNext = QShortcut(self.projTree)
self.keyGoNext.setKey("Alt+Down")
self.keyGoNext.setContext(Qt.ShortcutContext.WidgetShortcut)
- self.keyGoNext.activated.connect(qtLambda(self.projTree.moveToNextItem, 1))
+ self.keyGoNext.activated.connect(self.projTree.moveSiblingDown)
self.keyGoUp = QShortcut(self.projTree)
self.keyGoUp.setKey("Alt+Left")
self.keyGoUp.setContext(Qt.ShortcutContext.WidgetShortcut)
- self.keyGoUp.activated.connect(qtLambda(self.projTree.moveToLevel, -1))
+ self.keyGoUp.activated.connect(self.projTree.moveToParent)
self.keyGoDown = QShortcut(self.projTree)
self.keyGoDown.setKey("Alt+Right")
self.keyGoDown.setContext(Qt.ShortcutContext.WidgetShortcut)
- self.keyGoDown.activated.connect(qtLambda(self.projTree.moveToLevel, 1))
+ self.keyGoDown.activated.connect(self.projTree.moveToFirstChild)
self.keyContext = QShortcut(self.projTree)
self.keyContext.setKey("Ctrl+.")
@@ -288,11 +291,11 @@ class GuiProjectToolBar(QWidget):
# Move Buttons
self.tbMoveU = NIconToolButton(self, iSz)
self.tbMoveU.setToolTip("%s [Ctrl+Up]" % self.tr("Move Up"))
- self.tbMoveU.clicked.connect(qtLambda(self.projTree.moveTreeItem, -1))
+ self.tbMoveU.clicked.connect(self.projTree.moveItemUp)
self.tbMoveD = NIconToolButton(self, iSz)
self.tbMoveD.setToolTip("%s [Ctrl+Down]" % self.tr("Move Down"))
- self.tbMoveD.clicked.connect(qtLambda(self.projTree.moveTreeItem, 1))
+ self.tbMoveD.clicked.connect(self.projTree.moveItemDown)
# Add Item Menu
self.mAdd = QMenu(self)
@@ -616,7 +619,11 @@ class GuiProjectTree(QTreeView):
def loadModel(self) -> None:
"""Load and prepare a new project model."""
+ selModel = self.selectionModel()
self.setModel(SHARED.project.tree.model)
+ if selModel:
+ selModel.deleteLater()
+ del selModel
# Lock the column sizes
iPx = SHARED.theme.baseIconHeight
@@ -649,9 +656,81 @@ class GuiProjectTree(QTreeView):
return
##
- # Private Slots
+ # Events
##
+ def mousePressEvent(self, event: QMouseEvent) -> None:
+ """Overload mousePressEvent to clear selection if clicking the
+ mouse in a blank area of the tree view, and to load a document
+ for viewing if the user middle-clicked.
+ """
+ super().mousePressEvent(event)
+ if event.button() == QtMouseLeft:
+ if not self.indexAt(event.pos()).isValid():
+ self.selectionModel().clearCurrentIndex()
+ elif event.button() == QtMouseMiddle:
+ if (node := self._getNode(self.indexAt(event.pos()))) and node.item.isFileType():
+ self.projView.openDocumentRequest.emit(
+ node.item.itemHandle, nwDocMode.VIEW, "", False
+ )
+ return
+
+ ##
+ # Public Slots
+ ##
+
+ @pyqtSlot()
+ def moveItemUp(self) -> None:
+ """Move an item up in the tree."""
+ if model := self._getModel():
+ model.internalMove(self.currentIndex(), -1)
+ return
+
+ @pyqtSlot()
+ def moveItemDown(self) -> None:
+ """Move an item down in the tree."""
+ if model := self._getModel():
+ model.internalMove(self.currentIndex(), 1)
+ return
+
+ @pyqtSlot()
+ def moveSiblingUp(self) -> None:
+ """Skip to the previous sibling."""
+ if (node := self._getNode(self.currentIndex())) and (parent := node.parent()):
+ if (move := parent.child(node.row() - 1)) and (model := self._getModel()):
+ self.setCurrentIndex(model.indexFromNode(move))
+ return
+
+ @pyqtSlot()
+ def moveSiblingDown(self) -> None:
+ """Skip to the next sibling."""
+ if (node := self._getNode(self.currentIndex())) and (parent := node.parent()):
+ if (move := parent.child(node.row() + 1)) and (model := self._getModel()):
+ self.setCurrentIndex(model.indexFromNode(move))
+ return
+
+ @pyqtSlot()
+ def moveToParent(self) -> None:
+ """Move to parent item."""
+ if (
+ (model := self._getModel())
+ and (node := model.node(self.currentIndex()))
+ and (parent := node.parent())
+ ):
+ self.setCurrentIndex(model.indexFromNode(parent))
+ return
+
+ @pyqtSlot()
+ def moveToFirstChild(self) -> None:
+ """Move to first child item."""
+ if (
+ (model := self._getModel())
+ and (node := model.node(self.currentIndex()))
+ and (child := node.child(0))
+ ):
+ self.setCurrentIndex(model.indexFromNode(child))
+ return
+
##
# Private Slots
##
@@ -838,80 +917,6 @@ class GuiProjectTree(QTreeView):
return True
- def moveTreeItem(self, step: int) -> bool:
- """Move an item up or down in the tree."""
- # tHandle = self.getSelectedHandle()
- # tItem = self._getTreeItem(tHandle)
- # if tItem is None:
- # logger.debug("No item selected")
- # return False
-
- # pItem = tItem.parent()
- # isExp = tItem.isExpanded()
- # if pItem is None:
- # tIndex = self.indexOfTopLevelItem(tItem)
- # nChild = self.topLevelItemCount()
-
- # nIndex = tIndex + step
- # if nIndex < 0 or nIndex >= nChild:
- # return False
-
- # cItem = self.takeTopLevelItem(tIndex)
- # self.insertTopLevelItem(nIndex, cItem)
-
- # else:
- # tIndex = pItem.indexOfChild(tItem)
- # nChild = pItem.childCount()
-
- # nIndex = tIndex + step
- # if nIndex < 0 or nIndex >= nChild:
- # return False
-
- # cItem = pItem.takeChild(tIndex)
- # pItem.insertChild(nIndex, cItem)
-
- # self._alertTreeChange(tHandle, flush=True)
- # self.setCurrentItem(tItem)
- # tItem.setExpanded(isExp)
-
- return True
-
- def moveToNextItem(self, step: int) -> None:
- """Move to the next item of the same tree level."""
- # tHandle = self.getSelectedHandle()
- # tItem = self._getTreeItem(tHandle) if tHandle else None
- # if tItem:
- # pItem = tItem.parent() or self.invisibleRootItem()
- # next = minmax(pItem.indexOfChild(tItem) + step, 0, pItem.childCount() - 1)
- # self.setCurrentItem(pItem.child(next))
- return
-
- def moveToLevel(self, step: int) -> None:
- """Move to the next item in the parent/child chain."""
- # tHandle = self.getSelectedHandle()
- # tItem = self._getTreeItem(tHandle) if tHandle else None
- # if tItem:
- # if step < 0 and tItem.parent():
- # self.setCurrentItem(tItem.parent())
- # elif step > 0 and tItem.childCount() > 0:
- # self.setCurrentItem(tItem.child(0))
- return
-
- # def saveTreeOrder(self) -> None:
- # """Build a list of the items in the project tree and send them
- # to the project class. This syncs up the two versions of the
- # project structure, and must be called before any code that
- # depends on this order to be up to date.
- # """
- # # items = []
- # # for i in range(self.topLevelItemCount()):
- # # item = self.topLevelItem(i)
- # # if isinstance(item, QTreeWidgetItem):
- # # items = self._scanChildren(items, item, i)
- # # logger.debug("Saving project tree item order")
- # # SHARED.project.setTreeOrder(items)
- # return
-
def getTreeFromHandle(self, tHandle: str) -> list[str]:
"""Recursively return all the child items starting from a given
item handle.
@@ -1455,22 +1460,6 @@ class GuiProjectTree(QTreeView):
return
- def _getTreeItem(self, tHandle: str | None) -> QTreeWidgetItem | None:
- """Return the QTreeWidgetItem of a given item handle."""
- # return self._treeMap.get(tHandle, None) if tHandle else None
- return None
-
- def _recursiveSetExpanded(self, trItem: QTreeWidgetItem, isExpanded: bool) -> None:
- """Recursive function to set expanded status starting from (and
- not including) a given item.
- """
- # if isinstance(trItem, QTreeWidgetItem):
- # for i in range(trItem.childCount()):
- # chItem = trItem.child(i)
- # chItem.setExpanded(isExpanded)
- # self._recursiveSetExpanded(chItem, isExpanded)
- return
-
def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
"""Merge an item's child documents into a single document."""
# logger.info("Request to merge items under handle '%s'", tHandle)
From 53e70c118c6c008f489f80192e240c5c7627fdcf Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 19 Nov 2024 00:22:59 +0100
Subject: [PATCH 09/39] Add back create new project item functionality
---
novelwriter/core/itemmodel.py | 78 +++++---
novelwriter/core/project.py | 36 ++--
novelwriter/core/tree.py | 281 ++++++++++++----------------
novelwriter/gui/projtree.py | 208 ++++++++++----------
tests/test_gui/test_gui_projtree.py | 8 +-
5 files changed, 279 insertions(+), 332 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index d7e5f987..67253897 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -32,6 +32,7 @@ from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt
from PyQt5.QtGui import QIcon
from novelwriter import SHARED
+from novelwriter.common import minmax
from novelwriter.core.item import NWItem
from novelwriter.types import QtAlignRight
@@ -164,36 +165,44 @@ class ProjectNode:
# Data Edit
##
- def addChild(self, child: ProjectNode) -> None:
+ def addChild(self, child: ProjectNode, pos: int = -1) -> None:
+ """Add a child item to this item."""
child._parent = self
- child._row = len(self._children)
- self._children.append(child)
+ if 0 <= pos < len(self._children):
+ self._children.insert(pos, child)
+ self._refreshChildrenPos()
+ else:
+ child._row = len(self._children)
+ self._children.append(child)
self.refresh()
return
- def moveChild(self, source: int, step: int) -> int:
+ def moveChild(self, source: int, target: int) -> None:
"""Move a child internally."""
count = len(self._children)
- if 0 <= source < count:
- target = max(min(source + step, count - 1), 0)
- if source != target:
- node = self._children.pop(source)
- self._children.insert(target, node)
- for n, child in enumerate(self._children):
- child._row = n
- return target + 1 if target > source else target
- return -1
+ if (source != target) and (0 <= source < count) and (0 <= target <= count):
+ node = self._children.pop(source)
+ self._children.insert(target, node)
+ self._refreshChildrenPos()
+ return
##
# Internal Functions
##
def _recursiveAppendChildren(self, children: list[ProjectNode]) -> None:
+ """Recursively add all nodes to a list."""
for node in self._children:
children.append(node)
node._recursiveAppendChildren(children)
return
+ def _refreshChildrenPos(self) -> None:
+ """Update the row value on all children."""
+ for n, child in enumerate(self._children):
+ child._row = n
+ return
+
class ProjectModel(QAbstractItemModel):
@@ -258,18 +267,17 @@ class ProjectModel(QAbstractItemModel):
node: ProjectNode = index.internalPointer()
return node.data(index.column(), role)
- # def addChild(self, node: ProjectNode, parent: QModelIndex) -> None:
- # if parent.isValid():
- # item = parent.internalPointer()
- # else:
- # item = self._root
- # item.addChild(node)
- # return
-
##
# Data Access
##
+ def row(self, index: QModelIndex) -> int:
+ """Return the row number of the index."""
+ if index.isValid():
+ node: ProjectNode = index.internalPointer()
+ return node.row()
+ return -1
+
def node(self, index: QModelIndex) -> ProjectNode | None:
"""Return the node for a given model index."""
if index.isValid():
@@ -286,25 +294,41 @@ class ProjectModel(QAbstractItemModel):
"""Get the index representing a node in the model."""
return self.createIndex(node.row(), 0, node)
+ def rootIndex(self) -> QModelIndex:
+ """Get the index representing the root."""
+ return self.createIndex(0, 0, self._root)
+
##
# Model Edit
##
+ def insertChild(self, child: ProjectNode, parent: QModelIndex, pos: int) -> None:
+ """Insert a node into the model at a given position."""
+ if parent.isValid():
+ node: ProjectNode = parent.internalPointer()
+ else:
+ node = self._root
+ count = node.childCount()
+ row = minmax(pos, 0, count) if pos >= 0 else count
+ self.beginInsertRows(parent, row, row)
+ node.addChild(child, row)
+ self.endInsertRows()
+ return
+
def internalMove(self, index: QModelIndex, step: int) -> None:
"""Move an item internally among its siblings."""
if index.isValid():
node: ProjectNode = index.internalPointer()
if parent := node.parent():
pos = index.row()
- if (new := parent.moveChild(index.row(), step)) > -1:
- self.beginMoveRows(index.parent(), pos, pos, index.parent(), new)
+ new = minmax(pos + step, 0, parent.childCount() - 1)
+ if new != pos:
+ end = new if new < pos else new + 1
+ self.beginMoveRows(index.parent(), pos, pos, index.parent(), end)
+ parent.moveChild(pos, new)
self.endMoveRows()
return
- def moveRows(self, indices: list[QModelIndex], destination: QModelIndex, row: int) -> bool:
- """Move indices to destination."""
- return False
-
##
# Other Methods
##
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index e7025af6..ab960fe8 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -154,20 +154,20 @@ class NWProject:
# Item Methods
##
- def newRoot(self, itemClass: nwItemClass, label: str | None = None) -> str:
+ def newRoot(self, itemClass: nwItemClass, pos: int = -1) -> str | None:
"""Add a new root folder to the project. If label is not set,
use the class label.
"""
- label = label or trConst(nwLabels.CLASS_NAME[itemClass])
- return self._tree.create(label, None, nwItemType.ROOT, itemClass)
+ label = trConst(nwLabels.CLASS_NAME[itemClass])
+ return self._tree.create(label, None, nwItemType.ROOT, itemClass=itemClass, pos=pos)
- def newFolder(self, label: str, parent: str) -> str | None:
+ def newFolder(self, label: str, parent: str, pos: int = -1) -> str | None:
"""Add a new folder with a given label and parent item."""
- return self._tree.create(label, parent, nwItemType.FOLDER)
+ return self._tree.create(label, parent, nwItemType.FOLDER, pos=pos)
- def newFile(self, label: str, parent: str) -> str | None:
+ def newFile(self, label: str, parent: str, pos: int = -1) -> str | None:
"""Add a new file with a given label and parent item."""
- return self._tree.create(label, parent, nwItemType.FILE)
+ return self._tree.create(label, parent, nwItemType.FILE, pos=pos)
def writeNewFile(self, tHandle: str, hLevel: int, isDocument: bool, text: str = "") -> bool:
"""Write content to a new document after it is created. This
@@ -233,11 +233,11 @@ class NWProject:
def trashFolder(self) -> str:
"""Add the special trash root folder to the project."""
- trashHandle = self._tree.trashRoot
- if trashHandle is None:
- label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
- return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH)
- return trashHandle
+ # trashHandle = self._tree.trashRoot
+ # if trashHandle is None:
+ # label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
+ # return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH)
+ return ""
##
# Project Methods
@@ -329,7 +329,6 @@ class NWProject:
# ============
self._tree.unpack(projContent)
- self._tree.buildModel()
self._options.loadSettings()
self._loadProjectLocalisation()
@@ -490,17 +489,6 @@ class NWProject:
self.setProjectChanged(True)
return
- def setTreeOrder(self, order: list[str]) -> None:
- """A list representing the linear/flattened order of project
- items in the GUI project tree. The user can rearrange the order
- by drag-and-drop. Forwarded to the NWTree class.
- """
- if len(self._tree) != len(order):
- logger.warning("Sizes of new and old tree order do not match")
- self._tree.setOrder(order)
- self.setProjectChanged(True)
- return
-
def setProjectChanged(self, status: bool) -> bool:
"""Toggle the project changed flag, and propagate the
information to the GUI statusbar.
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index d0b1ad42..d159e383 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -28,10 +28,12 @@ import random
from collections.abc import Iterable, Iterator
from pathlib import Path
-from typing import TYPE_CHECKING, Literal, overload
+from typing import TYPE_CHECKING
+
+from PyQt5.QtCore import QModelIndex
from novelwriter import SHARED
-from novelwriter.constants import nwFiles
+from novelwriter.constants import nwFiles, nwLabels, trConst
from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
@@ -73,8 +75,7 @@ class NWTree:
self._model = ProjectModel(self)
self._items: dict[str, NWItem] = {}
self._nodes: dict[str, ProjectNode] = {}
- self._trash = None # The handle of the trash root folder
- self._changed = False # True if tree structure has changed
+ self._trash = None
logger.debug("Ready: NWTree")
return
@@ -86,10 +87,10 @@ class NWTree:
# Properties
##
- @property
- def trashRoot(self) -> str | None:
- """Return the handle of the trash folder, or None."""
- return self._trash
+ # @property
+ # def trashRoot(self) -> str | None:
+ # """Return the handle of the trash folder, or None."""
+ # return self._trash
@property
def model(self) -> ProjectModel:
@@ -110,7 +111,6 @@ class NWTree:
self._items = {}
self._nodes = {}
self._trash = None
- self._changed = False
oldModel.deleteLater()
del oldModel
return
@@ -119,74 +119,58 @@ class NWTree:
"""Returns a copy of the list of all the active handles."""
return list(self._items.keys())
- @overload # pragma: no cover
- def create(self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
- itemClass: nwItemClass) -> str:
- pass
+ def add(self, item: NWItem, pos: int = -1) -> bool:
+ """Add a project item into the project tree."""
+ if pHandle := item.itemParent:
+ if parent := self._nodes.get(pHandle):
+ node = ProjectNode(item)
+ index = self._model.indexFromNode(parent)
+ self._model.insertChild(node, index, pos)
+ self._nodes[item.itemHandle] = node
+ self._items[item.itemHandle] = item
+ else:
+ logger.error("Could not locate parent of '%s'", item.itemHandle)
+ return False
+ elif item.isRootType():
+ node = ProjectNode(item)
+ self._model.insertChild(node, QModelIndex(), pos)
+ self._nodes[item.itemHandle] = node
+ self._items[item.itemHandle] = item
+ else:
+ logger.error("Invalid project item '%s'", item.itemHandle)
+ return False
+ return True
- @overload # pragma: no cover
- def create(self, label: str, parent: str | None, itemType: nwItemType,
- itemClass: nwItemClass = nwItemClass.NO_CLASS) -> str | None:
- pass
-
- def create(self, label, parent, itemType, itemClass=nwItemClass.NO_CLASS) -> str | None:
+ def create(
+ self, label: str, parent: str | None, itemType: nwItemType,
+ itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1,
+ ) -> str | None:
"""Create a new item in the project tree, and return its handle.
If the item cannot be added to the project because of an invalid
parent, None is returned. For root elements, this cannot occur.
"""
- # parent = None if itemType == nwItemType.ROOT else parent
- # if parent is None or parent in self._order:
- # tHandle = self._makeHandle()
- # newItem = NWItem(self._project, tHandle)
- # newItem.setName(label)
- # newItem.setParent(parent)
- # newItem.setType(itemType)
- # newItem.setClass(itemClass)
- # self.append(newItem)
- # self.updateItemData(tHandle)
- # return tHandle
+ parent = None if itemType == nwItemType.ROOT else parent
+ if parent is None or parent in self._nodes:
+ tHandle = self._makeHandle()
+ nwItem = NWItem(self._project, tHandle)
+ nwItem.setName(label)
+ nwItem.setParent(parent)
+ nwItem.setType(itemType)
+ nwItem.setClass(itemClass)
+ if self.add(nwItem, pos):
+ self.updateItemData(tHandle)
+ self._project.setProjectChanged(True)
+ return tHandle
return None
- def append(self, nwItem: NWItem) -> bool:
- """Add a new item to the end of the tree."""
- # tHandle = nwItem.itemHandle
- # pHandle = nwItem.itemParent
-
- # if not isHandle(tHandle):
- # logger.warning("Invalid item handle '%s' detected, skipping", tHandle)
- # return False
-
- # if tHandle in self._tree:
- # logger.warning("Duplicate handle '%s' detected, skipping", tHandle)
- # return False
-
- # logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle))
-
- # if nwItem.isRootType():
- # logger.debug("Item '%s' is a root item", str(tHandle))
- # self._roots[tHandle] = nwItem
- # if nwItem.itemClass == nwItemClass.TRASH:
- # if self._trash is None:
- # logger.debug("Item '%s' is the trash folder", str(tHandle))
- # self._trash = tHandle
- # else:
- # logger.error("Only one trash folder allowed")
- # return False
-
- # self._tree[tHandle] = nwItem
- # self._order.append(tHandle)
- # self._setTreeChanged(True)
-
- return True
-
def duplicate(self, sHandle: str) -> NWItem | None:
"""Duplicate an item and set a new handle."""
- sItem = self.__getitem__(sHandle)
- if isinstance(sItem, NWItem):
- nItem = NWItem.duplicate(sItem, self._makeHandle())
- if self.append(nItem):
- logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
- return nItem
+ # sItem = self.__getitem__(sHandle)
+ # if isinstance(sItem, NWItem):
+ # nItem = NWItem.duplicate(sItem, self._makeHandle())
+ # if self.append(nItem):
+ # logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
+ # return nItem
return None
def pack(self) -> list[dict]:
@@ -206,28 +190,22 @@ class NWTree:
project tree.
"""
self.clear()
+ items: dict[str, NWItem] = self._items.copy()
for item in data:
nwItem = NWItem(self._project, "")
if nwItem.unpack(item):
- self._items[nwItem.itemHandle] = nwItem
- if nwItem.itemClass == nwItemClass.TRASH:
- logger.debug("Item '%s' is the trash folder", str(nwItem.itemHandle))
- self._trash = nwItem.itemHandle
- return
+ items[nwItem.itemHandle] = nwItem
- def buildModel(self) -> None:
- """"""
+ later = items
self._model.beginInsertRows(self._model.index(0, 0), 0, 0)
- later: dict[str, NWItem] = self._items.copy()
for _ in range(999):
- later = self._buildTree(later)
+ later = self._addItems(later)
if len(later) == 0:
break
else:
logger.error("Not all items could be added to project tree")
- for item in later.values():
- item.setParent(None)
+ self._trash = self._getTrashNode()
self._model.endInsertRows()
self._model.layoutChanged.emit()
@@ -248,30 +226,9 @@ class NWTree:
else:
for index in indices:
self._model.dataChanged.emit(index, index)
+ self._project.setProjectChanged(len(indices) > 0)
return
- def _buildTree(self, items: dict[str, NWItem]) -> dict[str, NWItem]:
- """"""
- remains: dict[str, NWItem] = {}
- for handle, item in items.items():
- if pHandle := item.itemParent:
- if parent := self._nodes.get(pHandle):
- node = ProjectNode(item)
- parent.addChild(node)
- self._nodes[handle] = node
- elif pHandle in items:
- remains[handle] = item
- logger.warning("Item '%s' found before its parent", handle)
- else:
- item.setParent(None)
- logger.error("Item '%s' has no parent in current tree", handle)
- elif item.isRootType():
- node = ProjectNode(item)
- self._model.root.addChild(node)
- self._nodes[handle] = node
-
- return remains
-
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
@@ -324,7 +281,7 @@ class NWTree:
newItem.setType(nwItemType.FILE)
newItem.setClass(oClass)
newItem.setLayout(oLayout)
- if self.append(newItem):
+ if self.add(newItem):
self.updateItemData(cHandle)
recovered += 1
@@ -393,22 +350,20 @@ class NWTree:
"""Update the root item handle of a given item. Returns True if
a root was found and data updated, otherwise False.
"""
- tItem = self.__getitem__(tHandle)
- if tItem is None:
- return False
-
- iItem = tItem
- for _ in range(MAX_DEPTH):
- if iItem.itemParent is None:
- tItem.setRoot(iItem.itemHandle)
- tItem.setClassDefaults(iItem.itemClass)
- return True
+ if tItem := self._items.get(tHandle):
+ iItem = tItem
+ for _ in range(MAX_DEPTH):
+ if iItem.itemParent is None:
+ tItem.setRoot(iItem.itemHandle)
+ tItem.setClassDefaults(iItem.itemClass)
+ return True
+ else:
+ iItem = self.__getitem__(iItem.itemParent)
+ if iItem is None:
+ return False
else:
- iItem = self.__getitem__(iItem.itemParent)
- if iItem is None:
- return False
- else:
- raise RecursionError("Critical internal error")
+ raise RecursionError("Critical internal error")
+ return False
def checkType(self, tHandle: str, itemType: nwItemType) -> bool:
"""Check if item exists and is of the specified item type."""
@@ -463,18 +418,18 @@ class NWTree:
def isTrash(self, tHandle: str) -> bool:
"""Check if an item is in or is the trash folder."""
- tItem = self.__getitem__(tHandle)
- if tItem is None:
- return True
- if tItem.itemClass == nwItemClass.TRASH:
- return True
- if self._trash is not None:
- if tHandle == self._trash:
- return True
- elif tItem.itemParent == self._trash:
- return True
- elif tItem.itemRoot == self._trash:
- return True
+ # tItem = self.__getitem__(tHandle)
+ # if tItem is None:
+ # return True
+ # if tItem.itemClass == nwItemClass.TRASH:
+ # return True
+ # if self._trash is not None:
+ # if tHandle == self._trash:
+ # return True
+ # elif tItem.itemParent == self._trash:
+ # return True
+ # elif tItem.itemRoot == self._trash:
+ # return True
return False
def findRoot(self, itemClass: nwItemClass | None) -> str | None:
@@ -484,29 +439,6 @@ class NWTree:
return node.item.itemHandle
return None
- ##
- # Setters
- ##
-
- def setOrder(self, newOrder: list[str]) -> None:
- """Reorders the tree based on a list of items."""
- # tmpOrder = [tHandle for tHandle in newOrder if tHandle in self._tree]
- # if not (len(tmpOrder) == len(newOrder) == len(self._order)):
- # # Something is wrong, so let's debug it
- # for tHandle in newOrder:
- # if tHandle not in self._tree:
- # logger.error("Handle '%s' in new tree order is not in old order", tHandle)
- # for tHandle in self._order:
- # if tHandle not in tmpOrder:
- # logger.warning("Handle '%s' in old tree order is not in new order", tHandle)
-
- # # Save the temp list
- # self._order = tmpOrder
- # self._setTreeChanged(True)
- # logger.debug("Project tree order updated")
-
- return
-
##
# Special Methods
##
@@ -542,8 +474,6 @@ class NWTree:
# if tHandle == self._trash:
# self._trash = None
- # self._setTreeChanged(True)
-
return
def __contains__(self, tHandle: str) -> bool:
@@ -554,24 +484,45 @@ class NWTree:
"""Iterate through project items."""
for node in self._model.root.allChildren():
yield node.item
- # for tHandle in self._order:
- # tItem = self._tree.get(tHandle)
- # if isinstance(tItem, NWItem):
- # yield tItem
return
##
# Internal Functions
##
- def _setTreeChanged(self, state: bool) -> None:
- """Set the changed flag to state, and if being set to True,
- propagate that state change to the parent NWProject class.
+ def _getTrashNode(self) -> ProjectNode | None:
+ """Get the trash node. If it doesn't exist, create it."""
+ for node in self._model.root.children:
+ if node.item.itemClass == nwItemClass.TRASH:
+ return node
+ label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
+ if handle := self.create(label, None, nwItemType.ROOT, nwItemClass.TRASH):
+ return self._nodes.get(handle)
+ return None
+
+ def _addItems(self, items: dict[str, NWItem]) -> dict[str, NWItem]:
+ """Add a dictionary of items to the project tree. Returns a new
+ dictionary of items that could not be added yet, but can be.
"""
- self._changed = state
- if state:
- self._project.setProjectChanged(True)
- return
+ remains: dict[str, NWItem] = {}
+ for handle, item in items.items():
+ if pHandle := item.itemParent:
+ if parent := self._nodes.get(pHandle):
+ node = ProjectNode(item)
+ parent.addChild(node)
+ self._items[handle] = item
+ self._nodes[handle] = node
+ elif pHandle in items:
+ remains[handle] = item
+ logger.warning("Item '%s' found before its parent", handle)
+ else:
+ logger.error("Item '%s' has no parent in current tree", handle)
+ elif item.isRootType():
+ node = ProjectNode(item)
+ self._model.root.addChild(node)
+ self._items[handle] = item
+ self._nodes[handle] = node
+ return remains
def _makeHandle(self) -> str:
"""Generate a unique item handle. In the event that the key
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 10b37618..adcaad09 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -39,7 +39,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import qtLambda
-from novelwriter.constants import nwLabels, nwUnicode, trConst
+from novelwriter.constants import nwLabels, nwStyles, nwUnicode, trConst
from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
from novelwriter.dialogs.editlabel import GuiEditLabel
@@ -568,8 +568,8 @@ class GuiProjectTree(QTreeView):
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# Connect signals
- self.clicked.connect(self._treeSingleClick)
- self.doubleClicked.connect(self._treeDoubleClick)
+ self.clicked.connect(self._onSingleClick)
+ self.doubleClicked.connect(self._onDoubleClick)
# Auto Scroll
# self._scrollMargin = SHARED.theme.baseIconHeight
@@ -639,13 +639,19 @@ class GuiProjectTree(QTreeView):
treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg)
treeHeader.resizeSection(self.C_STATUS, iPx + cMg)
- self.blockSignals(True)
- for index in SHARED.project.tree.model.allExpanded():
- self.setExpanded(index, True)
- self.blockSignals(False)
+ self.restoreExpandedState()
return
+ def restoreExpandedState(self) -> None:
+ """Expand all nodes that were previously expanded."""
+ if model := self._getModel():
+ self.blockSignals(True)
+ for index in model.allExpanded():
+ self.setExpanded(index, True)
+ self.blockSignals(False)
+ return
+
def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> None:
"""Set a specific handle as the selected item."""
if (model := self._getModel()) and (index := model.indexFromHandle(tHandle)).isValid():
@@ -655,6 +661,87 @@ class GuiProjectTree(QTreeView):
self.projView.selectedItemChanged.emit(tHandle)
return
+ def newTreeItem(
+ self, itemType: nwItemType, itemClass: nwItemClass | None = None,
+ hLevel: int = 1, isNote: bool = False, copyDoc: str | None = None,
+ ) -> None:
+ """Add new item to the tree, with a given itemType (and
+ itemClass if Root), and attach it to the selected handle. Also
+ make sure the item is added in a place it can be added, and that
+ other meta data is set correctly to ensure a valid project tree.
+ """
+ if not SHARED.hasProject:
+ logger.error("No project open")
+ return
+
+ tHandle = None
+ if itemType == nwItemType.ROOT and isinstance(itemClass, nwItemClass):
+
+ pos = -1
+ if (node := self._getNode(self.currentIndex())) and (itemRoot := node.item.itemRoot):
+ if root := SHARED.project.tree.nodes.get(itemRoot):
+ pos = root.row() + 1
+
+ SHARED.project.newRoot(itemClass, pos)
+ self.restoreExpandedState()
+
+ elif itemType in (nwItemType.FILE, nwItemType.FOLDER):
+
+ if not ((model := self._getModel()) and (node := model.node(self.currentIndex()))):
+ SHARED.error(self.tr("Did not find anywhere to add the file or folder!"))
+ return
+
+ if node.item.itemClass == nwItemClass.TRASH:
+ SHARED.error(self.tr("Cannot add new files or folders to the Trash folder."))
+ return
+
+ # Collect some information about the selected item
+ sLevel = nwStyles.H_LEVEL.get(node.item.mainHeading, 0)
+ sIsParent = node.childCount() > 0
+
+ # Set default label and determine if new item is to be added
+ # as child or sibling to the selected item
+ if itemType == nwItemType.FILE:
+ if copyDoc and (cItem := SHARED.project.tree[copyDoc]):
+ newLabel = cItem.itemName
+ asChild = sIsParent and node.item.isDocumentLayout()
+ elif isNote:
+ newLabel = self.tr("New Note")
+ asChild = sIsParent
+ elif hLevel == 2:
+ newLabel = self.tr("New Chapter")
+ asChild = sIsParent and node.item.isDocumentLayout() and sLevel < 2
+ elif hLevel == 3:
+ newLabel = self.tr("New Scene")
+ asChild = sIsParent and node.item.isDocumentLayout() and sLevel < 3
+ else:
+ newLabel = self.tr("New Document")
+ asChild = sIsParent and node.item.isDocumentLayout()
+ else:
+ newLabel = self.tr("New Folder")
+ asChild = False
+
+ pos = -1
+ sHandle = None
+ if not (asChild or node.item.isFolderType() or node.item.isRootType()):
+ pos = node.row() + 1
+ sHandle = node.item.itemParent
+
+ sHandle = sHandle or node.item.itemHandle
+ newLabel, dlgOk = GuiEditLabel.getLabel(self, text=newLabel)
+ if dlgOk:
+ # Add the file or folder
+ if itemType == nwItemType.FILE:
+ tHandle = SHARED.project.newFile(newLabel, sHandle, pos)
+ if tHandle and copyDoc:
+ SHARED.project.copyFileContent(tHandle, copyDoc)
+ elif tHandle and hLevel > 0:
+ SHARED.project.writeNewFile(tHandle, hLevel, not isNote)
+ else:
+ tHandle = SHARED.project.newFolder(newLabel, sHandle, pos)
+
+ return
+
##
# Events
##
@@ -736,14 +823,14 @@ class GuiProjectTree(QTreeView):
##
@pyqtSlot(QModelIndex)
- def _treeSingleClick(self, index: QModelIndex) -> None:
+ def _onSingleClick(self, index: QModelIndex) -> None:
"""The user changed which item is selected."""
if node := self._getNode(index):
self.projView.selectedItemChanged.emit(node.item.itemHandle)
return
@pyqtSlot(QModelIndex)
- def _treeDoubleClick(self, index: QModelIndex) -> None:
+ def _onDoubleClick(self, index: QModelIndex) -> None:
"""Capture a double-click event and either request the document
for editing if it is a file, or expand/close the node if not.
"""
@@ -789,109 +876,6 @@ class GuiProjectTree(QTreeView):
# self.revealNewTreeItem(tHandle, wordCount=True)
return
- def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None,
- hLevel: int = 1, isNote: bool = False, copyDoc: str | None = None) -> bool:
- """Add new item to the tree, with a given itemType (and
- itemClass if Root), and attach it to the selected handle. Also
- make sure the item is added in a place it can be added, and that
- other meta data is set correctly to ensure a valid project tree.
- """
- # if not SHARED.hasProject:
- # logger.error("No project open")
- # return False
-
- # nHandle = None
- # tHandle = None
-
- # if itemType == nwItemType.ROOT and isinstance(itemClass, nwItemClass):
-
- # tHandle = SHARED.project.newRoot(itemClass)
- # sHandle = self.getSelectedHandle()
- # pItem = SHARED.project.tree[sHandle] if sHandle else None
- # nHandle = pItem.itemRoot if pItem else None
-
- # elif itemType in (nwItemType.FILE, nwItemType.FOLDER):
-
- # sHandle = self.getSelectedHandle()
- # pItem = SHARED.project.tree[sHandle] if sHandle else None
- # if sHandle is None or pItem is None:
- # SHARED.error(self.tr("Did not find anywhere to add the file or folder!"))
- # return False
-
- # # Collect some information about the selected item
- # qItem = self._getTreeItem(sHandle)
- # sLevel = nwStyles.H_LEVEL.get(pItem.mainHeading, 0)
- # sIsParent = False if qItem is None else qItem.childCount() > 0
-
- # if SHARED.project.tree.isTrash(sHandle):
- # SHARED.error(self.tr("Cannot add new files or folders to the Trash folder."))
- # return False
-
- # # Set default label and determine if new item is to be added
- # # as child or sibling to the selected item
- # if itemType == nwItemType.FILE:
- # if copyDoc and (cItem := SHARED.project.tree[copyDoc]):
- # newLabel = cItem.itemName
- # asChild = sIsParent and pItem.isDocumentLayout()
- # elif isNote:
- # newLabel = self.tr("New Note")
- # asChild = sIsParent
- # elif hLevel == 2:
- # newLabel = self.tr("New Chapter")
- # asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 2
- # elif hLevel == 3:
- # newLabel = self.tr("New Scene")
- # asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 3
- # else:
- # newLabel = self.tr("New Document")
- # asChild = sIsParent and pItem.isDocumentLayout()
- # else:
- # newLabel = self.tr("New Folder")
- # asChild = False
-
- # if not (asChild or pItem.isFolderType() or pItem.isRootType()):
- # # Move to the parent item so that the new item is added
- # # as a sibling instead
- # nHandle = sHandle
- # sHandle = pItem.itemParent
- # if sHandle is None:
- # # Bug: We have a condition that is unhandled
- # logger.error("Internal error")
- # return False
-
- # # Ask for label
- # newLabel, dlgOk = GuiEditLabel.getLabel(self, text=newLabel)
- # if not dlgOk:
- # logger.info("New item creation cancelled by user")
- # return False
-
- # # Add the file or folder
- # if itemType == nwItemType.FILE:
- # tHandle = SHARED.project.newFile(newLabel, sHandle)
- # else:
- # tHandle = SHARED.project.newFolder(newLabel, sHandle)
-
- # else:
- # logger.error("Failed to add new item")
- # return False
-
- # # If there is no handle set, return here. This is a bug.
- # if tHandle is None: # pragma: no cover
- # logger.error("Internal error")
- # return True
-
- # # Handle new file creation
- # if itemType == nwItemType.FILE and copyDoc:
- # SHARED.project.copyFileContent(tHandle, copyDoc)
- # elif itemType == nwItemType.FILE and hLevel > 0:
- # SHARED.project.writeNewFile(tHandle, hLevel, not isNote)
-
- # # Add the new item to the project tree
- # self.revealNewTreeItem(tHandle, nHandle=nHandle, wordCount=True)
- # self.projView.setTreeFocus() # See issue #1376
-
- return True
-
def revealNewTreeItem(self, tHandle: str | None, nHandle: str | None = None,
wordCount: bool = False) -> bool:
"""Reveal a newly added project item in the project tree."""
diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py
index af14b26c..5aaedacc 100644
--- a/tests/test_gui/test_gui_projtree.py
+++ b/tests/test_gui/test_gui_projtree.py
@@ -987,25 +987,25 @@ def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd)
# Try to open a file with nothings selected
projTree.clearSelection()
- projTree._treeDoubleClick(QTreeWidgetItem(), 0)
+ projTree._onDoubleClick(QTreeWidgetItem(), 0)
assert nwGUI.docEditor.docHandle is None
# When the item cannot be found
projTree._getTreeItem(C.hTitlePage).setSelected(True) # type: ignore
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.tree.NWTree.__getitem__", lambda *a: None)
- projTree._treeDoubleClick(QTreeWidgetItem(), 0)
+ projTree._onDoubleClick(QTreeWidgetItem(), 0)
assert nwGUI.docEditor.docHandle is None
# Successfully open a file
- projTree._treeDoubleClick(projTree._getTreeItem(C.hTitlePage), 0)
+ projTree._onDoubleClick(projTree._getTreeItem(C.hTitlePage), 0)
assert nwGUI.docEditor.docHandle == C.hTitlePage
projTree._getTreeItem(C.hTitlePage).setSelected(False) # type: ignore
# A non-file item should be expanded instead
projTree._getTreeItem(C.hNovelRoot).setExpanded(False) # type: ignore
projTree._getTreeItem(C.hNovelRoot).setSelected(True) # type: ignore
- projTree._treeDoubleClick(projTree._getTreeItem(C.hNovelRoot), 1)
+ projTree._onDoubleClick(projTree._getTreeItem(C.hNovelRoot), 1)
assert nwGUI.docEditor.docHandle == C.hTitlePage
assert projTree._getTreeItem(C.hNovelRoot).isExpanded() is True # type: ignore
From 442b065834158c59b1fa8fc1833caf51794d83c7 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 19 Nov 2024 17:47:21 +0100
Subject: [PATCH 10/39] Handle item consistency in the nodes tree
---
novelwriter/core/itemmodel.py | 46 ++-
novelwriter/core/tree.py | 45 +--
novelwriter/gui/projtree.py | 675 ++++++++++++++++------------------
3 files changed, 352 insertions(+), 414 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 67253897..c88bd0da 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -45,7 +45,6 @@ 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
@@ -95,34 +94,31 @@ class ProjectNode:
##
def refresh(self) -> None:
- cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {}
-
+ """Refresh data values."""
# Label
- cache[C_LABEL_ICON] = SHARED.theme.getItemIcon(
+ self._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
+ self._cache[C_LABEL_TEXT] = self._item.itemName
# Count
- cache[C_COUNT_ALIGN] = QtAlignRight
+ self._cache[C_COUNT_ALIGN] = QtAlignRight
# Active
if self._item.isFileType():
if self._item.isActive:
- cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("checked")
+ self._cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("checked")
else:
- cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("unchecked")
+ self._cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("unchecked")
else:
- cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("noncheckable")
+ self._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[C_STATUS_ICON] = sIcon
+ self._cache[C_STATUS_TIP] = sText
- self._cache = cache
self.updateCount()
return
@@ -145,7 +141,7 @@ class ProjectNode:
return len(self._children)
def data(self, column: int, role: Qt.ItemDataRole) -> T_NodeData:
- """"""
+ """Return cached node data."""
return self._cache.get(COL_MASK*column | role)
def parent(self) -> ProjectNode | None:
@@ -157,6 +153,7 @@ class ProjectNode:
return None
def allChildren(self) -> list[ProjectNode]:
+ """Return a recursive list of all children."""
nodes: list[ProjectNode] = []
self._recursiveAppendChildren(nodes)
return nodes
@@ -168,15 +165,24 @@ class ProjectNode:
def addChild(self, child: ProjectNode, pos: int = -1) -> None:
"""Add a child item to this item."""
child._parent = self
+ self._updateRelationships(child)
if 0 <= pos < len(self._children):
self._children.insert(pos, child)
self._refreshChildrenPos()
else:
child._row = len(self._children)
self._children.append(child)
- self.refresh()
+ self.updateCount()
return
+ def takeChild(self, pos: int) -> ProjectNode | None:
+ """Remove a child item and return it."""
+ if 0 <= pos < len(self._children):
+ node = self._children.pop()
+ self._refreshChildrenPos()
+ return node
+ return None
+
def moveChild(self, source: int, target: int) -> None:
"""Move a child internally."""
count = len(self._children)
@@ -203,6 +209,16 @@ class ProjectNode:
child._row = n
return
+ def _updateRelationships(self, child: ProjectNode) -> None:
+ """Update a child item's relationships."""
+ if self._parent:
+ child.item.setParent(self.item.itemHandle)
+ child.item.setRoot(self.item.itemRoot)
+ else:
+ child.item.setParent(None)
+ child.item.setRoot(child.item.itemHandle)
+ return
+
class ProjectModel(QAbstractItemModel):
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index d159e383..d6d3a082 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -87,10 +87,10 @@ class NWTree:
# Properties
##
- # @property
- # def trashRoot(self) -> str | None:
- # """Return the handle of the trash folder, or None."""
- # return self._trash
+ @property
+ def trash(self) -> ProjectNode | None:
+ """Return trash node, if it exists."""
+ return self._getTrashNode()
@property
def model(self) -> ProjectModel:
@@ -158,7 +158,6 @@ class NWTree:
nwItem.setType(itemType)
nwItem.setClass(itemClass)
if self.add(nwItem, pos):
- self.updateItemData(tHandle)
self._project.setProjectChanged(True)
return tHandle
return None
@@ -238,23 +237,15 @@ class NWTree:
mark recovered files.
"""
storage = self._project.storage
- files = set(storage.scanContent())
- for tHandle in self._nodes:
- if self.updateItemData(tHandle):
- logger.debug("Checking item '%s' ... OK", tHandle)
- files.discard(tHandle) # Remove it from the record
- else:
- logger.error("Checking item '%s' ... ERROR", tHandle)
- self.__delitem__(tHandle) # The file will be re-added as orphaned
-
- orphans = len(files)
+ remains = set(storage.scanContent()).difference(set(self._nodes.keys()))
+ orphans = len(remains)
if orphans == 0:
logger.info("Checked project files: OK")
return 0, 0
logger.warning("Found %d file(s) not tracked in project", orphans)
recovered = 0
- for cHandle in files:
+ for cHandle in remains:
aDoc = storage.getDocument(cHandle)
aDoc.readDocument(isOrphan=True)
oName, oParent, oClass, oLayout = aDoc.getMeta()
@@ -282,7 +273,6 @@ class NWTree:
newItem.setClass(oClass)
newItem.setLayout(oLayout)
if self.add(newItem):
- self.updateItemData(cHandle)
recovered += 1
return orphans, recovered
@@ -346,25 +336,6 @@ class NWTree:
# Tree Item Methods
##
- def updateItemData(self, tHandle: str) -> bool:
- """Update the root item handle of a given item. Returns True if
- a root was found and data updated, otherwise False.
- """
- if tItem := self._items.get(tHandle):
- iItem = tItem
- for _ in range(MAX_DEPTH):
- if iItem.itemParent is None:
- tItem.setRoot(iItem.itemHandle)
- tItem.setClassDefaults(iItem.itemClass)
- return True
- else:
- iItem = self.__getitem__(iItem.itemParent)
- if iItem is None:
- return False
- else:
- raise RecursionError("Critical internal error")
- return False
-
def checkType(self, tHandle: str, itemType: nwItemType) -> bool:
"""Check if item exists and is of the specified item type."""
tItem = self.__getitem__(tHandle)
@@ -515,8 +486,6 @@ class NWTree:
elif pHandle in items:
remains[handle] = item
logger.warning("Item '%s' found before its parent", handle)
- else:
- logger.error("Item '%s' has no parent in current tree", handle)
elif item.isRootType():
node = ProjectNode(item)
self._model.root.addChild(node)
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index adcaad09..637703bd 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -39,12 +39,11 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import qtLambda
-from novelwriter.constants import nwLabels, nwStyles, nwUnicode, trConst
+from novelwriter.constants import nwLabels, nwStyles, trConst
from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
from novelwriter.dialogs.editlabel import GuiEditLabel
-from novelwriter.dialogs.projectsettings import GuiProjectSettings
-from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType
+from novelwriter.enum import nwDocMode, nwItemClass, nwItemType
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
from novelwriter.types import (
@@ -526,7 +525,6 @@ class GuiProjectTree(QTreeView):
# Tree Settings
iPx = SHARED.theme.baseIconHeight
- # cMg = CONFIG.pxInt(6)
self.setIconSize(SHARED.theme.baseIconSize)
self.setFrameStyle(QFrame.Shape.NoFrame)
@@ -536,18 +534,6 @@ class GuiProjectTree(QTreeView):
self.setAutoExpandDelay(1000)
self.setHeaderHidden(True)
self.setIndentation(iPx)
- # self.setColumnCount(4)
-
- # Lock the column sizes
- # 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)
# Allow Move by Drag & Drop
# self.setDragEnabled(True)
@@ -732,13 +718,15 @@ class GuiProjectTree(QTreeView):
if dlgOk:
# Add the file or folder
if itemType == nwItemType.FILE:
- tHandle = SHARED.project.newFile(newLabel, sHandle, pos)
- if tHandle and copyDoc:
- SHARED.project.copyFileContent(tHandle, copyDoc)
- elif tHandle and hLevel > 0:
- SHARED.project.writeNewFile(tHandle, hLevel, not isNote)
+ if tHandle := SHARED.project.newFile(newLabel, sHandle, pos):
+ if copyDoc:
+ SHARED.project.copyFileContent(tHandle, copyDoc)
+ elif hLevel > 0:
+ SHARED.project.writeNewFile(tHandle, hLevel, not isNote)
+ SHARED.project.index.reIndexHandle(tHandle)
+ SHARED.project.tree.refreshItems([tHandle])
else:
- tHandle = SHARED.project.newFolder(newLabel, sHandle, pos)
+ SHARED.project.newFolder(newLabel, sHandle, pos)
return
@@ -876,41 +864,6 @@ class GuiProjectTree(QTreeView):
# self.revealNewTreeItem(tHandle, wordCount=True)
return
- def revealNewTreeItem(self, tHandle: str | None, nHandle: str | None = None,
- wordCount: bool = False) -> bool:
- """Reveal a newly added project item in the project tree."""
- # nwItem = SHARED.project.tree[tHandle] if tHandle else None
- # if tHandle is None or nwItem is None:
- # return False
-
- # trItem = self._addTreeItem(nwItem, nHandle)
- # if trItem is None:
- # return False
-
- # if nwItem.isFileType() and wordCount:
- # wC = SHARED.project.index.getCounts(tHandle)[1]
- # self.propagateCount(tHandle, wC)
- # self.projView.wordCountsChanged.emit()
-
- # pHandle = nwItem.itemParent
- # if pHandle is not None and pHandle in self._treeMap:
- # self._treeMap[pHandle].setExpanded(True)
-
- # self._alertTreeChange(tHandle, flush=True)
- # self.setCurrentItem(trItem)
-
- return True
-
- def getTreeFromHandle(self, tHandle: str) -> list[str]:
- """Recursively return all the child items starting from a given
- item handle.
- """
- result = []
- # tIten = self._getTreeItem(tHandle)
- # if tIten is not None:
- # result = self._scanChildren(result, tIten, 0)
- return result
-
def requestDeleteItem(self, tHandle: str | None = None) -> bool:
"""Request an item deleted from the project tree. This function
can be called on any item, and will check whether to attempt a
@@ -1768,358 +1721,358 @@ class _UpdatableMenu(QMenu):
return
-class _TreeContextMenu(QMenu):
+# class _TreeContextMenu(QMenu):
- def __init__(self, projTree: GuiProjectTree, nwItem: NWItem) -> None:
- super().__init__(parent=projTree)
+# def __init__(self, projTree: GuiProjectTree, nwItem: NWItem) -> None:
+# super().__init__(parent=projTree)
- self.projTree = projTree
- self.projView = projTree.projView
+# self.projTree = projTree
+# self.projView = projTree.projView
- self._item = nwItem
- self._handle = nwItem.itemHandle
- self._items: list[NWItem] = []
+# self._item = nwItem
+# self._handle = nwItem.itemHandle
+# self._items: list[NWItem] = []
- logger.debug("Ready: _TreeContextMenu")
+# logger.debug("Ready: _TreeContextMenu")
- return
+# return
- def __del__(self) -> None: # pragma: no cover
- logger.debug("Delete: _TreeContextMenu")
- return
+# def __del__(self) -> None: # pragma: no cover
+# logger.debug("Delete: _TreeContextMenu")
+# return
- ##
- # Methods
- ##
+# ##
+# # Methods
+# ##
- def buildTrashMenu(self) -> None:
- """Build the special menu for the Trash folder."""
- action = self.addAction(self.tr("Empty Trash"))
- action.triggered.connect(self.projTree.emptyTrash)
- return
+# def buildTrashMenu(self) -> None:
+# """Build the special menu for the Trash folder."""
+# action = self.addAction(self.tr("Empty Trash"))
+# action.triggered.connect(self.projTree.emptyTrash)
+# return
- def buildSingleSelectMenu(self, hasChild: bool) -> None:
- """Build the single-select menu."""
- isFile = self._item.isFileType()
- isFolder = self._item.isFolderType()
- isRoot = self._item.isRootType()
+# def buildSingleSelectMenu(self, hasChild: bool) -> None:
+# """Build the single-select menu."""
+# isFile = self._item.isFileType()
+# isFolder = self._item.isFolderType()
+# isRoot = self._item.isRootType()
- # Document Actions
- if isFile:
- self._docActions()
- self.addSeparator()
+# # Document Actions
+# if isFile:
+# self._docActions()
+# self.addSeparator()
- # Create New Items
- self._itemCreation()
- self.addSeparator()
+# # Create New Items
+# self._itemCreation()
+# self.addSeparator()
- # Edit Item Settings
- action = self.addAction(self.tr("Rename"))
- action.triggered.connect(qtLambda(self.projView.renameTreeItem, self._handle))
- if isFile:
- self._itemHeader()
- self._itemActive(False)
- self._itemStatusImport(False)
+# # Edit Item Settings
+# action = self.addAction(self.tr("Rename"))
+# action.triggered.connect(qtLambda(self.projView.renameTreeItem, self._handle))
+# if isFile:
+# self._itemHeader()
+# self._itemActive(False)
+# self._itemStatusImport(False)
- # Transform Item
- if isFile or isFolder:
- self._itemTransform(isFile, isFolder, hasChild)
- self.addSeparator()
+# # Transform Item
+# if isFile or isFolder:
+# self._itemTransform(isFile, isFolder, hasChild)
+# self.addSeparator()
- # Process Item
- self._itemProcess(isFile, isFolder, isRoot, hasChild)
+# # Process Item
+# self._itemProcess(isFile, isFolder, isRoot, hasChild)
- return
+# return
- def buildMultiSelectMenu(self, handles: list[str]) -> None:
- """Build the multi-select menu."""
- self._items = []
- for tHandle in handles:
- if (tItem := SHARED.project.tree[tHandle]):
- self._items.append(tItem)
+# def buildMultiSelectMenu(self, handles: list[str]) -> None:
+# """Build the multi-select menu."""
+# self._items = []
+# for tHandle in handles:
+# if (tItem := SHARED.project.tree[tHandle]):
+# self._items.append(tItem)
- self._itemActive(True)
- self._itemStatusImport(True)
- self.addSeparator()
- self._multiMoveToTrash()
- return
+# self._itemActive(True)
+# self._itemStatusImport(True)
+# self.addSeparator()
+# self._multiMoveToTrash()
+# return
- ##
- # Menu Builders
- ##
+# ##
+# # Menu Builders
+# ##
- def _docActions(self) -> None:
- """Add document actions."""
- action = self.addAction(self.tr("Open Document"))
- action.triggered.connect(qtLambda(
- self.projView.openDocumentRequest.emit,
- self._handle, nwDocMode.EDIT, "", True
- ))
- action = self.addAction(self.tr("View Document"))
- action.triggered.connect(qtLambda(
- self.projView.openDocumentRequest.emit,
- self._handle, nwDocMode.VIEW, "", False
- ))
- return
+# def _docActions(self) -> None:
+# """Add document actions."""
+# action = self.addAction(self.tr("Open Document"))
+# action.triggered.connect(qtLambda(
+# self.projView.openDocumentRequest.emit,
+# self._handle, nwDocMode.EDIT, "", True
+# ))
+# action = self.addAction(self.tr("View Document"))
+# action.triggered.connect(qtLambda(
+# self.projView.openDocumentRequest.emit,
+# self._handle, nwDocMode.VIEW, "", False
+# ))
+# return
- def _itemCreation(self) -> None:
- """Add create item actions."""
- menu = self.addMenu(self.tr("Create New ..."))
- menu.addAction(self.projView.projBar.aAddEmpty)
- menu.addAction(self.projView.projBar.aAddChap)
- menu.addAction(self.projView.projBar.aAddScene)
- menu.addAction(self.projView.projBar.aAddNote)
- menu.addAction(self.projView.projBar.aAddFolder)
- return
+# def _itemCreation(self) -> None:
+# """Add create item actions."""
+# menu = self.addMenu(self.tr("Create New ..."))
+# menu.addAction(self.projView.projBar.aAddEmpty)
+# menu.addAction(self.projView.projBar.aAddChap)
+# menu.addAction(self.projView.projBar.aAddScene)
+# menu.addAction(self.projView.projBar.aAddNote)
+# menu.addAction(self.projView.projBar.aAddFolder)
+# return
- def _itemHeader(self) -> None:
- """Check if there is a header that can be used for rename."""
- SHARED.saveEditor()
- if hItem := SHARED.project.index.getItemHeading(self._handle, "T0001"):
- action = self.addAction(self.tr("Rename to Heading"))
- action.triggered.connect(
- qtLambda(self.projView.renameTreeItem, self._handle, hItem.title)
- )
- return
+# def _itemHeader(self) -> None:
+# """Check if there is a header that can be used for rename."""
+# SHARED.saveEditor()
+# if hItem := SHARED.project.index.getItemHeading(self._handle, "T0001"):
+# action = self.addAction(self.tr("Rename to Heading"))
+# action.triggered.connect(
+# qtLambda(self.projView.renameTreeItem, self._handle, hItem.title)
+# )
+# return
- def _itemActive(self, multi: bool) -> None:
- """Add Active/Inactive actions."""
- if multi:
- mSub = self.addMenu(self.tr("Set Active to ..."))
- aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self.projTree.trActive)
- aOne.triggered.connect(qtLambda(self._iterItemActive, True))
- aTwo = mSub.addAction(SHARED.theme.getIcon("unchecked"), self.projTree.trInactive)
- aTwo.triggered.connect(qtLambda(self._iterItemActive, False))
- else:
- action = self.addAction(self.tr("Toggle Active"))
- action.triggered.connect(self._toggleItemActive)
- return
+# def _itemActive(self, multi: bool) -> None:
+# """Add Active/Inactive actions."""
+# if multi:
+# mSub = self.addMenu(self.tr("Set Active to ..."))
+# aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self.projTree.trActive)
+# aOne.triggered.connect(qtLambda(self._iterItemActive, True))
+# aTwo = mSub.addAction(SHARED.theme.getIcon("unchecked"), self.projTree.trInactive)
+# aTwo.triggered.connect(qtLambda(self._iterItemActive, False))
+# else:
+# action = self.addAction(self.tr("Toggle Active"))
+# action.triggered.connect(self._toggleItemActive)
+# return
- def _itemStatusImport(self, multi: bool) -> None:
- """Add actions for changing status or importance."""
- if self._item.isNovelLike():
- menu = self.addMenu(self.tr("Set Status to ..."))
- current = self._item.itemStatus
- for key, entry in SHARED.project.data.itemStatus.iterItems():
- name = entry.name
- if not multi and current == key:
- name += f" ({nwUnicode.U_CHECK})"
- action = menu.addAction(entry.icon, name)
- if multi:
- action.triggered.connect(qtLambda(self._iterSetItemStatus, key))
- else:
- action.triggered.connect(qtLambda(self._changeItemStatus, key))
- menu.addSeparator()
- action = menu.addAction(self.tr("Manage Labels ..."))
- action.triggered.connect(qtLambda(
- self.projView.projectSettingsRequest.emit,
- GuiProjectSettings.PAGE_STATUS
- ))
- else:
- menu = self.addMenu(self.tr("Set Importance to ..."))
- current = self._item.itemImport
- for key, entry in SHARED.project.data.itemImport.iterItems():
- name = entry.name
- if not multi and current == key:
- name += f" ({nwUnicode.U_CHECK})"
- action = menu.addAction(entry.icon, name)
- if multi:
- action.triggered.connect(qtLambda(self._iterSetItemImport, key))
- else:
- action.triggered.connect(qtLambda(self._changeItemImport, key))
- menu.addSeparator()
- action = menu.addAction(self.tr("Manage Labels ..."))
- action.triggered.connect(qtLambda(
- self.projView.projectSettingsRequest.emit,
- GuiProjectSettings.PAGE_IMPORT
- ))
- return
+# def _itemStatusImport(self, multi: bool) -> None:
+# """Add actions for changing status or importance."""
+# if self._item.isNovelLike():
+# menu = self.addMenu(self.tr("Set Status to ..."))
+# current = self._item.itemStatus
+# for key, entry in SHARED.project.data.itemStatus.iterItems():
+# name = entry.name
+# if not multi and current == key:
+# name += f" ({nwUnicode.U_CHECK})"
+# action = menu.addAction(entry.icon, name)
+# if multi:
+# action.triggered.connect(qtLambda(self._iterSetItemStatus, key))
+# else:
+# action.triggered.connect(qtLambda(self._changeItemStatus, key))
+# menu.addSeparator()
+# action = menu.addAction(self.tr("Manage Labels ..."))
+# action.triggered.connect(qtLambda(
+# self.projView.projectSettingsRequest.emit,
+# GuiProjectSettings.PAGE_STATUS
+# ))
+# else:
+# menu = self.addMenu(self.tr("Set Importance to ..."))
+# current = self._item.itemImport
+# for key, entry in SHARED.project.data.itemImport.iterItems():
+# name = entry.name
+# if not multi and current == key:
+# name += f" ({nwUnicode.U_CHECK})"
+# action = menu.addAction(entry.icon, name)
+# if multi:
+# action.triggered.connect(qtLambda(self._iterSetItemImport, key))
+# else:
+# action.triggered.connect(qtLambda(self._changeItemImport, key))
+# menu.addSeparator()
+# action = menu.addAction(self.tr("Manage Labels ..."))
+# action.triggered.connect(qtLambda(
+# self.projView.projectSettingsRequest.emit,
+# GuiProjectSettings.PAGE_IMPORT
+# ))
+# return
- def _itemTransform(self, isFile: bool, isFolder: bool, hasChild: bool) -> None:
- """Add actions for the Transform menu."""
- menu = self.addMenu(self.tr("Transform ..."))
+# def _itemTransform(self, isFile: bool, isFolder: bool, hasChild: bool) -> None:
+# """Add actions for the Transform menu."""
+# menu = self.addMenu(self.tr("Transform ..."))
- tree = self.projTree
- tHandle = self._handle
+# tree = self.projTree
+# tHandle = self._handle
- trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT])
- trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE])
- loDoc = nwItemLayout.DOCUMENT
- loNote = nwItemLayout.NOTE
- isDocFile = isFile and self._item.isDocumentLayout()
- isNoteFile = isFile and self._item.isNoteLayout()
+# trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT])
+# trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE])
+# loDoc = nwItemLayout.DOCUMENT
+# loNote = nwItemLayout.NOTE
+# isDocFile = isFile and self._item.isDocumentLayout()
+# isNoteFile = isFile and self._item.isNoteLayout()
- if isNoteFile and self._item.documentAllowed():
- action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
- action.triggered.connect(qtLambda(self._changeItemLayout, loDoc))
+# if isNoteFile and self._item.documentAllowed():
+# action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
+# action.triggered.connect(qtLambda(self._changeItemLayout, loDoc))
- if isDocFile:
- action = menu.addAction(self.tr("Convert to {0}").format(trNote))
- action.triggered.connect(qtLambda(self._changeItemLayout, loNote))
+# if isDocFile:
+# action = menu.addAction(self.tr("Convert to {0}").format(trNote))
+# action.triggered.connect(qtLambda(self._changeItemLayout, loNote))
- if isFolder and self._item.documentAllowed():
- action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
- action.triggered.connect(qtLambda(self._covertFolderToFile, loDoc))
+# if isFolder and self._item.documentAllowed():
+# action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
+# action.triggered.connect(qtLambda(self._covertFolderToFile, loDoc))
- if isFolder:
- action = menu.addAction(self.tr("Convert to {0}").format(trNote))
- action.triggered.connect(qtLambda(self._covertFolderToFile, loNote))
+# if isFolder:
+# action = menu.addAction(self.tr("Convert to {0}").format(trNote))
+# action.triggered.connect(qtLambda(self._covertFolderToFile, loNote))
- if hasChild and isFile:
- action = menu.addAction(self.tr("Merge Child Items into Self"))
- action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, False))
- action = menu.addAction(self.tr("Merge Child Items into New"))
- action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, True))
+# if hasChild and isFile:
+# action = menu.addAction(self.tr("Merge Child Items into Self"))
+# action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, False))
+# action = menu.addAction(self.tr("Merge Child Items into New"))
+# action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, True))
- if hasChild and isFolder:
- action = menu.addAction(self.tr("Merge Documents in Folder"))
- action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, True))
+# if hasChild and isFolder:
+# action = menu.addAction(self.tr("Merge Documents in Folder"))
+# action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, True))
- if isFile:
- action = menu.addAction(self.tr("Split Document by Headings"))
- action.triggered.connect(qtLambda(tree._splitDocument, tHandle))
+# if isFile:
+# action = menu.addAction(self.tr("Split Document by Headings"))
+# action.triggered.connect(qtLambda(tree._splitDocument, tHandle))
- return
+# return
- def _itemProcess(self, isFile: bool, isFolder: bool, isRoot: bool, hasChild: bool) -> None:
- """Add actions for item processing."""
- tree = self.projTree
- tHandle = self._handle
- if hasChild:
- action = self.addAction(self.tr("Expand All"))
- action.triggered.connect(qtLambda(tree.setExpandedFromHandle, tHandle, True))
- action = self.addAction(self.tr("Collapse All"))
- action.triggered.connect(qtLambda(tree.setExpandedFromHandle, tHandle, False))
+# def _itemProcess(self, isFile: bool, isFolder: bool, isRoot: bool, hasChild: bool) -> None:
+# """Add actions for item processing."""
+# tree = self.projTree
+# tHandle = self._handle
+# if hasChild:
+# action = self.addAction(self.tr("Expand All"))
+# action.triggered.connect(qtLambda(tree.setExpandedFromHandle, tHandle, True))
+# action = self.addAction(self.tr("Collapse All"))
+# action.triggered.connect(qtLambda(tree.setExpandedFromHandle, tHandle, False))
- action = self.addAction(self.tr("Duplicate"))
- action.triggered.connect(qtLambda(tree._duplicateFromHandle, tHandle))
+# action = self.addAction(self.tr("Duplicate"))
+# action.triggered.connect(qtLambda(tree._duplicateFromHandle, tHandle))
- if self._item.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
- action = self.addAction(self.tr("Delete Permanently"))
- action.triggered.connect(qtLambda(tree.permDeleteItem, tHandle))
- else:
- action = self.addAction(self.tr("Move to Trash"))
- action.triggered.connect(qtLambda(tree.moveItemToTrash, tHandle))
+# if self._item.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
+# action = self.addAction(self.tr("Delete Permanently"))
+# action.triggered.connect(qtLambda(tree.permDeleteItem, tHandle))
+# else:
+# action = self.addAction(self.tr("Move to Trash"))
+# action.triggered.connect(qtLambda(tree.moveItemToTrash, tHandle))
- return
+# return
- def _multiMoveToTrash(self) -> None:
- """Add move to Trash action."""
- areTrash = [i.itemClass == nwItemClass.TRASH for i in self._items]
- if all(areTrash):
- action = self.addAction(self.tr("Delete Permanently"))
- action.triggered.connect(self._iterPermDelete)
- elif not any(areTrash):
- action = self.addAction(self.tr("Move to Trash"))
- action.triggered.connect(self._iterMoveToTrash)
- return
+# def _multiMoveToTrash(self) -> None:
+# """Add move to Trash action."""
+# areTrash = [i.itemClass == nwItemClass.TRASH for i in self._items]
+# if all(areTrash):
+# action = self.addAction(self.tr("Delete Permanently"))
+# action.triggered.connect(self._iterPermDelete)
+# elif not any(areTrash):
+# action = self.addAction(self.tr("Move to Trash"))
+# action.triggered.connect(self._iterMoveToTrash)
+# return
- ##
- # Private Slots
- ##
+# ##
+# # Private Slots
+# ##
- @pyqtSlot()
- def _iterMoveToTrash(self) -> None:
- """Iterate through files and move them to Trash."""
- if SHARED.question(self.tr("Move {0} items to Trash?").format(len(self._items))):
- for tItem in self._items:
- if tItem.isFileType() and tItem.itemClass != nwItemClass.TRASH:
- self.projTree.moveItemToTrash(tItem.itemHandle, askFirst=False, flush=False)
- # self.projTree.saveTreeOrder()
- return
+# @pyqtSlot()
+# def _iterMoveToTrash(self) -> None:
+# """Iterate through files and move them to Trash."""
+# if SHARED.question(self.tr("Move {0} items to Trash?").format(len(self._items))):
+# for tItem in self._items:
+# if tItem.isFileType() and tItem.itemClass != nwItemClass.TRASH:
+# self.projTree.moveItemToTrash(tItem.itemHandle, askFirst=False, flush=False)
+# # self.projTree.saveTreeOrder()
+# return
- @pyqtSlot()
- def _iterPermDelete(self) -> None:
- """Iterate through files and delete them."""
- if SHARED.question(self.projTree.trPermDelete.format(len(self._items))):
- for tItem in self._items:
- if tItem.isFileType() and tItem.itemClass == nwItemClass.TRASH:
- self.projTree.permDeleteItem(tItem.itemHandle, askFirst=False, flush=False)
- # self.projTree.saveTreeOrder()
- return
+# @pyqtSlot()
+# def _iterPermDelete(self) -> None:
+# """Iterate through files and delete them."""
+# if SHARED.question(self.projTree.trPermDelete.format(len(self._items))):
+# for tItem in self._items:
+# if tItem.isFileType() and tItem.itemClass == nwItemClass.TRASH:
+# self.projTree.permDeleteItem(tItem.itemHandle, askFirst=False, flush=False)
+# # self.projTree.saveTreeOrder()
+# return
- @pyqtSlot()
- def _toggleItemActive(self) -> None:
- """Toggle the active status of an item."""
- self._item.setActive(not self._item.isActive)
- self.projTree.setTreeItemValues(self._item)
- self.projTree._alertTreeChange(self._handle, flush=False)
- return
+# @pyqtSlot()
+# def _toggleItemActive(self) -> None:
+# """Toggle the active status of an item."""
+# self._item.setActive(not self._item.isActive)
+# self.projTree.setTreeItemValues(self._item)
+# self.projTree._alertTreeChange(self._handle, flush=False)
+# return
- ##
- # Internal Functions
- ##
+# ##
+# # Internal Functions
+# ##
- def _iterItemActive(self, isActive: bool) -> None:
- """Set the active status of multiple items."""
- for tItem in self._items:
- if tItem and tItem.isFileType():
- tItem.setActive(isActive)
- self.projTree.setTreeItemValues(tItem)
- self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
- return
+# def _iterItemActive(self, isActive: bool) -> None:
+# """Set the active status of multiple items."""
+# for tItem in self._items:
+# if tItem and tItem.isFileType():
+# tItem.setActive(isActive)
+# self.projTree.setTreeItemValues(tItem)
+# self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
+# return
- def _changeItemStatus(self, key: str) -> None:
- """Set a new status value of an item."""
- self._item.setStatus(key)
- self.projTree.setTreeItemValues(self._item)
- self.projTree._alertTreeChange(self._handle, flush=False)
- return
+# def _changeItemStatus(self, key: str) -> None:
+# """Set a new status value of an item."""
+# self._item.setStatus(key)
+# self.projTree.setTreeItemValues(self._item)
+# self.projTree._alertTreeChange(self._handle, flush=False)
+# return
- def _iterSetItemStatus(self, key: str) -> None:
- """Change the status value for multiple items."""
- for tItem in self._items:
- if tItem and tItem.isNovelLike():
- tItem.setStatus(key)
- self.projTree.setTreeItemValues(tItem)
- self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
- return
+# def _iterSetItemStatus(self, key: str) -> None:
+# """Change the status value for multiple items."""
+# for tItem in self._items:
+# if tItem and tItem.isNovelLike():
+# tItem.setStatus(key)
+# self.projTree.setTreeItemValues(tItem)
+# self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
+# return
- def _changeItemImport(self, key: str) -> None:
- """Set a new importance value of an item."""
- self._item.setImport(key)
- self.projTree.setTreeItemValues(self._item)
- self.projTree._alertTreeChange(self._handle, flush=False)
- return
+# def _changeItemImport(self, key: str) -> None:
+# """Set a new importance value of an item."""
+# self._item.setImport(key)
+# self.projTree.setTreeItemValues(self._item)
+# self.projTree._alertTreeChange(self._handle, flush=False)
+# return
- def _iterSetItemImport(self, key: str) -> None:
- """Change the status value for multiple items."""
- for tItem in self._items:
- if tItem and not tItem.isNovelLike():
- tItem.setImport(key)
- self.projTree.setTreeItemValues(tItem)
- self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
- return
+# def _iterSetItemImport(self, key: str) -> None:
+# """Change the status value for multiple items."""
+# for tItem in self._items:
+# if tItem and not tItem.isNovelLike():
+# tItem.setImport(key)
+# self.projTree.setTreeItemValues(tItem)
+# self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
+# return
- def _changeItemLayout(self, itemLayout: nwItemLayout) -> None:
- """Set a new item layout value of an item."""
- if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
- self._item.setLayout(nwItemLayout.DOCUMENT)
- self.projTree.setTreeItemValues(self._item)
- self.projTree._alertTreeChange(self._handle, flush=False)
- elif itemLayout == nwItemLayout.NOTE:
- self._item.setLayout(nwItemLayout.NOTE)
- self.projTree.setTreeItemValues(self._item)
- self.projTree._alertTreeChange(self._handle, flush=False)
- return
+# def _changeItemLayout(self, itemLayout: nwItemLayout) -> None:
+# """Set a new item layout value of an item."""
+# if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
+# self._item.setLayout(nwItemLayout.DOCUMENT)
+# self.projTree.setTreeItemValues(self._item)
+# self.projTree._alertTreeChange(self._handle, flush=False)
+# elif itemLayout == nwItemLayout.NOTE:
+# self._item.setLayout(nwItemLayout.NOTE)
+# self.projTree.setTreeItemValues(self._item)
+# self.projTree._alertTreeChange(self._handle, flush=False)
+# return
- def _covertFolderToFile(self, itemLayout: nwItemLayout) -> None:
- """Convert a folder to a note or document."""
- if self._item.isFolderType():
- msgYes = SHARED.question(self.tr(
- "Do you want to convert the folder to a {0}? "
- "This action cannot be reversed."
- ).format(trConst(nwLabels.LAYOUT_NAME[itemLayout])))
- if msgYes and itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
- self._item.setType(nwItemType.FILE)
- self._item.setLayout(nwItemLayout.DOCUMENT)
- self.projTree.setTreeItemValues(self._item)
- self.projTree._alertTreeChange(self._handle, flush=False)
- elif msgYes and itemLayout == nwItemLayout.NOTE:
- self._item.setType(nwItemType.FILE)
- self._item.setLayout(nwItemLayout.NOTE)
- self.projTree.setTreeItemValues(self._item)
- self.projTree._alertTreeChange(self._handle, flush=False)
- else:
- logger.info("Folder conversion cancelled")
- return
+# def _covertFolderToFile(self, itemLayout: nwItemLayout) -> None:
+# """Convert a folder to a note or document."""
+# if self._item.isFolderType():
+# msgYes = SHARED.question(self.tr(
+# "Do you want to convert the folder to a {0}? "
+# "This action cannot be reversed."
+# ).format(trConst(nwLabels.LAYOUT_NAME[itemLayout])))
+# if msgYes and itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
+# self._item.setType(nwItemType.FILE)
+# self._item.setLayout(nwItemLayout.DOCUMENT)
+# self.projTree.setTreeItemValues(self._item)
+# self.projTree._alertTreeChange(self._handle, flush=False)
+# elif msgYes and itemLayout == nwItemLayout.NOTE:
+# self._item.setType(nwItemType.FILE)
+# self._item.setLayout(nwItemLayout.NOTE)
+# self.projTree.setTreeItemValues(self._item)
+# self.projTree._alertTreeChange(self._handle, flush=False)
+# else:
+# logger.info("Folder conversion cancelled")
+# return
From 3532306833105c1e74d6aff8128af431d0ce6c1c Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 19 Nov 2024 17:48:01 +0100
Subject: [PATCH 11/39] Add move to trash functionality
---
novelwriter/core/itemmodel.py | 71 ++++++++++++++++++++-----------
novelwriter/gui/mainmenu.py | 15 +++----
novelwriter/gui/projtree.py | 78 +++++++++++++----------------------
3 files changed, 82 insertions(+), 82 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index c88bd0da..69578a4d 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -178,7 +178,7 @@ class ProjectNode:
def takeChild(self, pos: int) -> ProjectNode | None:
"""Remove a child item and return it."""
if 0 <= pos < len(self._children):
- node = self._children.pop()
+ node = self._children.pop(pos)
self._refreshChildrenPos()
return node
return None
@@ -256,32 +256,23 @@ class ProjectModel(QAbstractItemModel):
def parent(self, index: QModelIndex) -> QModelIndex:
"""Get the parent model index of another index."""
- if index.isValid():
- if parent := index.internalPointer().parent():
- return self.createIndex(parent.row(), 0, parent)
+ if index.isValid() and (parent := index.internalPointer().parent()):
+ return self.createIndex(parent.row(), 0, parent)
return QModelIndex()
def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex:
"""get the index of a child item of a parent."""
- if parent.isValid():
- item: ProjectNode = 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)
-
+ if self.hasIndex(row, column, parent):
+ node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
+ if child := node.child(row):
+ return self.createIndex(row, column, child)
return QModelIndex()
def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData:
"""Return display data for a project node."""
- if not index.isValid():
- return None
- node: ProjectNode = index.internalPointer()
- return node.data(index.column(), role)
+ if index.isValid():
+ return index.internalPointer().data(index.column(), role)
+ return None
##
# Data Access
@@ -290,8 +281,7 @@ class ProjectModel(QAbstractItemModel):
def row(self, index: QModelIndex) -> int:
"""Return the row number of the index."""
if index.isValid():
- node: ProjectNode = index.internalPointer()
- return node.row()
+ return index.internalPointer().row()
return -1
def node(self, index: QModelIndex) -> ProjectNode | None:
@@ -320,10 +310,7 @@ class ProjectModel(QAbstractItemModel):
def insertChild(self, child: ProjectNode, parent: QModelIndex, pos: int) -> None:
"""Insert a node into the model at a given position."""
- if parent.isValid():
- node: ProjectNode = parent.internalPointer()
- else:
- node = self._root
+ node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
count = node.childCount()
row = minmax(pos, 0, count) if pos >= 0 else count
self.beginInsertRows(parent, row, row)
@@ -331,6 +318,16 @@ class ProjectModel(QAbstractItemModel):
self.endInsertRows()
return
+ def removeChild(self, parent: QModelIndex, pos: int) -> ProjectNode | None:
+ """Remove a node from the model and return it."""
+ node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
+ if 0 <= pos < node.childCount():
+ self.beginRemoveRows(parent, pos, pos)
+ child = node.takeChild(pos)
+ self.endRemoveRows()
+ return child
+ return None
+
def internalMove(self, index: QModelIndex, step: int) -> None:
"""Move an item internally among its siblings."""
if index.isValid():
@@ -345,6 +342,30 @@ class ProjectModel(QAbstractItemModel):
self.endMoveRows()
return
+ def multiMove(self, indices: list[QModelIndex], target: QModelIndex) -> None:
+ """Move multiple items to a new location."""
+ if target.isValid():
+ # This is a two pass process. First we only select unique
+ # non-root items for move, then we do a second pass and only
+ # move those items that don't have a parent also scheduled
+ # for moving or have already been moved. Child items are
+ # moved with the parent.
+ pruned = []
+ handles = set()
+ for index in indices:
+ if index.isValid():
+ node: ProjectNode = index.internalPointer()
+ handle = node.item.itemHandle
+ if node.item.isRootType() is False and handle not in handles:
+ pruned.append(node)
+ handles.add(handle)
+ for node in pruned:
+ if node.item.itemParent not in handles:
+ index = self.indexFromNode(node)
+ if child := self.removeChild(index.parent(), index.row()):
+ self.insertChild(child, target, -1)
+ return
+
##
# Other Methods
##
diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py
index 7637893a..0b2d93b9 100644
--- a/novelwriter/gui/mainmenu.py
+++ b/novelwriter/gui/mainmenu.py
@@ -162,19 +162,20 @@ class GuiMainMenu(QMenuBar):
self.projMenu.addSeparator()
# Project > Edit
- self.aEditItem = self.projMenu.addAction(self.tr("Rename Item"))
- self.aEditItem.setShortcut("F2")
- self.aEditItem.triggered.connect(qtLambda(self.mainGui.projView.renameTreeItem, None))
- self.mainGui.addAction(self.aEditItem)
+ self.aRenameItem = self.projMenu.addAction(self.tr("Rename Item"))
+ self.aRenameItem.setShortcut("F2")
# Project > Delete
self.aDeleteItem = self.projMenu.addAction(self.tr("Delete Item"))
- self.aDeleteItem.setShortcut("Ctrl+Shift+Del") # Cannot be Ctrl+Del, see #629
- self.aDeleteItem.triggered.connect(qtLambda(self.mainGui.projView.requestDeleteItem, None))
+ self.aDeleteItem.setShortcut("Del")
+ self.aDeleteItem.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
# Project > Empty Trash
self.aEmptyTrash = self.projMenu.addAction(self.tr("Empty Trash"))
- self.aEmptyTrash.triggered.connect(qtLambda(self.mainGui.projView.emptyTrash))
+
+ self.mainGui.projView.connectMenuActions(
+ self.aRenameItem, self.aDeleteItem, self.aEmptyTrash
+ )
# Project > Separator
self.projMenu.addSeparator()
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 637703bd..0b74a983 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -131,8 +131,6 @@ class GuiProjectView(QWidget):
self.projBar.newDocumentFromTemplate.connect(self.createFileFromTemplate)
# Function Mappings
- self.emptyTrash = self.projTree.emptyTrash
- self.requestDeleteItem = self.projTree.requestDeleteItem
self.getSelectedHandle = self.projTree.getSelectedHandle
return
@@ -185,10 +183,20 @@ class GuiProjectView(QWidget):
"""Check if the project tree has focus."""
return self.projTree.hasFocus()
+ def connectMenuActions(self, rename: QAction, delete: QAction, trash: QAction) -> None:
+ """Main menu actions passed to the project tree."""
+ self.projTree.addAction(rename)
+ self.projTree.addAction(delete)
+ self.projTree.addAction(trash)
+ rename.triggered.connect(self.renameTreeItem)
+ delete.triggered.connect(self.projTree.moveItemsToTrash)
+ return
+
##
# Public Slots
##
+ @pyqtSlot()
@pyqtSlot(str, str)
def renameTreeItem(self, tHandle: str | None = None, name: str = "") -> None:
"""External request to rename an item or the currently selected
@@ -806,6 +814,20 @@ class GuiProjectTree(QTreeView):
self.setCurrentIndex(model.indexFromNode(child))
return
+ @pyqtSlot()
+ def moveItemsToTrash(self, askFirst: bool = True) -> None:
+ """Move selected items to Trash."""
+ if (
+ (items := self._selectedRows())
+ and (model := self._getModel())
+ and (trashNode := SHARED.project.tree.trash)
+ ):
+ if askFirst and not SHARED.question(self.tr("Move selected items to Trash?")):
+ logger.info("Action cancelled by user")
+ return
+ model.multiMove(items, model.indexFromNode(trashNode))
+ return
+
##
# Private Slots
##
@@ -835,6 +857,10 @@ class GuiProjectTree(QTreeView):
# Internal Functions
##
+ def _selectedRows(self) -> list[QModelIndex]:
+ """Return all column 0 indexes."""
+ return [i for i in self.selectedIndexes() if i.column() == 0]
+
def _getModel(self) -> ProjectModel | None:
"""Return a project node corresponding to a model index."""
if isinstance(model := self.model(), ProjectModel):
@@ -943,54 +969,6 @@ class GuiProjectTree(QTreeView):
return True
- def moveItemToTrash(self, tHandle: str, askFirst: bool = True, flush: bool = True) -> bool:
- """Move an item to Trash. Root folders cannot be moved to Trash,
- so such a request is cancelled.
- """
- # trItemS = self._getTreeItem(tHandle)
- # nwItemS = SHARED.project.tree[tHandle]
-
- # if trItemS is None or nwItemS is None:
- # logger.error("Could not find tree item for deletion")
- # return False
-
- # if SHARED.project.tree.isTrash(tHandle):
- # logger.error("Item is already in the Trash folder")
- # return False
-
- # if nwItemS.isRootType():
- # logger.error("Root folders cannot be moved to Trash")
- # return False
-
- # logger.debug("User requested file or folder '%s' move to Trash", tHandle)
-
- # trItemP = trItemS.parent()
- # trItemT = self._addTrashRoot()
- # if trItemP is None or trItemT is None:
- # logger.error("Could not delete item")
- # return False
-
- # if askFirst:
- # msgYes = SHARED.question(
- # self.tr("Move '{0}' to Trash?").format(nwItemS.itemName)
- # )
- # if not msgYes:
- # logger.info("Action cancelled by user")
- # return False
-
- # self.propagateCount(tHandle, 0)
-
- # tIndex = trItemP.indexOfChild(trItemS)
- # trItemC = trItemP.takeChild(tIndex)
- # trItemT.addChild(trItemC)
-
- # self._postItemMove(tHandle)
- # self._alertTreeChange(tHandle, flush=flush)
-
- # logger.debug("Moved item '%s' to Trash", tHandle)
-
- return True
-
def permDeleteItem(self, tHandle: str, askFirst: bool = True, flush: bool = True) -> bool:
"""Permanently delete a tree item from the project and the map.
Root items are handled a little different than other items.
From 54685c81844058172e40a23279188c64797807e2 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 19 Nov 2024 23:26:01 +0100
Subject: [PATCH 12/39] Reimplement item deletion
---
novelwriter/core/coretools.py | 4 +-
novelwriter/core/itemmodel.py | 16 ++
novelwriter/core/project.py | 18 +-
novelwriter/core/tree.py | 41 ++--
novelwriter/gui/projtree.py | 343 +++-------------------------------
5 files changed, 69 insertions(+), 353 deletions(-)
diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py
index e2bdd342..fce179ab 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -521,10 +521,8 @@ class ProjectBuilder:
f"%Short: {bfNote}\n\n"
)
- # Also add the archive and trash folders
+ # Also add the archive folder
project.newRoot(nwItemClass.ARCHIVE)
- project.trashFolder()
-
project.saveProject()
project.closeProject()
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 69578a4d..182ea296 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -34,6 +34,7 @@ from PyQt5.QtGui import QIcon
from novelwriter import SHARED
from novelwriter.common import minmax
from novelwriter.core.item import NWItem
+from novelwriter.enum import nwItemClass
from novelwriter.types import QtAlignRight
if TYPE_CHECKING: # pragma: no cover
@@ -214,6 +215,7 @@ class ProjectNode:
if self._parent:
child.item.setParent(self.item.itemHandle)
child.item.setRoot(self.item.itemRoot)
+ child.item.setClassDefaults(self.item.itemClass)
else:
child.item.setParent(None)
child.item.setRoot(child.item.itemHandle)
@@ -290,6 +292,10 @@ class ProjectModel(QAbstractItemModel):
return index.internalPointer()
return None
+ def nodes(self, indices: list[QModelIndex]) -> list[ProjectNode]:
+ """Return the nodes for a list of model indices."""
+ return [i.internalPointer() for i in indices if i.isValid() and i.column() == 0]
+
def indexFromHandle(self, handle: str | None) -> QModelIndex:
"""Get the index representing a node in the model."""
if handle and (node := self._tree.nodes.get(handle)):
@@ -342,6 +348,16 @@ class ProjectModel(QAbstractItemModel):
self.endMoveRows()
return
+ def trashSelection(self, indices: list[QModelIndex]) -> bool:
+ """Check if a selection of indices are all in trash or not."""
+ for index in indices:
+ if index.isValid():
+ node: ProjectNode = index.internalPointer()
+ print(node)
+ if node.item.itemClass != nwItemClass.TRASH:
+ return False
+ return True
+
def multiMove(self, indices: list[QModelIndex], target: QModelIndex) -> None:
"""Move multiple items to a new location."""
if target.isValid():
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index ab960fe8..cc51ce35 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -154,7 +154,7 @@ class NWProject:
# Item Methods
##
- def newRoot(self, itemClass: nwItemClass, pos: int = -1) -> str | None:
+ def newRoot(self, itemClass: nwItemClass, pos: int = -1) -> str:
"""Add a new root folder to the project. If label is not set,
use the class label.
"""
@@ -218,27 +218,19 @@ class NWProject:
project entry and a document file if it exists.
"""
if self._tree.checkType(tHandle, nwItemType.FILE):
- delDoc = self._storage.getDocument(tHandle)
- if not delDoc.deleteDocument():
+ doc = self._storage.getDocument(tHandle)
+ if not doc.deleteDocument():
SHARED.error(
self.tr("Could not delete document file."),
- info=delDoc.getError()
+ info=doc.getError()
)
return False
self._index.deleteHandle(tHandle)
- del self._tree[tHandle]
+ self._tree.remove(tHandle)
return True
- def trashFolder(self) -> str:
- """Add the special trash root folder to the project."""
- # trashHandle = self._tree.trashRoot
- # if trashHandle is None:
- # label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
- # return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH)
- return ""
-
##
# Project Methods
##
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index d6d3a082..75b82818 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -28,7 +28,7 @@ import random
from collections.abc import Iterable, Iterator
from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal, overload
from PyQt5.QtCore import QModelIndex
@@ -141,6 +141,29 @@ class NWTree:
return False
return True
+ def remove(self, tHandle: str) -> bool:
+ """Remove an item from the project tree."""
+ if (node := self._nodes.get(tHandle)) and tHandle in self._items:
+ index = self._model.indexFromNode(node)
+ if index.isValid() and self._model.removeChild(index.parent(), index.row()):
+ del self._nodes[tHandle]
+ del self._items[tHandle]
+ return True
+
+ @overload # pragma: no cover
+ def create(
+ self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
+ itemClass: nwItemClass, pos: int = -1
+ ) -> str:
+ pass
+
+ @overload # pragma: no cover
+ def create(
+ self, label: str, parent: str | None, itemType: nwItemType,
+ itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1
+ ) -> str | None:
+ pass
+
def create(
self, label: str, parent: str | None, itemType: nwItemType,
itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1,
@@ -431,22 +454,6 @@ class NWTree:
logger.error("No tree item with handle '%s'", str(tHandle))
return None
- def __delitem__(self, tHandle: str) -> None:
- """Remove an item from the internal lists and dictionaries."""
- # if tHandle in self._order and tHandle in self._tree:
- # self._order.remove(tHandle)
- # del self._tree[tHandle]
- # else:
- # logger.warning("Failed to delete item '%s': item not found", tHandle)
- # return
-
- # if tHandle in self._roots:
- # del self._roots[tHandle]
- # if tHandle == self._trash:
- # self._trash = None
-
- return
-
def __contains__(self, tHandle: str) -> bool:
"""Checks if a handle exists in the tree."""
return tHandle in self._items
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 0b74a983..ff725f80 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -34,7 +34,7 @@ from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QIcon, QMouseEvent, QPalette
from PyQt5.QtWidgets import (
QAbstractItemView, QAction, QFrame, QHBoxLayout, QHeaderView, QLabel,
- QMenu, QShortcut, QTreeView, QTreeWidgetItem, QVBoxLayout, QWidget
+ QMenu, QShortcut, QTreeView, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
@@ -61,7 +61,6 @@ class GuiProjectView(QWidget):
"""
# Signals triggered when the meta data values of items change
- # treeItemChanged = pyqtSignal(str)
rootFolderChanged = pyqtSignal(str)
wordCountsChanged = pyqtSignal()
@@ -189,7 +188,7 @@ class GuiProjectView(QWidget):
self.projTree.addAction(delete)
self.projTree.addAction(trash)
rename.triggered.connect(self.renameTreeItem)
- delete.triggered.connect(self.projTree.moveItemsToTrash)
+ delete.triggered.connect(self.projTree.processDeleteRequest)
return
##
@@ -815,17 +814,29 @@ class GuiProjectTree(QTreeView):
return
@pyqtSlot()
- def moveItemsToTrash(self, askFirst: bool = True) -> None:
+ def processDeleteRequest(self, askFirst: bool = True) -> None:
"""Move selected items to Trash."""
- if (
- (items := self._selectedRows())
- and (model := self._getModel())
- and (trashNode := SHARED.project.tree.trash)
- ):
- if askFirst and not SHARED.question(self.tr("Move selected items to Trash?")):
- logger.info("Action cancelled by user")
+ if (items := self._selectedRows()) and (model := self._getModel()):
+ if len(items) == 1 and (node := model.node(items[0])) and node.item.isRootType():
+ if node.childCount() == 0:
+ SHARED.project.removeItem(node.item.itemHandle)
+ else:
+ SHARED.error(self.tr("Root folders can only be deleted when they are empty."))
return
- model.multiMove(items, model.indexFromNode(trashNode))
+
+ if model.trashSelection(items):
+ if askFirst:
+ if not SHARED.question(self.tr("Permanently delete selected item(s)?")):
+ logger.info("Action cancelled by user")
+ return
+ for node in model.nodes(items):
+ SHARED.project.removeItem(node.item.itemHandle)
+
+ elif trashNode := SHARED.project.tree.trash:
+ if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")):
+ logger.info("Action cancelled by user")
+ return
+ model.multiMove(items, model.indexFromNode(trashNode))
return
##
@@ -890,44 +901,6 @@ class GuiProjectTree(QTreeView):
# self.revealNewTreeItem(tHandle, wordCount=True)
return
- def requestDeleteItem(self, tHandle: str | None = None) -> bool:
- """Request an item deleted from the project tree. This function
- can be called on any item, and will check whether to attempt a
- permanent deletion or moving the item to Trash.
- """
- # if not SHARED.hasProject:
- # logger.error("No project open")
- # return False
-
- # if not self.hasFocus():
- # logger.info("Delete action blocked due to no widget focus")
- # return False
-
- # if tHandle is None:
- # tHandle = self.getSelectedHandle()
-
- # if tHandle is None:
- # logger.error("There is no item to delete")
- # return False
-
- # trashHandle = SHARED.project.tree.trashRoot
- # if tHandle == trashHandle:
- # logger.error("Cannot delete the Trash folder")
- # return False
-
- # nwItem = SHARED.project.tree[tHandle]
- # if nwItem is None:
- # return False
-
- # if SHARED.project.tree.isTrash(tHandle) or nwItem.isRootType():
- # status = self.permDeleteItem(tHandle)
- # else:
- # status = self.moveItemToTrash(tHandle)
-
- # return status
-
- return False
-
@pyqtSlot()
def emptyTrash(self) -> bool:
"""Permanently delete all documents in the Trash folder. This
@@ -969,68 +942,6 @@ class GuiProjectTree(QTreeView):
return True
- def permDeleteItem(self, tHandle: str, askFirst: bool = True, flush: bool = True) -> bool:
- """Permanently delete a tree item from the project and the map.
- Root items are handled a little different than other items.
- """
- # trItemS = self._getTreeItem(tHandle)
- # nwItemS = SHARED.project.tree[tHandle]
- # if trItemS is None or nwItemS is None:
- # logger.error("Could not find tree item for deletion")
- # return False
-
- # if nwItemS.isRootType():
- # # Only an empty ROOT folder can be deleted
- # if trItemS.childCount() > 0:
- # SHARED.error(self.tr("Root folders can only be deleted when they are empty."))
- # return False
-
- # logger.debug("Permanently deleting root folder '%s'", tHandle)
-
- # tIndex = self.indexOfTopLevelItem(trItemS)
- # self.takeTopLevelItem(tIndex)
- # SHARED.project.removeItem(tHandle)
- # self._treeMap.pop(tHandle, None)
- # self._alertTreeChange(tHandle, flush=True)
-
- # # These are not emitted by the alert function because the
- # # item has already been deleted
- # self.projView.rootFolderChanged.emit(tHandle)
- # self.projView.treeItemChanged.emit(tHandle)
-
- # else:
- # if askFirst:
- # msgYes = SHARED.question(
- # self.tr("Permanently delete '{0}'?").format(nwItemS.itemName)
- # )
- # if not msgYes:
- # logger.info("Action cancelled by user")
- # return False
-
- # logger.debug("Permanently deleting item '%s'", tHandle)
-
- # self.propagateCount(tHandle, 0)
-
- # # If a non-root item ends up on root due to a bug, allow it
- # # to still be deleted from invisible root (see #2108)
- # trItemP = trItemS.parent() or self.invisibleRootItem()
- # tIndex = trItemP.indexOfChild(trItemS)
- # trItemP.takeChild(tIndex)
-
- # for dHandle in reversed(self.getTreeFromHandle(tHandle)):
- # SHARED.closeEditor(dHandle)
- # SHARED.project.removeItem(dHandle)
- # self._treeMap.pop(dHandle, None)
-
- # self._alertTreeChange(tHandle, flush=flush)
- # self.projView.wordCountsChanged.emit()
-
- # # This is not emitted by the alert function because the item
- # # has already been deleted
- # self.projView.treeItemChanged.emit(tHandle)
-
- return True
-
def refreshUserLabels(self, kind: str) -> None:
"""Refresh status or importance labels."""
# if kind == "s":
@@ -1119,22 +1030,6 @@ class GuiProjectTree(QTreeView):
return
- # def getSelectedHandle(self) -> str | None:
- # """Get the currently selected handle. If multiple items are
- # selected, return the first.
- # """
- # # if items := self.selectedItems():
- # # return items[0].data(self.C_DATA, self.D_HANDLE)
- # return None
-
- # def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> None:
- # """Set a specific handle as the selected item."""
- # # if tHandle in self._treeMap:
- # # self.setCurrentItem(self._treeMap[tHandle])
- # # if (indexes := self.selectedIndexes()) and doScroll:
- # # self.scrollTo(indexes[0], QAbstractItemView.ScrollHint.PositionAtCenter)
- # return
-
def setActiveHandle(self, tHandle: str | None) -> None:
"""Highlight the rows associated with a given handle."""
# brushOn = self.palette().alternateBase()
@@ -1204,36 +1099,6 @@ class GuiProjectTree(QTreeView):
# Private Slots
##
- # @pyqtSlot(QModelIndex)
- # def _treeSingleClick(self, index: QModelIndex) -> None:
- # """The user changed which item is selected."""
- # if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
- # self.projView.selectedItemChanged.emit(node.item.itemHandle)
-
- # # # When selecting multiple items, don't allow including root
- # # # items in the selection and instead deselect them
- # # items = self.selectedItems()
- # # if items and len(items) > 1:
- # # for item in items:
- # # if item.parent() is None:
- # # item.setSelected(False)
-
- # return
-
- # @pyqtSlot(QModelIndex)
- # def _treeDoubleClick(self, index: QModelIndex) -> None:
- # """Capture a double-click event and either request the document
- # for editing if it is a file, or expand/close the node if not.
- # """
- # if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
- # if node.item.isFileType():
- # self.projView.openDocumentRequest.emit(
- # node.item.itemHandle, nwDocMode.EDIT, "", True
- # )
- # else:
- # self.setExpanded(index, not self.isExpanded(index))
- # return
-
@pyqtSlot()
def _doAutoScroll(self) -> None:
"""Scroll one item up or down based on direction value."""
@@ -1249,24 +1114,6 @@ class GuiProjectTree(QTreeView):
# Events
##
- # def mousePressEvent(self, event: QMouseEvent) -> None:
- # """Overload mousePressEvent to clear selection if clicking the
- # mouse in a blank area of the tree view, and to load a document
- # for viewing if the user middle-clicked.
- # """
- # super().mousePressEvent(event)
- # if event.button() == QtMouseLeft:
- # selItem = self.indexAt(event.pos())
- # if not selItem.isValid():
- # self.clearSelection()
- # elif event.button() == QtMouseMiddle:
- # selItem = self.itemAt(event.pos())
- # if selItem:
- # tHandle = selItem.data(self.C_DATA, self.D_HANDLE)
- # if (tItem := SHARED.project.tree[tHandle]) and tItem.isFileType():
- # self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False)
- # return
-
# def startDrag(self, dropAction: Qt.DropAction) -> None:
# """Capture the drag and drop handling to pop alerts."""
# super().startDrag(dropAction)
@@ -1343,38 +1190,6 @@ class GuiProjectTree(QTreeView):
# Internal Functions
##
- def _postItemMove(self, tHandle: str) -> None:
- """Run various maintenance tasks for a moved item."""
- # trItemS = self._getTreeItem(tHandle)
- # nwItemS = SHARED.project.tree[tHandle]
- # trItemP = trItemS.parent() if trItemS else None
- # if trItemP is None or nwItemS is None:
- # logger.error("Failed to find new parent item of '%s'", tHandle)
- # return
-
- # # Update item parent handle in the project
- # pHandle = trItemP.data(self.C_DATA, self.D_HANDLE)
- # nwItemS.setParent(pHandle)
- # trItemP.setExpanded(True)
- # logger.debug("The parent of item '%s' has been changed to '%s'", tHandle, pHandle)
-
- # mHandles = self.getTreeFromHandle(tHandle)
- # logger.debug("A total of %d item(s) were moved", len(mHandles))
- # for mHandle in mHandles:
- # logger.debug("Updating item '%s'", mHandle)
- # SHARED.project.tree.updateItemData(mHandle)
- # if nwItemS.isInactiveClass():
- # SHARED.project.index.deleteHandle(mHandle)
- # else:
- # SHARED.project.index.reIndexHandle(mHandle)
- # if mItem := SHARED.project.tree[mHandle]:
- # self.setTreeItemValues(mItem)
-
- # # Update word count
- # self.propagateCount(tHandle, nwItemS.wordCount, countChildren=True)
-
- return
-
def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
"""Merge an item's child documents into a single document."""
# logger.info("Request to merge items under handle '%s'", tHandle)
@@ -1523,118 +1338,6 @@ class GuiProjectTree(QTreeView):
return True
- def _scanChildren(self, itemList: list, tItem: QTreeWidgetItem, tIndex: int) -> list[str]:
- """This is a recursive function returning all items in a tree
- starting at a given QTreeWidgetItem.
- """
- # tHandle = tItem.data(self.C_DATA, self.D_HANDLE)
- # cCount = tItem.childCount()
-
- # # Update tree-related meta data
- # nwItem = SHARED.project.tree[tHandle]
- # if nwItem is not None:
- # nwItem.setExpanded(tItem.isExpanded() and cCount > 0)
- # nwItem.setOrder(tIndex)
-
- # itemList.append(tHandle)
- # for i in range(cCount):
- # self._scanChildren(itemList, tItem.child(i), i)
-
- return itemList
-
- def _addTreeItem(self, nwItem: NWItem | None,
- nHandle: str | None = None) -> QTreeWidgetItem | None:
- """Create a QTreeWidgetItem from an NWItem and add it to the
- project tree. Returns the widget if the item is valid, otherwise
- a None is returned.
- """
- # if not nwItem:
- # logger.error("Invalid item cannot be added to project tree")
- # return None
-
- # tHandle = nwItem.itemHandle
- # pHandle = nwItem.itemParent
- # newItem = QTreeWidgetItem()
-
- # newItem.setText(self.C_NAME, "")
- # newItem.setText(self.C_COUNT, "0")
- # newItem.setText(self.C_ACTIVE, "")
- # newItem.setText(self.C_STATUS, "")
-
- # newItem.setTextAlignment(self.C_NAME, QtAlignLeft)
- # newItem.setTextAlignment(self.C_COUNT, QtAlignRight)
- # newItem.setTextAlignment(self.C_ACTIVE, QtAlignLeft)
- # newItem.setTextAlignment(self.C_STATUS, QtAlignLeft)
-
- # newItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
- # newItem.setData(self.C_DATA, self.D_WORDS, 0)
-
- # if pHandle is None and nwItem.isRootType():
- # pItem = self.invisibleRootItem()
- # elif pHandle and pHandle in self._treeMap:
- # pItem = self._treeMap[pHandle]
- # else:
- # SHARED.error(self.tr(
- # "There is nowhere to add item with name '{0}'."
- # ).format(nwItem.itemName))
- # return None
-
- # byIndex = -1
- # if nHandle is not None and nHandle in self._treeMap:
- # byIndex = pItem.indexOfChild(self._treeMap[nHandle])
- # if byIndex >= 0:
- # pItem.insertChild(byIndex + 1, newItem)
- # else:
- # pItem.addChild(newItem)
-
- # self._treeMap[tHandle] = newItem
- # self.propagateCount(tHandle, nwItem.wordCount, countChildren=True)
- # self.setTreeItemValues(nwItem)
- # newItem.setExpanded(nwItem.isExpanded)
-
- # return newItem
-
- return None
-
- def _addTrashRoot(self) -> QTreeWidgetItem | None:
- """Adds the trash root folder if it doesn't already exist in the
- project tree.
- """
- # trashHandle = SHARED.project.trashFolder()
- # if trashHandle is None:
- # return None
-
- # trItem = self._getTreeItem(trashHandle)
- # if trItem is None:
- # trItem = self._addTreeItem(SHARED.project.tree[trashHandle])
- # if trItem is not None:
- # trItem.setExpanded(True)
- # self._alertTreeChange(trashHandle, flush=True)
-
- # return trItem
-
- return None
-
- def _alertTreeChange(self, tHandle: str | None, flush: bool = False) -> None:
- """Update information on tree change state, and emit necessary
- signals. A flush is only needed if an item is moved, created or
- deleted.
- """
- # SHARED.project.setProjectChanged(True)
- # if flush:
- # self.saveTreeOrder()
-
- # if tHandle is None or tHandle not in SHARED.project.tree:
- # return
-
- # tItem = SHARED.project.tree[tHandle]
- # if tItem and tItem.isRootType():
- # self.projView.rootFolderChanged.emit(tHandle)
-
- # self.projView.treeItemChanged.emit(tHandle)
-
- return
-
class _UpdatableMenu(QMenu):
From 72f1bc2d7752264fb5ea5263b05a14ab6208b054 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 20 Nov 2024 01:31:56 +0100
Subject: [PATCH 13/39] Add model support for drag and drop
---
novelwriter/constants.py | 3 ++
novelwriter/core/itemmodel.py | 76 +++++++++++++++++++++++++++++++----
novelwriter/gui/projtree.py | 13 +++---
3 files changed, 78 insertions(+), 14 deletions(-)
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index dc4a44eb..237b715b 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -53,6 +53,9 @@ class nwConst:
# Requests
USER_AGENT = "Mozilla/5.0 (compatible; novelWriter (Python))"
+ # Mime Types
+ MIME_HANDLE = "text/vnd.novelwriter.handle"
+
# Gui Settings
STATUS_MSG_TIMEOUT = 15000 # milliseconds
MAX_SEARCH_RESULT = 1000
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 182ea296..c29abbc3 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -28,11 +28,12 @@ import logging
from typing import TYPE_CHECKING
-from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt
+from PyQt5.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt
from PyQt5.QtGui import QIcon
from novelwriter import SHARED
from novelwriter.common import minmax
+from novelwriter.constants import nwConst
from novelwriter.core.item import NWItem
from novelwriter.enum import nwItemClass
from novelwriter.types import QtAlignRight
@@ -54,12 +55,16 @@ C_ACTIVE_TIP = 0x0200 | Qt.ItemDataRole.ToolTipRole
C_STATUS_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole
C_STATUS_TIP = 0x0300 | Qt.ItemDataRole.ToolTipRole
+NODE_FLAGS = Qt.ItemFlag.ItemIsEnabled
+NODE_FLAGS |= Qt.ItemFlag.ItemIsSelectable
+NODE_FLAGS |= Qt.ItemFlag.ItemIsDropEnabled
+
T_NodeData = str | QIcon | Qt.AlignmentFlag | None
class ProjectNode:
- __slots__ = ("_item", "_children", "_parent", "_row", "_cache", "_count")
+ __slots__ = ("_item", "_children", "_parent", "_row", "_cache", "_flags", "_count")
def __init__(self, item: NWItem) -> None:
self._item = item
@@ -67,6 +72,7 @@ class ProjectNode:
self._parent: ProjectNode | None = None
self._row = 0
self._cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {}
+ self._flags = NODE_FLAGS
self.refresh()
return
@@ -145,6 +151,10 @@ class ProjectNode:
"""Return cached node data."""
return self._cache.get(COL_MASK*column | role)
+ def flags(self) -> Qt.ItemFlag:
+ """Return cached node flags."""
+ return self._flags
+
def parent(self) -> ProjectNode | None:
return self._parent
@@ -213,9 +223,10 @@ class ProjectNode:
def _updateRelationships(self, child: ProjectNode) -> None:
"""Update a child item's relationships."""
if self._parent:
- child.item.setParent(self.item.itemHandle)
- child.item.setRoot(self.item.itemRoot)
- child.item.setClassDefaults(self.item.itemClass)
+ child.item.setParent(self._item.itemHandle)
+ child.item.setRoot(self._item.itemRoot)
+ child.item.setClassDefaults(self._item.itemClass)
+ child._flags |= Qt.ItemFlag.ItemIsDragEnabled
else:
child.item.setParent(None)
child.item.setRoot(child.item.itemHandle)
@@ -230,6 +241,7 @@ class ProjectModel(QAbstractItemModel):
super().__init__()
self._tree = tree
self._root = ProjectNode(NWItem(tree._project, "invisibleRoot"))
+ self._root.item.setName("Invisible Root")
logger.debug("Ready: ProjectModel")
return
@@ -276,6 +288,55 @@ class ProjectModel(QAbstractItemModel):
return index.internalPointer().data(index.column(), role)
return None
+ def flags(self, index: QModelIndex) -> Qt.ItemFlag:
+ """Return flags for a project node."""
+ if index.isValid():
+ return index.internalPointer().flags()
+ return Qt.ItemFlag.NoItemFlags
+
+ ##
+ # Drag and Drop
+ ##
+
+ def supportedDropActions(self) -> Qt.DropAction:
+ """Return supported drop actions"""
+ return Qt.DropAction.MoveAction
+
+ def mimeTypes(self) -> list[str]:
+ """Return the supported mime types of the model."""
+ return [nwConst.MIME_HANDLE]
+
+ def mimeData(self, indices: list[QModelIndex]) -> QMimeData:
+ """Encode mime data about a selection."""
+ handles = [
+ i.internalPointer().item.itemHandle.encode()
+ for i in indices if i.isValid() and i.column() == 0
+ ]
+ mime = QMimeData()
+ mime.setData(nwConst.MIME_HANDLE, b"|".join(handles))
+ return mime
+
+ def canDropMimeData(
+ self, data: QMimeData, action: Qt.DropAction,
+ row: int, column: int, parent: QModelIndex
+ ) -> bool:
+ """Check if mime data can be dropped on the current location."""
+ return data.hasFormat(nwConst.MIME_HANDLE) and action == Qt.DropAction.MoveAction
+
+ def dropMimeData(
+ self, data: QMimeData, action: Qt.DropAction,
+ row: int, column: int, parent: QModelIndex
+ ) -> bool:
+ """Process mime data drop."""
+ if self.canDropMimeData(data, action, row, column, parent):
+ items = []
+ for handle in data.data(nwConst.MIME_HANDLE).data().decode().split("|"):
+ if (index := self.indexFromHandle(handle)).isValid():
+ items.append(index)
+ self.multiMove(items, parent, row)
+ return True
+ return False
+
##
# Data Access
##
@@ -353,12 +414,11 @@ class ProjectModel(QAbstractItemModel):
for index in indices:
if index.isValid():
node: ProjectNode = index.internalPointer()
- print(node)
if node.item.itemClass != nwItemClass.TRASH:
return False
return True
- def multiMove(self, indices: list[QModelIndex], target: QModelIndex) -> None:
+ def multiMove(self, indices: list[QModelIndex], target: QModelIndex, pos: int = -1) -> None:
"""Move multiple items to a new location."""
if target.isValid():
# This is a two pass process. First we only select unique
@@ -379,7 +439,7 @@ class ProjectModel(QAbstractItemModel):
if node.item.itemParent not in handles:
index = self.indexFromNode(node)
if child := self.removeChild(index.parent(), index.row()):
- self.insertChild(child, target, -1)
+ self.insertChild(child, target, pos)
return
##
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index ff725f80..f029647e 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -3,10 +3,11 @@ novelWriter – GUI Project Tree
==============================
File History:
-Created: 2018-09-29 [0.0.1] GuiProjectTree
-Created: 2022-06-06 [2.0rc1] GuiProjectView
-Created: 2022-06-06 [2.0rc1] GuiProjectToolBar
-Created: 2023-11-22 [2.2rc1] _TreeContextMenu
+Created: 2018-09-29 [0.0.1] GuiProjectTree
+Created: 2022-06-06 [2.0rc1] GuiProjectView
+Created: 2022-06-06 [2.0rc1] GuiProjectToolBar
+Created: 2023-11-22 [2.2rc1] _TreeContextMenu
+Rewritten: 2024-11-17 [2.7b1] GuiProjectTree
This file is a part of novelWriter
Copyright 2018–2024, Veronica Berglyd Olsen
@@ -543,8 +544,8 @@ class GuiProjectTree(QTreeView):
self.setIndentation(iPx)
# Allow Move by Drag & Drop
- # self.setDragEnabled(True)
- # self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
+ self.setDragEnabled(True)
+ self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
# Disable built-in auto scroll as it isn't working in some Qt
# releases (see #1561) and instead use our own implementation
From 5dcfe5c874dc8474434d41c1a5c9e0fcd8117557 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 20 Nov 2024 02:12:28 +0100
Subject: [PATCH 14/39] Allow drag and drop to open documents
---
novelwriter/common.py | 7 +++-
novelwriter/core/itemmodel.py | 5 ++-
novelwriter/gui/doceditor.py | 35 ++++++++++++++--
novelwriter/gui/docviewer.py | 35 ++++++++++++++--
novelwriter/gui/projtree.py | 76 -----------------------------------
novelwriter/guimain.py | 1 +
6 files changed, 74 insertions(+), 85 deletions(-)
diff --git a/novelwriter/common.py b/novelwriter/common.py
index 92b304e7..64a4de1b 100644
--- a/novelwriter/common.py
+++ b/novelwriter/common.py
@@ -37,7 +37,7 @@ from typing import TYPE_CHECKING, Any, Literal, TypeVar
from urllib.parse import urljoin
from urllib.request import pathname2url
-from PyQt5.QtCore import QCoreApplication, QUrl
+from PyQt5.QtCore import QCoreApplication, QMimeData, QUrl
from PyQt5.QtGui import QColor, QDesktopServices, QFont, QFontInfo
from novelwriter.constants import nwConst, nwLabels, nwUnicode, trConst
@@ -441,6 +441,11 @@ def qtLambda(func: Callable, *args: Any, **kwargs: Any) -> Callable:
return wrapper
+def decodeMimeHandles(mimeData: QMimeData) -> list[str]:
+ """Decode and split a mime data object with handles."""
+ return mimeData.data(nwConst.MIME_HANDLE).data().decode().split("|")
+
+
##
# Encoder Functions
##
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index c29abbc3..87795cb2 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -32,7 +32,7 @@ from PyQt5.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt
from PyQt5.QtGui import QIcon
from novelwriter import SHARED
-from novelwriter.common import minmax
+from novelwriter.common import decodeMimeHandles, minmax
from novelwriter.constants import nwConst
from novelwriter.core.item import NWItem
from novelwriter.enum import nwItemClass
@@ -191,6 +191,7 @@ class ProjectNode:
if 0 <= pos < len(self._children):
node = self._children.pop(pos)
self._refreshChildrenPos()
+ self.updateCount()
return node
return None
@@ -330,7 +331,7 @@ class ProjectModel(QAbstractItemModel):
"""Process mime data drop."""
if self.canDropMimeData(data, action, row, column, parent):
items = []
- for handle in data.data(nwConst.MIME_HANDLE).data().decode().split("|"):
+ for handle in decodeMimeHandles(data):
if (index := self.indexFromHandle(handle)).isValid():
items.append(index)
self.multiMove(items, parent, row)
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index 3ddd7731..c0b9a4c6 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -43,8 +43,9 @@ from PyQt5.QtCore import (
pyqtSlot
)
from PyQt5.QtGui import (
- QColor, QCursor, QKeyEvent, QKeySequence, QMouseEvent, QPalette, QPixmap,
- QResizeEvent, QTextBlock, QTextCursor, QTextDocument, QTextOption
+ QColor, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent, QKeyEvent,
+ QKeySequence, QMouseEvent, QPalette, QPixmap, QResizeEvent, QTextBlock,
+ QTextCursor, QTextDocument, QTextOption
)
from PyQt5.QtWidgets import (
QAction, QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit,
@@ -52,7 +53,7 @@ from PyQt5.QtWidgets import (
)
from novelwriter import CONFIG, SHARED
-from novelwriter.common import minmax, qtLambda, transferCase
+from novelwriter.common import decodeMimeHandles, minmax, qtLambda, transferCase
from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
from novelwriter.core.document import NWDocument
from novelwriter.enum import (
@@ -116,6 +117,7 @@ class GuiDocEditor(QPlainTextEdit):
loadDocumentTagRequest = pyqtSignal(str, Enum)
novelItemMetaChanged = pyqtSignal(str)
novelStructureChanged = pyqtSignal()
+ openDocumentRequest = pyqtSignal(str, Enum, str, bool)
requestNewNoteCreation = pyqtSignal(str, nwItemClass)
requestNextDocument = pyqtSignal(str, bool)
requestProjectItemRenamed = pyqtSignal(str, str)
@@ -191,6 +193,7 @@ class GuiDocEditor(QPlainTextEdit):
self.setMinimumWidth(CONFIG.pxInt(300))
self.setAutoFillBackground(True)
self.setFrameStyle(QFrame.Shape.NoFrame)
+ self.setAcceptDrops(True)
# Custom Shortcuts
self.keyContext = QShortcut(self)
@@ -997,6 +1000,32 @@ class GuiDocEditor(QPlainTextEdit):
return
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
+ """Overload drag enter event to handle dragged items."""
+ if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
+ event.acceptProposedAction()
+ else:
+ super().dragEnterEvent(event)
+ return
+
+ def dragMoveEvent(self, event: QDragMoveEvent) -> None:
+ """Overload drag move event to handle dragged items."""
+ if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
+ event.acceptProposedAction()
+ else:
+ super().dragMoveEvent(event)
+ return
+
+ def dropEvent(self, event: QDropEvent) -> None:
+ """Overload drop event to handle dragged items."""
+ if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
+ if handles := decodeMimeHandles(event.mimeData()):
+ if SHARED.project.tree.checkType(handles[0], nwItemType.FILE):
+ self.openDocumentRequest.emit(handles[0], nwDocMode.EDIT, "", True)
+ else:
+ super().dropEvent(event)
+ return
+
def focusNextPrevChild(self, next: bool) -> bool:
"""Capture the focus request from the tab key on the text
editor. If the editor has focus, we do not change focus and
diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py
index 6745db9d..2675eaa1 100644
--- a/novelwriter/gui/docviewer.py
+++ b/novelwriter/gui/docviewer.py
@@ -31,15 +31,18 @@ import logging
from enum import Enum
from PyQt5.QtCore import QPoint, Qt, QUrl, pyqtSignal, pyqtSlot
-from PyQt5.QtGui import QCursor, QDesktopServices, QMouseEvent, QPalette, QResizeEvent, QTextCursor
+from PyQt5.QtGui import (
+ QCursor, QDesktopServices, QDragEnterEvent, QDragMoveEvent, QDropEvent,
+ QMouseEvent, QPalette, QResizeEvent, QTextCursor
+)
from PyQt5.QtWidgets import (
QAction, QApplication, QFrame, QHBoxLayout, QMenu, QTextBrowser,
QToolButton, QWidget
)
from novelwriter import CONFIG, SHARED
-from novelwriter.common import qtLambda
-from novelwriter.constants import nwStyles, nwUnicode
+from novelwriter.common import decodeMimeHandles, qtLambda
+from novelwriter.constants import nwConst, nwStyles, nwUnicode
from novelwriter.enum import nwDocAction, nwDocMode, nwItemType
from novelwriter.error import logException
from novelwriter.extensions.configlayout import NColourLabel
@@ -445,6 +448,32 @@ class GuiDocViewer(QTextBrowser):
super().mouseReleaseEvent(event)
return
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
+ """Overload drag enter event to handle dragged items."""
+ if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
+ event.acceptProposedAction()
+ else:
+ super().dragEnterEvent(event)
+ return
+
+ def dragMoveEvent(self, event: QDragMoveEvent) -> None:
+ """Overload drag move event to handle dragged items."""
+ if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
+ event.acceptProposedAction()
+ else:
+ super().dragMoveEvent(event)
+ return
+
+ def dropEvent(self, event: QDropEvent) -> None:
+ """Overload drop event to handle dragged items."""
+ if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
+ if handles := decodeMimeHandles(event.mimeData()):
+ if SHARED.project.tree.checkType(handles[0], nwItemType.FILE):
+ self.openDocumentRequest.emit(handles[0], nwDocMode.VIEW, "", True)
+ else:
+ super().dropEvent(event)
+ return
+
##
# Internal Functions
##
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index f029647e..ab723d43 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -1111,82 +1111,6 @@ class GuiProjectTree(QTreeView):
# self._scrollTimer.stop()
return
- ##
- # Events
- ##
-
- # def startDrag(self, dropAction: Qt.DropAction) -> None:
- # """Capture the drag and drop handling to pop alerts."""
- # super().startDrag(dropAction)
- # if self._popAlert:
- # SHARED.error(self._popAlert)
- # self._popAlert = None
- # return
-
- # def dragEnterEvent(self, event: QDragEnterEvent) -> None:
- # """Check that we're only dragging items that are siblings, and
- # not a root level item.
- # """
- # items = self.selectedItems()
- # if items and (parent := items[0].parent()) and all(x.parent() is parent for x in items):
- # super().dragEnterEvent(event)
- # else:
- # logger.warning("Drag action is not allowed and has been cancelled")
- # self._popAlert = self.tr(
- # "Drag and drop is only allowed for single items, non-root "
- # "items, or multiple items with the same parent."
- # )
- # event.mimeData().clear()
- # event.ignore()
- # return
-
- # def dragMoveEvent(self, event: QDragMoveEvent) -> None:
- # """Capture the drag move event to enable edge auto scroll."""
- # y = event.pos().y()
- # if y < self._scrollMargin:
- # if not self._scrollTimer.isActive():
- # self._scrollDirection = -1
- # self._scrollTimer.start()
- # elif y > self.height() - self._scrollMargin:
- # if not self._scrollTimer.isActive():
- # self._scrollDirection = 1
- # self._scrollTimer.start()
- # super().dragMoveEvent(event)
- # return
-
- # def dropEvent(self, event: QDropEvent) -> None:
- # """Overload the drop item event to ensure the drag and drop
- # action is allowed, and update relevant data.
- # """
- # tItem = self.itemAt(event.pos())
- # onItem = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnItem
- # onView = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnViewport
- # # Make sure nothing can be dropped on invisible root (see #1569)
- # # Make sure nothing can be dropped on the viewport (see #2108)
- # if tItem is None or (tItem.parent() is None and not onItem) or onView:
- # logger.error("Invalid drop location")
- # event.ignore()
- # return
-
- # mItems: dict[str, tuple[QTreeWidgetItem, bool]] = {}
- # sItems = self.selectedItems()
- # if sItems and (parent := sItems[0].parent()) and all(x.parent() is parent for x in sItems):
- # for sItem in sItems:
- # mHandle = str(sItem.data(self.C_DATA, self.D_HANDLE))
- # mItems[mHandle] = (sItem, sItem.isExpanded())
- # self.propagateCount(mHandle, 0)
-
- # super().dropEvent(event)
-
- # for mHandle, (sItem, isExpanded) in mItems.items():
- # self._postItemMove(mHandle)
- # sItem.setExpanded(isExpanded)
- # self._alertTreeChange(mHandle, flush=False)
-
- # self.saveTreeOrder()
-
- # return
-
##
# Internal Functions
##
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index fc7cd532..adda022d 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -259,6 +259,7 @@ class GuiMain(QMainWindow):
self.docEditor.loadDocumentTagRequest.connect(self._followTag)
self.docEditor.novelItemMetaChanged.connect(self.novelView.updateNovelItemMeta)
self.docEditor.novelStructureChanged.connect(self.novelView.refreshTree)
+ self.docEditor.openDocumentRequest.connect(self._openDocument)
self.docEditor.requestNewNoteCreation.connect(self.projView.createNewNote)
self.docEditor.requestNextDocument.connect(self.openNextDocument)
self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem)
From ec01336c212dd7c9733812da0e13991e657aee31 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 20 Nov 2024 13:06:49 +0100
Subject: [PATCH 15/39] Fix active status icon and tooltip
---
novelwriter/constants.py | 5 +++++
novelwriter/core/item.py | 8 +++++++
novelwriter/core/itemmodel.py | 12 ++++-------
novelwriter/gui/projtree.py | 40 +++++++++++++++++------------------
novelwriter/guimain.py | 18 +++++++++++++---
5 files changed, 52 insertions(+), 31 deletions(-)
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index 237b715b..1477ddbd 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -274,6 +274,11 @@ class nwLabels:
"doc_h4": QT_TRANSLATE_NOOP("Constant", "Novel Section"),
"note": QT_TRANSLATE_NOOP("Constant", "Project Note"),
}
+ ACTIVE_NAME = {
+ "checked": QT_TRANSLATE_NOOP("Constant", "Active"),
+ "unchecked": QT_TRANSLATE_NOOP("Constant", "Inactive"),
+ "noncheckable": QT_TRANSLATE_NOOP("Constant", "Not Available"),
+ }
KEY_NAME = {
nwKeyWords.TAG_KEY: QT_TRANSLATE_NOOP("Constant", "Tag"),
nwKeyWords.POV_KEY: QT_TRANSLATE_NOOP("Constant", "Point of View"),
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index cdfd4a38..2dcc7664 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -29,6 +29,7 @@ from typing import TYPE_CHECKING, Any
from PyQt5.QtGui import QIcon
+from novelwriter import SHARED
from novelwriter.common import (
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified,
yesNo
@@ -330,6 +331,13 @@ class NWItem:
entry = self._project.data.itemImport[self._import]
return entry.name, entry.icon
+ def getActiveStatus(self) -> tuple[str, QIcon]:
+ """Return the relevant active status label and icon for
+ the current item based on its type.
+ """
+ key = ("checked" if self._active else "unchecked") if self.isFileType() else "noncheckable"
+ return trConst(nwLabels.ACTIVE_NAME[key]), SHARED.theme.getIcon(key)
+
##
# Checker Methods
##
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 87795cb2..86cebc3a 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -113,18 +113,14 @@ class ProjectNode:
self._cache[C_COUNT_ALIGN] = QtAlignRight
# Active
- if self._item.isFileType():
- if self._item.isActive:
- self._cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("checked")
- else:
- self._cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("unchecked")
- else:
- self._cache[C_ACTIVE_ICON] = SHARED.theme.getIcon("noncheckable")
+ aText, aIcon = self._item.getActiveStatus()
+ self._cache[C_ACTIVE_TIP] = aText
+ self._cache[C_ACTIVE_ICON] = aIcon
# Status
sText, sIcon = self._item.getImportStatus()
- self._cache[C_STATUS_ICON] = sIcon
self._cache[C_STATUS_TIP] = sText
+ self._cache[C_STATUS_ICON] = sIcon
self.updateCount()
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index ab723d43..f5a30c6c 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -515,22 +515,8 @@ class GuiProjectTree(QTreeView):
self.projView = projView
# Internal Variables
- # self._treeMap: dict[str, QTreeWidgetItem] = {}
- # self._popAlert = None
# self._actHandle = None
- # Cached Translations
- self.trActive = self.tr("Active")
- self.trInactive = self.tr("Inactive")
- self.trPermDelete = self.tr("Permanently delete {0} file(s) from Trash?")
-
- # Build GUI
- # =========
-
- # Context Menu
- # self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- # self.customContextMenuRequested.connect(self.openContextMenu)
-
# Tree Settings
iPx = SHARED.theme.baseIconHeight
@@ -551,19 +537,19 @@ class GuiProjectTree(QTreeView):
# releases (see #1561) and instead use our own implementation
# self.setAutoScroll(False)
- # But don't allow drop on root level
- # Due to a bug, this stops working somewhere between Qt 5.15.3
- # and 5.15.8, so this is also blocked in dropEvent (see #1569)
- # trRoot = self.invisibleRootItem()
- # trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled)
-
# Set selection options
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ # Context Menu
+ # self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ # self.customContextMenuRequested.connect(self.openContextMenu)
+
# Connect signals
self.clicked.connect(self._onSingleClick)
self.doubleClicked.connect(self._onDoubleClick)
+ self.collapsed.connect(self._onNodeCollapsed)
+ self.expanded.connect(self._onNodeExpanded)
# Auto Scroll
# self._scrollMargin = SHARED.theme.baseIconHeight
@@ -865,6 +851,20 @@ class GuiProjectTree(QTreeView):
self.setExpanded(index, not self.isExpanded(index))
return
+ @pyqtSlot(QModelIndex)
+ def _onNodeCollapsed(self, index: QModelIndex) -> None:
+ """Capture a node collapse, and pass it to the model."""
+ if node := self._getNode(index):
+ node.item.setExpanded(False)
+ return
+
+ @pyqtSlot(QModelIndex)
+ def _onNodeExpanded(self, index: QModelIndex) -> None:
+ """Capture a node expand, and pass it to the model."""
+ if node := self._getNode(index):
+ node.item.setExpanded(True)
+ return
+
##
# Internal Functions
##
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index adda022d..2dc5aec6 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -44,7 +44,7 @@ from novelwriter.dialogs.about import GuiAbout
from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.projectsettings import GuiProjectSettings
from novelwriter.dialogs.wordlist import GuiWordList
-from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwFocus, nwView
+from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwFocus, nwItemType, nwView
from novelwriter.gui.doceditor import GuiDocEditor
from novelwriter.gui.docviewer import GuiDocViewer
from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
@@ -62,6 +62,7 @@ from novelwriter.tools.manuscript import GuiManuscript
from novelwriter.tools.noveldetails import GuiNovelDetails
from novelwriter.tools.welcome import GuiWelcome
from novelwriter.tools.writingstats import GuiWritingStats
+from novelwriter.types import QtModShift
logger = logging.getLogger(__name__)
@@ -298,10 +299,18 @@ class GuiMain(QMainWindow):
self.keyReturn.setKey("Return")
self.keyReturn.activated.connect(self._keyPressReturn)
+ self.keyShiftReturn = QShortcut(self)
+ self.keyShiftReturn.setKey("Shift+Return")
+ self.keyShiftReturn.activated.connect(self._keyPressReturn)
+
self.keyEnter = QShortcut(self)
self.keyEnter.setKey("Enter")
self.keyEnter.activated.connect(self._keyPressReturn)
+ self.keyShiftEnter = QShortcut(self)
+ self.keyShiftEnter.setKey("Shift+Enter")
+ self.keyShiftEnter.activated.connect(self._keyPressReturn)
+
self.keyEscape = QShortcut(self)
self.keyEscape.setKey("Esc")
self.keyEscape.activated.connect(self._keyPressEscape)
@@ -719,8 +728,11 @@ class GuiMain(QMainWindow):
logger.warning("No item selected")
return
- if tHandle:
- self.openDocument(tHandle, sTitle=sTitle, changeFocus=False, doScroll=False)
+ if tHandle and SHARED.project.tree.checkType(tHandle, nwItemType.FILE):
+ if QApplication.keyboardModifiers() == QtModShift:
+ self.viewDocument(tHandle)
+ else:
+ self.openDocument(tHandle, sTitle=sTitle, changeFocus=False, doScroll=False)
return
From 3f41e62e2112e8efe7a44c683ef2c1af71e38bb0 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 20 Nov 2024 16:41:26 +0100
Subject: [PATCH 16/39] Move some variables around
---
novelwriter/core/itemmodel.py | 9 +++-
novelwriter/gui/docviewerpanel.py | 18 ++++----
novelwriter/gui/noveltree.py | 18 ++++----
novelwriter/gui/projtree.py | 70 +++++-------------------------
novelwriter/gui/search.py | 13 +++---
novelwriter/tools/manussettings.py | 15 ++++---
novelwriter/types.py | 8 +++-
7 files changed, 60 insertions(+), 91 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 86cebc3a..5f59c505 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -64,6 +64,11 @@ T_NodeData = str | QIcon | Qt.AlignmentFlag | None
class ProjectNode:
+ C_NAME = 0
+ C_COUNT = 1
+ C_ACTIVE = 2
+ C_STATUS = 3
+
__slots__ = ("_item", "_children", "_parent", "_row", "_cache", "_flags", "_count")
def __init__(self, item: NWItem) -> None:
@@ -126,10 +131,10 @@ class ProjectNode:
return
- def updateCount(self) -> None:
+ def updateCount(self, propagate: bool = True) -> 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:
+ if propagate and (parent := self._parent):
parent.updateCount()
return
diff --git a/novelwriter/gui/docviewerpanel.py b/novelwriter/gui/docviewerpanel.py
index 443ba678..8529ceef 100644
--- a/novelwriter/gui/docviewerpanel.py
+++ b/novelwriter/gui/docviewerpanel.py
@@ -29,8 +29,8 @@ from enum import Enum
from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import (
- QAbstractItemView, QFrame, QHeaderView, QMenu, QTabWidget, QToolButton,
- QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
+ QAbstractItemView, QFrame, QMenu, QTabWidget, QToolButton, QTreeWidget,
+ QTreeWidgetItem, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
@@ -40,7 +40,7 @@ from novelwriter.core.index import IndexHeading, IndexItem
from novelwriter.enum import nwDocMode, nwItemClass
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_FLAT_TABS, STYLES_MIN_TOOLBUTTON
-from novelwriter.types import QtDecoration, QtUserRole
+from novelwriter.types import QtDecoration, QtHeaderFixed, QtHeaderToContents, QtUserRole
logger = logging.getLogger(__name__)
@@ -259,10 +259,10 @@ class _ViewPanelBackRefs(QTreeWidget):
treeHeader = self.header()
treeHeader.setStretchLastSection(True)
treeHeader.setMinimumSectionSize(iPx + cMg) # See Issue #1627
- treeHeader.setSectionResizeMode(self.C_DOC, QHeaderView.ResizeMode.ResizeToContents)
- treeHeader.setSectionResizeMode(self.C_EDIT, QHeaderView.ResizeMode.Fixed)
- treeHeader.setSectionResizeMode(self.C_VIEW, QHeaderView.ResizeMode.Fixed)
- treeHeader.setSectionResizeMode(self.C_TITLE, QHeaderView.ResizeMode.ResizeToContents)
+ treeHeader.setSectionResizeMode(self.C_DOC, QtHeaderToContents)
+ treeHeader.setSectionResizeMode(self.C_EDIT, QtHeaderFixed)
+ treeHeader.setSectionResizeMode(self.C_VIEW, QtHeaderFixed)
+ treeHeader.setSectionResizeMode(self.C_TITLE, QtHeaderToContents)
treeHeader.resizeSection(self.C_EDIT, iPx + cMg)
treeHeader.resizeSection(self.C_VIEW, iPx + cMg)
treeHeader.setSectionsMovable(False)
@@ -407,8 +407,8 @@ class _ViewPanelKeyWords(QTreeWidget):
treeHeader = self.header()
treeHeader.setStretchLastSection(True)
treeHeader.setMinimumSectionSize(iPx + cMg) # See Issue #1627
- treeHeader.setSectionResizeMode(self.C_EDIT, QHeaderView.ResizeMode.Fixed)
- treeHeader.setSectionResizeMode(self.C_VIEW, QHeaderView.ResizeMode.Fixed)
+ treeHeader.setSectionResizeMode(self.C_EDIT, QtHeaderFixed)
+ treeHeader.setSectionResizeMode(self.C_VIEW, QtHeaderFixed)
treeHeader.resizeSection(self.C_EDIT, iPx + cMg)
treeHeader.resizeSection(self.C_VIEW, iPx + cMg)
treeHeader.setSectionsMovable(False)
diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py
index 795622c0..7a3b114a 100644
--- a/novelwriter/gui/noveltree.py
+++ b/novelwriter/gui/noveltree.py
@@ -33,9 +33,8 @@ from time import time
from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QFocusEvent, QFont, QMouseEvent, QPalette, QResizeEvent
from PyQt5.QtWidgets import (
- QAbstractItemView, QActionGroup, QFrame, QHBoxLayout, QHeaderView,
- QInputDialog, QMenu, QToolTip, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
- QWidget
+ QAbstractItemView, QActionGroup, QFrame, QHBoxLayout, QInputDialog, QMenu,
+ QToolTip, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
@@ -47,8 +46,9 @@ from novelwriter.extensions.modified import NIconToolButton
from novelwriter.extensions.novelselector import NovelSelector
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
from novelwriter.types import (
- QtAlignRight, QtDecoration, QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff,
- QtScrollAsNeeded, QtSizeExpanding, QtUserRole
+ QtAlignRight, QtDecoration, QtHeaderStretch, QtHeaderToContents,
+ QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded,
+ QtSizeExpanding, QtUserRole
)
logger = logging.getLogger(__name__)
@@ -406,10 +406,10 @@ class GuiNovelTree(QTreeWidget):
treeHeader = self.header()
treeHeader.setStretchLastSection(False)
treeHeader.setMinimumSectionSize(iPx + cMg)
- treeHeader.setSectionResizeMode(self.C_TITLE, QHeaderView.ResizeMode.Stretch)
- treeHeader.setSectionResizeMode(self.C_WORDS, QHeaderView.ResizeMode.ResizeToContents)
- treeHeader.setSectionResizeMode(self.C_EXTRA, QHeaderView.ResizeMode.ResizeToContents)
- treeHeader.setSectionResizeMode(self.C_MORE, QHeaderView.ResizeMode.ResizeToContents)
+ treeHeader.setSectionResizeMode(self.C_TITLE, QtHeaderStretch)
+ treeHeader.setSectionResizeMode(self.C_WORDS, QtHeaderToContents)
+ treeHeader.setSectionResizeMode(self.C_EXTRA, QtHeaderToContents)
+ treeHeader.setSectionResizeMode(self.C_MORE, QtHeaderToContents)
# Pre-Generate Tree Formatting
fH1 = self.font()
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index f5a30c6c..083ee90f 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -34,8 +34,8 @@ from enum import Enum
from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QIcon, QMouseEvent, QPalette
from PyQt5.QtWidgets import (
- QAbstractItemView, QAction, QFrame, QHBoxLayout, QHeaderView, QLabel,
- QMenu, QShortcut, QTreeView, QVBoxLayout, QWidget
+ QAbstractItemView, QAction, QFrame, QHBoxLayout, QLabel, QMenu, QShortcut,
+ QTreeView, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
@@ -48,8 +48,8 @@ from novelwriter.enum import nwDocMode, nwItemClass, nwItemType
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
from novelwriter.types import (
- QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded,
- QtSizeExpanding
+ QtHeaderFixed, QtHeaderStretch, QtHeaderToContents, QtMouseLeft,
+ QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded, QtSizeExpanding
)
logger = logging.getLogger(__name__)
@@ -226,8 +226,8 @@ class GuiProjectView(QWidget):
@pyqtSlot(str)
def updateItemValues(self, tHandle: str) -> None:
"""Update tree item."""
- if nwItem := SHARED.project.tree[tHandle]:
- self.projTree.setTreeItemValues(nwItem)
+ # if nwItem := SHARED.project.tree[tHandle]:
+ # self.projTree.setTreeItemValues(nwItem)
return
@pyqtSlot(str)
@@ -496,15 +496,6 @@ class GuiProjectToolBar(QWidget):
class GuiProjectTree(QTreeView):
- # C_DATA = 0
- C_NAME = 0
- C_COUNT = 1
- C_ACTIVE = 2
- C_STATUS = 3
-
- # D_HANDLE = QtUserRole
- # D_WORDS = QtUserRole + 1
-
itemRefreshed = pyqtSignal(str, NWItem, QIcon)
def __init__(self, projView: GuiProjectView) -> None:
@@ -612,12 +603,12 @@ class GuiProjectTree(QTreeView):
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)
+ treeHeader.setSectionResizeMode(ProjectNode.C_NAME, QtHeaderStretch)
+ treeHeader.setSectionResizeMode(ProjectNode.C_COUNT, QtHeaderToContents)
+ treeHeader.setSectionResizeMode(ProjectNode.C_ACTIVE, QtHeaderFixed)
+ treeHeader.setSectionResizeMode(ProjectNode.C_STATUS, QtHeaderFixed)
+ treeHeader.resizeSection(ProjectNode.C_ACTIVE, iPx + cMg)
+ treeHeader.resizeSection(ProjectNode.C_STATUS, iPx + cMg)
self.restoreExpandedState()
@@ -955,43 +946,6 @@ class GuiProjectTree(QTreeView):
# self.setTreeItemValues(nwItem)
return
- def setTreeItemValues(self, nwItem: NWItem | None) -> None:
- """Set the name and flag values for a tree item in the project
- tree. Does not trigger a tree change as the data is already
- coming from project data.
- """
- # if isinstance(nwItem, NWItem) and (trItem := self._getTreeItem(nwItem.itemHandle)):
- # itemStatus, statusIcon = nwItem.getImportStatus()
- # hLevel = nwItem.mainHeading
- # itemIcon = SHARED.theme.getItemIcon(
- # nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
- # )
-
- # trItem.setIcon(self.C_NAME, itemIcon)
- # trItem.setText(self.C_NAME, nwItem.itemName)
- # trItem.setIcon(self.C_STATUS, statusIcon)
- # trItem.setToolTip(self.C_STATUS, itemStatus)
-
- # if nwItem.isFileType():
- # iconName = "checked" if nwItem.isActive else "unchecked"
- # toolTip = self.trActive if nwItem.isActive else self.trInactive
- # trItem.setToolTip(self.C_ACTIVE, toolTip)
- # else:
- # iconName = "noncheckable"
-
- # trItem.setIcon(self.C_ACTIVE, SHARED.theme.getIcon(iconName))
-
- # if CONFIG.emphLabels and nwItem.isDocumentLayout():
- # trFont = trItem.font(self.C_NAME)
- # trFont.setBold(hLevel == "H1" or hLevel == "H2")
- # trFont.setUnderline(hLevel == "H1")
- # trItem.setFont(self.C_NAME, trFont)
-
- # # Emit Refresh Signal
- # self.itemRefreshed.emit(nwItem.itemHandle, nwItem, itemIcon)
-
- return
-
def propagateCount(self, tHandle: str, newCount: int, countChildren: bool = False) -> None:
"""Recursive function setting the word count for a given item,
and propagating that count upwards in the tree until reaching a
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index 65f23a44..f18e98ee 100644
--- a/novelwriter/gui/search.py
+++ b/novelwriter/gui/search.py
@@ -30,15 +30,18 @@ from time import time
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QCursor, QKeyEvent
from PyQt5.QtWidgets import (
- QApplication, QFrame, QHBoxLayout, QHeaderView, QLabel, QLineEdit,
- QToolBar, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
+ QApplication, QFrame, QHBoxLayout, QLabel, QLineEdit, QToolBar,
+ QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import checkInt, cssCol
from novelwriter.core.coretools import DocSearch
from novelwriter.core.item import NWItem
-from novelwriter.types import QtAlignMiddle, QtAlignRight, QtUserRole
+from novelwriter.types import (
+ QtAlignMiddle, QtAlignRight, QtHeaderStretch, QtHeaderToContents,
+ QtUserRole
+)
logger = logging.getLogger(__name__)
@@ -120,8 +123,8 @@ class GuiProjectSearch(QWidget):
treeHeader = self.searchResult.header()
treeHeader.setStretchLastSection(False)
- treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch)
- treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeMode.ResizeToContents)
+ treeHeader.setSectionResizeMode(self.C_NAME, QtHeaderStretch)
+ treeHeader.setSectionResizeMode(self.C_COUNT, QtHeaderToContents)
# Assemble
self.headerBox = QHBoxLayout()
diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py
index f4c658e7..79dcf0bc 100644
--- a/novelwriter/tools/manussettings.py
+++ b/novelwriter/tools/manussettings.py
@@ -31,9 +31,9 @@ from PyQt5.QtCore import QEvent, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QFont, QIcon, QSyntaxHighlighter, QTextCharFormat, QTextDocument
from PyQt5.QtWidgets import (
QAbstractButton, QAbstractItemView, QDialogButtonBox, QFrame, QGridLayout,
- QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMenu, QPlainTextEdit,
- QPushButton, QSplitter, QStackedWidget, QTreeWidget, QTreeWidgetItem,
- QVBoxLayout, QWidget
+ QHBoxLayout, QLabel, QLineEdit, QMenu, QPlainTextEdit, QPushButton,
+ QSplitter, QStackedWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
+ QWidget
)
from novelwriter import CONFIG, SHARED
@@ -51,7 +51,8 @@ from novelwriter.extensions.switch import NSwitch
from novelwriter.extensions.switchbox import NSwitchBox
from novelwriter.types import (
QtAlignCenter, QtAlignLeft, QtDialogApply, QtDialogClose, QtDialogSave,
- QtRoleAccept, QtRoleApply, QtRoleReject, QtUserRole
+ QtHeaderFixed, QtHeaderStretch, QtRoleAccept, QtRoleApply, QtRoleReject,
+ QtUserRole
)
if TYPE_CHECKING: # pragma: no cover
@@ -313,9 +314,9 @@ class _FilterTab(NFixedPage):
treeHeader = self.optTree.header()
treeHeader.setStretchLastSection(False)
treeHeader.setMinimumSectionSize(iPx + cMg) # See Issue #1551
- treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch)
- treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.ResizeMode.Fixed)
- treeHeader.setSectionResizeMode(self.C_STATUS, QHeaderView.ResizeMode.Fixed)
+ treeHeader.setSectionResizeMode(self.C_NAME, QtHeaderStretch)
+ treeHeader.setSectionResizeMode(self.C_ACTIVE, QtHeaderFixed)
+ treeHeader.setSectionResizeMode(self.C_STATUS, QtHeaderFixed)
treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg)
treeHeader.resizeSection(self.C_STATUS, iPx + cMg)
diff --git a/novelwriter/types.py b/novelwriter/types.py
index e997f619..1dffc689 100644
--- a/novelwriter/types.py
+++ b/novelwriter/types.py
@@ -28,7 +28,7 @@ from PyQt5.QtGui import (
QColor, QFont, QPainter, QTextBlockFormat, QTextCharFormat, QTextCursor,
QTextFormat
)
-from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QSizePolicy, QStyle
+from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QHeaderView, QSizePolicy, QStyle
# Qt Alignment Flags
@@ -119,6 +119,12 @@ QtSizeIgnored = QSizePolicy.Policy.Ignored
QtSizeMinimum = QSizePolicy.Policy.Minimum
QtSizeMinimumExpanding = QSizePolicy.Policy.MinimumExpanding
+# Resize Mode
+
+QtHeaderStretch = QHeaderView.ResizeMode.Stretch
+QtHeaderToContents = QHeaderView.ResizeMode.ResizeToContents
+QtHeaderFixed = QHeaderView.ResizeMode.Fixed
+
# Scroll Bar Policy
QtScrollAlwaysOff = Qt.ScrollBarPolicy.ScrollBarAlwaysOff
From 072be78991fc80841e738e8cb26d988c45dc06d3 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 20 Nov 2024 16:42:08 +0100
Subject: [PATCH 17/39] Re-enable project tree context menu and clean up split
and merge
---
novelwriter/core/coretools.py | 188 +++---
novelwriter/core/tree.py | 6 +
novelwriter/gui/projtree.py | 943 +++++++++++++---------------
tests/test_gui/test_gui_projtree.py | 6 +-
4 files changed, 539 insertions(+), 604 deletions(-)
diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py
index fce179ab..0f2f39c9 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -56,10 +56,17 @@ class DocMerger:
def __init__(self, project: NWProject) -> None:
self._project = project
self._error = ""
- self._targetDoc = None
- self._targetText = []
+ self._target = None
+ self._text = []
return
+ @property
+ def targetHandle(self) -> str | None:
+ """Get the handle of the target document."""
+ if self._target:
+ return self._target.itemHandle
+ return None
+
##
# Methods
##
@@ -72,63 +79,56 @@ class DocMerger:
"""Set the target document for the merging. Calling this
function resets the class.
"""
- self._targetDoc = tHandle
- self._targetText = []
+ self._target = self._project.tree[tHandle]
+ self._text = []
return
- def newTargetDoc(self, srcHandle: str, docLabel: str) -> str | None:
+ def newTargetDoc(self, sHandle: str, label: str) -> None:
"""Create a brand new target document based on a source handle
and a new doc label. Calling this function resets the class.
"""
- srcItem = self._project.tree[srcHandle]
- if srcItem is None or srcItem.itemParent is None:
- return None
+ sItem = self._project.tree[sHandle]
+ if sItem and sItem.itemParent:
+ tHandle = self._project.newFile(label, sItem.itemParent)
+ if nwItem := self._project.tree[tHandle]:
+ nwItem.setLayout(sItem.itemLayout)
+ nwItem.setStatus(sItem.itemStatus)
+ nwItem.setImport(sItem.itemImport)
+ nwItem.notifyToRefresh()
+ self._target = nwItem
+ self._text = []
+ return
- newHandle = self._project.newFile(docLabel, srcItem.itemParent)
- newItem = self._project.tree[newHandle]
- if isinstance(newItem, NWItem):
- newItem.setLayout(srcItem.itemLayout)
- newItem.setStatus(srcItem.itemStatus)
- newItem.setImport(srcItem.itemImport)
-
- self._targetDoc = newHandle
- self._targetText = []
-
- return newHandle
-
- def appendText(self, srcHandle: str, addComment: bool, cmtPrefix: str) -> bool:
+ def appendText(self, sHandle: str, addComment: bool, cmtPrefix: str) -> None:
"""Append text from an existing document to the text buffer."""
- srcItem = self._project.tree[srcHandle]
- if srcItem is None:
- return False
-
- docText = self._project.storage.getDocumentText(srcHandle).rstrip("\n")
- if addComment:
- docInfo = srcItem.describeMe()
- docSt, _ = srcItem.getImportStatus()
- cmtLine = f"% {cmtPrefix} {docInfo}: {srcItem.itemName} [{docSt}]\n\n"
- docText = cmtLine + docText
-
- self._targetText.append(docText)
-
- return True
+ if item := self._project.tree[sHandle]:
+ text = self._project.storage.getDocumentText(sHandle).rstrip("\n")
+ if addComment:
+ info = item.describeMe()
+ status, _ = item.getImportStatus()
+ text = f"% {cmtPrefix} {info}: {item.itemName} [{status}]\n\n{text}"
+ self._text.append(text)
+ return
def writeTargetDoc(self) -> bool:
"""Write the accumulated text into the designated target
document, appending any existing text.
"""
- if self._targetDoc is None:
- return False
+ if self._target:
+ outDoc = self._project.storage.getDocument(self._target.itemHandle)
+ if text := (outDoc.readDocument() or "").rstrip("\n"):
+ self._text.insert(0, text)
- outDoc = self._project.storage.getDocument(self._targetDoc)
- if text := (outDoc.readDocument() or "").rstrip("\n"):
- self._targetText.insert(0, text)
+ status = outDoc.writeDocument("\n\n".join(self._text) + "\n\n")
+ if not status:
+ self._error = outDoc.getError()
- status = outDoc.writeDocument("\n\n".join(self._targetText) + "\n\n")
- if not status:
- self._error = outDoc.getError()
+ self._project.index.reIndexHandle(self._target.itemHandle)
+ self._target.notifyToRefresh()
- return status
+ return status
+
+ return False
class DocSplitter:
@@ -172,23 +172,19 @@ class DocSplitter:
self._inFolder = False
return
- def newParentFolder(self, pHandle: str, folderLabel: str) -> str | None:
+ def newParentFolder(self, pHandle: str, folderLabel: str) -> None:
"""Create a new folder that will be the top level parent item
for the new documents.
"""
- if self._srcItem is None:
- return None
-
- newHandle = self._project.newFolder(folderLabel, pHandle)
- newItem = self._project.tree[newHandle]
- if isinstance(newItem, NWItem):
- newItem.setStatus(self._srcItem.itemStatus)
- newItem.setImport(self._srcItem.itemImport)
-
- self._parHandle = newHandle
- self._inFolder = True
-
- return newHandle
+ if self._srcItem:
+ nHandle = self._project.newFolder(folderLabel, pHandle)
+ if nwItem := self._project.tree[nHandle]:
+ nwItem.setStatus(self._srcItem.itemStatus)
+ nwItem.setImport(self._srcItem.itemImport)
+ nwItem.notifyToRefresh()
+ self._parHandle = nHandle
+ self._inFolder = True
+ return
def splitDocument(self, splitData: list, splitText: list[str]) -> None:
"""Loop through the split data record and perform the split job
@@ -202,58 +198,50 @@ class DocSplitter:
self._rawData.insert(0, (chunk, hLevel, hLabel))
return
- def writeDocuments(self, docHierarchy: bool) -> Iterable[tuple[bool, str | None, str | None]]:
+ def writeDocuments(self, docHierarchy: bool) -> Iterable[bool]:
"""An iterator that will write each document in the buffer, and
return its new handle, parent handle, and sibling handle.
"""
- if self._srcHandle is None or self._srcItem is None or self._parHandle is None:
- return
+ if self._srcHandle and self._srcItem and self._parHandle:
+ pHandle = self._parHandle
+ hHandle = [self._parHandle, None, None, None, None]
+ pLevel = 0
+ for docText, hLevel, docLabel in self._rawData:
- pHandle = self._parHandle
- nHandle = self._parHandle if self._inFolder else self._srcHandle
- hHandle = [self._parHandle, None, None, None, None]
+ hLevel = minmax(hLevel, 1, 4)
+ if pLevel == 0:
+ pLevel = hLevel
- pLevel = 0
- for docText, hLevel, docLabel in self._rawData:
+ if docHierarchy:
+ if hLevel == 1:
+ pHandle = self._parHandle
+ elif hLevel == 2:
+ pHandle = hHandle[1] or hHandle[0]
+ elif hLevel == 3:
+ pHandle = hHandle[2] or hHandle[1] or hHandle[0]
+ elif hLevel == 4:
+ pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0]
- hLevel = minmax(hLevel, 1, 4)
- if pLevel == 0:
- pLevel = hLevel
+ if (
+ (dHandle := self._project.newFile(docLabel, pHandle))
+ and (nwItem := self._project.tree[dHandle])
+ ):
+ hHandle[hLevel] = dHandle
+ nwItem.setStatus(self._srcItem.itemStatus)
+ nwItem.setImport(self._srcItem.itemImport)
- if docHierarchy:
- if hLevel == 1:
- pHandle = self._parHandle
- elif hLevel == 2:
- pHandle = hHandle[1] or hHandle[0]
- elif hLevel == 3:
- pHandle = hHandle[2] or hHandle[1] or hHandle[0]
- elif hLevel == 4:
- pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0]
+ outDoc = self._project.storage.getDocument(dHandle)
+ status = outDoc.writeDocument("\n".join(docText))
+ if not status:
+ self._error = outDoc.getError()
- if hLevel < pLevel:
- nHandle = hHandle[hLevel] or hHandle[0]
- elif hLevel > pLevel:
- nHandle = pHandle
+ self._project.index.reIndexHandle(dHandle)
+ nwItem.notifyToRefresh()
- dHandle = self._project.newFile(docLabel, pHandle)
- hHandle[hLevel] = dHandle
-
- newItem = self._project.tree[dHandle]
- if isinstance(newItem, NWItem):
- newItem.setStatus(self._srcItem.itemStatus)
- newItem.setImport(self._srcItem.itemImport)
-
- outDoc = self._project.storage.getDocument(dHandle)
- status = outDoc.writeDocument("\n".join(docText))
- if not status:
- self._error = outDoc.getError()
-
- yield status, dHandle, nHandle
-
- hHandle[hLevel] = dHandle
- nHandle = dHandle
- pLevel = hLevel
+ yield status
+ hHandle[hLevel] = dHandle
+ pLevel = hLevel
return
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 75b82818..59a0c3ac 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -391,6 +391,12 @@ class NWTree:
return tTree
+ def subTree(self, tHandle: str) -> list[str]:
+ """Get the subtree from a given handle."""
+ if node := self._nodes.get(tHandle):
+ return [child.item.itemHandle for child in node.allChildren()]
+ return []
+
##
# Tree Root Methods
##
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 083ee90f..1a883906 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -40,11 +40,15 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import qtLambda
-from novelwriter.constants import nwLabels, nwStyles, trConst
+from novelwriter.constants import nwLabels, nwStyles, nwUnicode, trConst
+from novelwriter.core.coretools import DocMerger, DocSplitter
from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
+from novelwriter.dialogs.docmerge import GuiDocMerge
+from novelwriter.dialogs.docsplit import GuiDocSplit
from novelwriter.dialogs.editlabel import GuiEditLabel
-from novelwriter.enum import nwDocMode, nwItemClass, nwItemType
+from novelwriter.dialogs.projectsettings import GuiProjectSettings
+from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
from novelwriter.types import (
@@ -349,14 +353,10 @@ class GuiProjectToolBar(QWidget):
self.mMore = QMenu(self)
self.aExpand = self.mMore.addAction(self.tr("Expand All"))
- self.aExpand.triggered.connect(
- qtLambda(self.projTree.setExpandedFromHandle, None, True)
- )
+ self.aExpand.triggered.connect(self.projTree.expandAll)
self.aCollapse = self.mMore.addAction(self.tr("Collapse All"))
- self.aCollapse.triggered.connect(
- qtLambda(self.projTree.setExpandedFromHandle, None, False)
- )
+ self.aCollapse.triggered.connect(self.projTree.collapseAll)
self.aEmptyTrash = self.mMore.addAction(self.tr("Empty Trash"))
self.aEmptyTrash.triggered.connect(qtLambda(self.projTree.emptyTrash))
@@ -508,6 +508,10 @@ class GuiProjectTree(QTreeView):
# Internal Variables
# self._actHandle = None
+ # Cached Translations
+ self.trActive = trConst(nwLabels.ACTIVE_NAME["checked"])
+ self.trInactive = trConst(nwLabels.ACTIVE_NAME["unchecked"])
+
# Tree Settings
iPx = SHARED.theme.baseIconHeight
@@ -533,8 +537,8 @@ class GuiProjectTree(QTreeView):
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# Context Menu
- # self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- # self.customContextMenuRequested.connect(self.openContextMenu)
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.customContextMenuRequested.connect(self.openContextMenu)
# Connect signals
self.clicked.connect(self._onSingleClick)
@@ -715,6 +719,101 @@ class GuiProjectTree(QTreeView):
return
+ def mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
+ """Merge an item's child documents into a single document."""
+ logger.info("Request to merge items under handle '%s'", tHandle)
+
+ if not (tItem := SHARED.project.tree[tHandle]):
+ return False
+
+ if tItem.isRootType():
+ logger.error("Cannot merge root item")
+ return False
+
+ itemList = SHARED.project.tree.subTree(tHandle)
+ if newFile:
+ itemList.insert(0, tHandle)
+
+ data, status = GuiDocMerge.getData(SHARED.mainGui, tHandle, itemList)
+ if not status:
+ logger.info("Action cancelled by user")
+ return False
+ if not (items := data.get("finalItems", [])):
+ SHARED.info(self.tr("No documents selected for merging."))
+ return False
+
+ # Save the open document first, in case it's part of merge
+ SHARED.saveEditor()
+
+ # Create merge object, and append docs
+ docMerger = DocMerger(SHARED.project)
+ mLabel = self.tr("Merged")
+
+ if newFile:
+ docMerger.newTargetDoc(tHandle, f"[{mLabel}] {tItem.itemName}")
+ elif tItem.isFileType():
+ docMerger.setTargetDoc(tHandle)
+ else:
+ return False
+
+ for sHandle in items:
+ docMerger.appendText(sHandle, True, mLabel)
+
+ if not docMerger.writeTargetDoc():
+ SHARED.error(
+ self.tr("Could not write document content."),
+ info=docMerger.getError()
+ )
+ return False
+
+ if data.get("moveToTrash", False):
+ self.processDeleteRequest(data.get("finalItems", []), False)
+
+ if mHandle := docMerger.targetHandle:
+ self.projView.openDocumentRequest.emit(mHandle, nwDocMode.EDIT, "", False)
+ self.projView.setSelectedHandle(mHandle, doScroll=True)
+
+ return True
+
+ def splitDocument(self, tHandle: str) -> bool:
+ """Split a document into multiple documents."""
+ logger.info("Request to split items with handle '%s'", tHandle)
+
+ if not (tItem := SHARED.project.tree[tHandle]):
+ return False
+
+ if not tItem.isFileType() or tItem.itemParent is None:
+ logger.error("Only valid document items can be split")
+ return False
+
+ data, text, status = GuiDocSplit.getData(SHARED.mainGui, tHandle)
+ if not status:
+ logger.info("Action cancelled by user")
+ return False
+
+ headerList = data.get("headerList", [])
+ intoFolder = data.get("intoFolder", False)
+ docHierarchy = data.get("docHierarchy", False)
+
+ docSplit = DocSplitter(SHARED.project, tHandle)
+ if intoFolder:
+ docSplit.newParentFolder(tItem.itemParent, tItem.itemName)
+ else:
+ docSplit.setParentItem(tItem.itemParent)
+
+ docSplit.splitDocument(headerList, text)
+ for writeOk in docSplit.writeDocuments(docHierarchy):
+ if not writeOk:
+ SHARED.error(
+ self.tr("Could not write document content."),
+ info=docSplit.getError()
+ )
+
+ if data.get("moveToTrash", False):
+ self.processDeleteRequest([tHandle], False)
+
+ return True
+
##
# Events
##
@@ -791,10 +890,29 @@ class GuiProjectTree(QTreeView):
self.setCurrentIndex(model.indexFromNode(child))
return
+ @pyqtSlot(QModelIndex)
+ def expandFromIndex(self, index: QModelIndex) -> None:
+ """Expand all nodes from index."""
+ self.expandRecursively(index)
+ return
+
+ @pyqtSlot(QModelIndex)
+ def collapseFromIndex(self, index: QModelIndex) -> None:
+ """Collapse all nodes from index."""
+ if (model := self._getModel()) and (node := model.node(index)):
+ for child in node.allChildren():
+ self.setExpanded(model.indexFromNode(child), False)
+ return
+
@pyqtSlot()
- def processDeleteRequest(self, askFirst: bool = True) -> None:
+ def processDeleteRequest(self, handles: list[str] = [], askFirst: bool = True) -> None:
"""Move selected items to Trash."""
- if (items := self._selectedRows()) and (model := self._getModel()):
+ if handles and (model := self._getModel()):
+ items = [model.indexFromHandle(handle) for handle in handles]
+ else:
+ items = self._selectedRows()
+
+ if items and (model := self._getModel()):
if len(items) == 1 and (node := model.node(items[0])) and node.item.isRootType():
if node.childCount() == 0:
SHARED.project.removeItem(node.item.itemHandle)
@@ -803,10 +921,9 @@ class GuiProjectTree(QTreeView):
return
if model.trashSelection(items):
- if askFirst:
- if not SHARED.question(self.tr("Permanently delete selected item(s)?")):
- logger.info("Action cancelled by user")
- return
+ if not SHARED.question(self.tr("Permanently delete selected item(s)?")):
+ logger.info("Action cancelled by user")
+ return
for node in model.nodes(items):
SHARED.project.removeItem(node.item.itemHandle)
@@ -815,6 +932,35 @@ class GuiProjectTree(QTreeView):
logger.info("Action cancelled by user")
return
model.multiMove(items, model.indexFromNode(trashNode))
+
+ return
+
+ @pyqtSlot()
+ @pyqtSlot("QPoint")
+ def openContextMenu(self, point: QPoint | None = None) -> None:
+ """The user right clicked an element in the project tree, so we
+ open a context menu in-place.
+ """
+ if model := self._getModel():
+ if point is None:
+ point = self.visualRect(self.currentIndex()).center()
+
+ if (
+ point is not None
+ and (node := self._getNode(self.currentIndex()))
+ and (indices := self._selectedRows())
+ ):
+ ctxMenu = _TreeContextMenu(self, model, node, indices)
+ if node is SHARED.project.tree.trash:
+ ctxMenu.buildTrashMenu()
+ elif len(indices) > 1:
+ ctxMenu.buildMultiSelectMenu()
+ else:
+ ctxMenu.buildSingleSelectMenu()
+
+ ctxMenu.exec(self.viewport().mapToGlobal(point))
+ ctxMenu.deleteLater()
+
return
##
@@ -998,58 +1144,6 @@ class GuiProjectTree(QTreeView):
# self._actHandle = tHandle or None
return
- def setExpandedFromHandle(self, tHandle: str | None, isExpanded: bool) -> None:
- """Iterate through items below tHandle and change expanded
- status for all child items. If tHandle is None, it affects the
- entire tree.
- """
- # item = self._getTreeItem(tHandle) or self.invisibleRootItem()
- # self._recursiveSetExpanded(item, isExpanded)
- return
-
- ##
- # Public Slots
- ##
-
- @pyqtSlot()
- @pyqtSlot("QPoint")
- def openContextMenu(self, clickPos: QPoint | None = None) -> None:
- """The user right clicked an element in the project tree, so we
- open a context menu in-place.
- """
- # if clickPos is None and (items := self.selectedItems()):
- # clickPos = self.visualItemRect(items[0]).center()
-
- # if clickPos is not None:
- # tItem = None
- # tHandle = None
- # hasChild = False
- # sItem = self.itemAt(clickPos)
- # sItems = self.selectedItems()
- # if isinstance(sItem, QTreeWidgetItem):
- # tHandle = sItem.data(self.C_DATA, self.D_HANDLE)
- # tItem = SHARED.project.tree[tHandle]
- # hasChild = sItem.childCount() > 0
-
- # if tItem is None or tHandle is None:
- # logger.debug("No item found")
- # return
-
- # ctxMenu = _TreeContextMenu(self, tItem)
- # trashHandle = SHARED.project.tree.trashRoot
- # if trashHandle and tHandle == trashHandle:
- # ctxMenu.buildTrashMenu()
- # elif len(sItems) > 1:
- # handles = [str(x.data(self.C_DATA, self.D_HANDLE)) for x in sItems]
- # ctxMenu.buildMultiSelectMenu(handles)
- # else:
- # ctxMenu.buildSingleSelectMenu(hasChild)
-
- # ctxMenu.exec(self.viewport().mapToGlobal(clickPos))
- # ctxMenu.deleteLater()
-
- return
-
##
# Private Slots
##
@@ -1069,125 +1163,6 @@ class GuiProjectTree(QTreeView):
# Internal Functions
##
- def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
- """Merge an item's child documents into a single document."""
- # logger.info("Request to merge items under handle '%s'", tHandle)
- # itemList = self.getTreeFromHandle(tHandle)
-
- # tItem = SHARED.project.tree[tHandle]
- # if tItem is None:
- # return False
-
- # if tItem.isRootType():
- # logger.error("Cannot merge root item")
- # return False
-
- # if not newFile:
- # itemList.remove(tHandle)
-
- # data, status = GuiDocMerge.getData(SHARED.mainGui, tHandle, itemList)
- # if status:
- # items = data.get("finalItems", [])
- # if not items:
- # SHARED.info(self.tr("No documents selected for merging."))
- # return False
-
- # # Save the open document first, in case it's part of merge
- # SHARED.saveEditor()
-
- # # Create merge object, and append docs
- # docMerger = DocMerger(SHARED.project)
- # mLabel = self.tr("Merged")
-
- # if newFile:
- # docLabel = f"[{mLabel}] {tItem.itemName}"
- # mHandle = docMerger.newTargetDoc(tHandle, docLabel)
- # elif tItem.isFileType():
- # docMerger.setTargetDoc(tHandle)
- # mHandle = tHandle
- # else:
- # return False
-
- # for sHandle in items:
- # docMerger.appendText(sHandle, True, mLabel)
-
- # if not docMerger.writeTargetDoc():
- # SHARED.error(
- # self.tr("Could not write document content."),
- # info=docMerger.getError()
- # )
- # return False
-
- # SHARED.project.index.reIndexHandle(mHandle)
- # if newFile:
- # self.revealNewTreeItem(mHandle, nHandle=tHandle, wordCount=True)
-
- # self.projView.openDocumentRequest.emit(mHandle, nwDocMode.EDIT, "", False)
- # self.projView.setSelectedHandle(mHandle, doScroll=True)
-
- # if data.get("moveToTrash", False):
- # for sHandle in reversed(data.get("finalItems", [])):
- # trItem = self._getTreeItem(sHandle)
- # if isinstance(trItem, QTreeWidgetItem) and trItem.childCount() == 0:
- # self.moveItemToTrash(sHandle, askFirst=False, flush=False)
-
- # self._alertTreeChange(mHandle, flush=True)
- # self.projView.wordCountsChanged.emit()
-
- # else:
- # logger.info("Action cancelled by user")
- # return False
-
- return True
-
- def _splitDocument(self, tHandle: str) -> bool:
- """Split a document into multiple documents."""
- # logger.info("Request to split items with handle '%s'", tHandle)
-
- # tItem = SHARED.project.tree[tHandle]
- # if tItem is None:
- # return False
-
- # if not tItem.isFileType() or tItem.itemParent is None:
- # logger.error("Only valid document items can be split")
- # return False
-
- # data, text, status = GuiDocSplit.getData(SHARED.mainGui, tHandle)
- # if status:
- # headerList = data.get("headerList", [])
- # intoFolder = data.get("intoFolder", False)
- # docHierarchy = data.get("docHierarchy", False)
-
- # docSplit = DocSplitter(SHARED.project, tHandle)
- # if intoFolder:
- # fHandle = docSplit.newParentFolder(tItem.itemParent, tItem.itemName)
- # self.revealNewTreeItem(fHandle, nHandle=tHandle)
- # self._alertTreeChange(fHandle, flush=False)
- # else:
- # docSplit.setParentItem(tItem.itemParent)
-
- # docSplit.splitDocument(headerList, text)
- # for writeOk, dHandle, nHandle in docSplit.writeDocuments(docHierarchy):
- # SHARED.project.index.reIndexHandle(dHandle)
- # self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
- # self._alertTreeChange(dHandle, flush=False)
- # if not writeOk:
- # SHARED.error(
- # self.tr("Could not write document content."),
- # info=docSplit.getError()
- # )
-
- # if data.get("moveToTrash", False):
- # self.moveItemToTrash(tHandle, askFirst=False, flush=True)
-
- # self.saveTreeOrder()
-
- # else:
- # logger.info("Action cancelled by user")
- # return False
-
- return True
-
def _duplicateFromHandle(self, tHandle: str) -> bool:
"""Duplicate the item hierarchy from a given item."""
# itemTree = self.getTreeFromHandle(tHandle)
@@ -1281,358 +1256,324 @@ class _UpdatableMenu(QMenu):
return
-# class _TreeContextMenu(QMenu):
+class _TreeContextMenu(QMenu):
-# def __init__(self, projTree: GuiProjectTree, nwItem: NWItem) -> None:
-# super().__init__(parent=projTree)
+ __slots__ = ("_tree", "_view", "_node", "_item", "_model", "_handle", "_indices", "_children")
-# self.projTree = projTree
-# self.projView = projTree.projView
+ def __init__(
+ self, projTree: GuiProjectTree, model: ProjectModel,
+ node: ProjectNode, indices: list[QModelIndex]
+ ) -> None:
+ super().__init__(parent=projTree)
+ self._tree = projTree
+ self._view = projTree.projView
+ self._node = node
+ self._item = node.item
+ self._model = model
+ self._handle = node.item.itemHandle
+ self._indices = indices
+ self._children = node.childCount() > 0
+ logger.debug("Ready: _TreeContextMenu")
+ return
-# self._item = nwItem
-# self._handle = nwItem.itemHandle
-# self._items: list[NWItem] = []
+ def __del__(self) -> None: # pragma: no cover
+ logger.debug("Delete: _TreeContextMenu")
+ return
-# logger.debug("Ready: _TreeContextMenu")
+ ##
+ # Methods
+ ##
-# return
+ def buildTrashMenu(self) -> None:
+ """Build the special menu for the Trash folder."""
+ action = self.addAction(self.tr("Empty Trash"))
+ action.triggered.connect(self._tree.emptyTrash)
+ if self._children:
+ self._expandCollapse()
+ return
-# def __del__(self) -> None: # pragma: no cover
-# logger.debug("Delete: _TreeContextMenu")
-# return
+ def buildSingleSelectMenu(self) -> None:
+ """Build the single-select menu."""
+ isFile = self._item.isFileType()
+ isFolder = self._item.isFolderType()
-# ##
-# # Methods
-# ##
+ # Document Actions
+ if isFile:
+ self._docActions()
+ self.addSeparator()
-# def buildTrashMenu(self) -> None:
-# """Build the special menu for the Trash folder."""
-# action = self.addAction(self.tr("Empty Trash"))
-# action.triggered.connect(self.projTree.emptyTrash)
-# return
+ # Create New Items
+ self._itemCreation()
+ self.addSeparator()
-# def buildSingleSelectMenu(self, hasChild: bool) -> None:
-# """Build the single-select menu."""
-# isFile = self._item.isFileType()
-# isFolder = self._item.isFolderType()
-# isRoot = self._item.isRootType()
+ # Edit Item Settings
+ action = self.addAction(self.tr("Rename"))
+ action.triggered.connect(qtLambda(self._view.renameTreeItem, self._handle))
+ if isFile:
+ self._itemHeader()
+ self._itemActive()
+ self._itemStatusImport(False)
-# # Document Actions
-# if isFile:
-# self._docActions()
-# self.addSeparator()
+ # Transform Item
+ if isFile or isFolder:
+ self._itemTransform(isFile, isFolder)
+ self.addSeparator()
-# # Create New Items
-# self._itemCreation()
-# self.addSeparator()
+ # Process Item
+ if self._children:
+ self._expandCollapse()
+ if isFile:
+ action = self.addAction(self.tr("Duplicate"))
+ action.triggered.connect(qtLambda(self._tree._duplicateFromHandle, self._handle))
+ self._deleteOrTrash()
-# # Edit Item Settings
-# action = self.addAction(self.tr("Rename"))
-# action.triggered.connect(qtLambda(self.projView.renameTreeItem, self._handle))
-# if isFile:
-# self._itemHeader()
-# self._itemActive(False)
-# self._itemStatusImport(False)
+ return
-# # Transform Item
-# if isFile or isFolder:
-# self._itemTransform(isFile, isFolder, hasChild)
-# self.addSeparator()
+ def buildMultiSelectMenu(self) -> None:
+ """Build the multi-select menu."""
+ self._itemActive()
+ self._itemStatusImport(True)
+ self.addSeparator()
+ self._deleteOrTrash()
+ return
-# # Process Item
-# self._itemProcess(isFile, isFolder, isRoot, hasChild)
+ ##
+ # Menu Builders
+ ##
-# return
+ def _docActions(self) -> None:
+ """Add document actions."""
+ action = self.addAction(self.tr("Open Document"))
+ action.triggered.connect(qtLambda(
+ self._view.openDocumentRequest.emit,
+ self._handle, nwDocMode.EDIT, "", True
+ ))
+ action = self.addAction(self.tr("View Document"))
+ action.triggered.connect(qtLambda(
+ self._view.openDocumentRequest.emit,
+ self._handle, nwDocMode.VIEW, "", False
+ ))
+ return
-# def buildMultiSelectMenu(self, handles: list[str]) -> None:
-# """Build the multi-select menu."""
-# self._items = []
-# for tHandle in handles:
-# if (tItem := SHARED.project.tree[tHandle]):
-# self._items.append(tItem)
+ def _itemCreation(self) -> None:
+ """Add create item actions."""
+ menu = self.addMenu(self.tr("Create New ..."))
+ menu.addAction(self._view.projBar.aAddEmpty)
+ menu.addAction(self._view.projBar.aAddChap)
+ menu.addAction(self._view.projBar.aAddScene)
+ menu.addAction(self._view.projBar.aAddNote)
+ menu.addAction(self._view.projBar.aAddFolder)
+ return
-# self._itemActive(True)
-# self._itemStatusImport(True)
-# self.addSeparator()
-# self._multiMoveToTrash()
-# return
+ def _itemHeader(self) -> None:
+ """Check if there is a header that can be used for rename."""
+ SHARED.saveEditor()
+ if hItem := SHARED.project.index.getItemHeading(self._handle, "T0001"):
+ action = self.addAction(self.tr("Rename to Heading"))
+ action.triggered.connect(
+ qtLambda(self._view.renameTreeItem, self._handle, hItem.title)
+ )
+ return
-# ##
-# # Menu Builders
-# ##
+ def _itemActive(self) -> None:
+ """Add Active/Inactive actions."""
+ if len(self._indices) > 1:
+ mSub = self.addMenu(self.tr("Set Active to ..."))
+ aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self._tree.trActive)
+ aOne.triggered.connect(qtLambda(self._iterItemActive, True))
+ aTwo = mSub.addAction(SHARED.theme.getIcon("unchecked"), self._tree.trInactive)
+ aTwo.triggered.connect(qtLambda(self._iterItemActive, False))
+ else:
+ action = self.addAction(self.tr("Toggle Active"))
+ action.triggered.connect(self._toggleItemActive)
+ return
-# def _docActions(self) -> None:
-# """Add document actions."""
-# action = self.addAction(self.tr("Open Document"))
-# action.triggered.connect(qtLambda(
-# self.projView.openDocumentRequest.emit,
-# self._handle, nwDocMode.EDIT, "", True
-# ))
-# action = self.addAction(self.tr("View Document"))
-# action.triggered.connect(qtLambda(
-# self.projView.openDocumentRequest.emit,
-# self._handle, nwDocMode.VIEW, "", False
-# ))
-# return
+ def _itemStatusImport(self, multi: bool) -> None:
+ """Add actions for changing status or importance."""
+ if self._item.isNovelLike():
+ menu = self.addMenu(self.tr("Set Status to ..."))
+ current = self._item.itemStatus
+ for key, entry in SHARED.project.data.itemStatus.iterItems():
+ name = entry.name
+ if not multi and current == key:
+ name += f" ({nwUnicode.U_CHECK})"
+ action = menu.addAction(entry.icon, name)
+ if multi:
+ action.triggered.connect(qtLambda(self._iterSetItemStatus, key))
+ else:
+ action.triggered.connect(qtLambda(self._changeItemStatus, key))
+ menu.addSeparator()
+ action = menu.addAction(self.tr("Manage Labels ..."))
+ action.triggered.connect(qtLambda(
+ self._view.projectSettingsRequest.emit,
+ GuiProjectSettings.PAGE_STATUS
+ ))
+ else:
+ menu = self.addMenu(self.tr("Set Importance to ..."))
+ current = self._item.itemImport
+ for key, entry in SHARED.project.data.itemImport.iterItems():
+ name = entry.name
+ if not multi and current == key:
+ name += f" ({nwUnicode.U_CHECK})"
+ action = menu.addAction(entry.icon, name)
+ if multi:
+ action.triggered.connect(qtLambda(self._iterSetItemImport, key))
+ else:
+ action.triggered.connect(qtLambda(self._changeItemImport, key))
+ menu.addSeparator()
+ action = menu.addAction(self.tr("Manage Labels ..."))
+ action.triggered.connect(qtLambda(
+ self._view.projectSettingsRequest.emit,
+ GuiProjectSettings.PAGE_IMPORT
+ ))
+ return
-# def _itemCreation(self) -> None:
-# """Add create item actions."""
-# menu = self.addMenu(self.tr("Create New ..."))
-# menu.addAction(self.projView.projBar.aAddEmpty)
-# menu.addAction(self.projView.projBar.aAddChap)
-# menu.addAction(self.projView.projBar.aAddScene)
-# menu.addAction(self.projView.projBar.aAddNote)
-# menu.addAction(self.projView.projBar.aAddFolder)
-# return
+ def _itemTransform(self, isFile: bool, isFolder: bool) -> None:
+ """Add actions for the Transform menu."""
+ menu = self.addMenu(self.tr("Transform ..."))
-# def _itemHeader(self) -> None:
-# """Check if there is a header that can be used for rename."""
-# SHARED.saveEditor()
-# if hItem := SHARED.project.index.getItemHeading(self._handle, "T0001"):
-# action = self.addAction(self.tr("Rename to Heading"))
-# action.triggered.connect(
-# qtLambda(self.projView.renameTreeItem, self._handle, hItem.title)
-# )
-# return
+ trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT])
+ trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE])
+ loDoc = nwItemLayout.DOCUMENT
+ loNote = nwItemLayout.NOTE
+ isDocFile = isFile and self._item.isDocumentLayout()
+ isNoteFile = isFile and self._item.isNoteLayout()
-# def _itemActive(self, multi: bool) -> None:
-# """Add Active/Inactive actions."""
-# if multi:
-# mSub = self.addMenu(self.tr("Set Active to ..."))
-# aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self.projTree.trActive)
-# aOne.triggered.connect(qtLambda(self._iterItemActive, True))
-# aTwo = mSub.addAction(SHARED.theme.getIcon("unchecked"), self.projTree.trInactive)
-# aTwo.triggered.connect(qtLambda(self._iterItemActive, False))
-# else:
-# action = self.addAction(self.tr("Toggle Active"))
-# action.triggered.connect(self._toggleItemActive)
-# return
+ if isNoteFile and self._item.documentAllowed():
+ action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
+ action.triggered.connect(qtLambda(self._changeItemLayout, loDoc))
-# def _itemStatusImport(self, multi: bool) -> None:
-# """Add actions for changing status or importance."""
-# if self._item.isNovelLike():
-# menu = self.addMenu(self.tr("Set Status to ..."))
-# current = self._item.itemStatus
-# for key, entry in SHARED.project.data.itemStatus.iterItems():
-# name = entry.name
-# if not multi and current == key:
-# name += f" ({nwUnicode.U_CHECK})"
-# action = menu.addAction(entry.icon, name)
-# if multi:
-# action.triggered.connect(qtLambda(self._iterSetItemStatus, key))
-# else:
-# action.triggered.connect(qtLambda(self._changeItemStatus, key))
-# menu.addSeparator()
-# action = menu.addAction(self.tr("Manage Labels ..."))
-# action.triggered.connect(qtLambda(
-# self.projView.projectSettingsRequest.emit,
-# GuiProjectSettings.PAGE_STATUS
-# ))
-# else:
-# menu = self.addMenu(self.tr("Set Importance to ..."))
-# current = self._item.itemImport
-# for key, entry in SHARED.project.data.itemImport.iterItems():
-# name = entry.name
-# if not multi and current == key:
-# name += f" ({nwUnicode.U_CHECK})"
-# action = menu.addAction(entry.icon, name)
-# if multi:
-# action.triggered.connect(qtLambda(self._iterSetItemImport, key))
-# else:
-# action.triggered.connect(qtLambda(self._changeItemImport, key))
-# menu.addSeparator()
-# action = menu.addAction(self.tr("Manage Labels ..."))
-# action.triggered.connect(qtLambda(
-# self.projView.projectSettingsRequest.emit,
-# GuiProjectSettings.PAGE_IMPORT
-# ))
-# return
+ if isDocFile:
+ action = menu.addAction(self.tr("Convert to {0}").format(trNote))
+ action.triggered.connect(qtLambda(self._changeItemLayout, loNote))
-# def _itemTransform(self, isFile: bool, isFolder: bool, hasChild: bool) -> None:
-# """Add actions for the Transform menu."""
-# menu = self.addMenu(self.tr("Transform ..."))
+ if isFolder and self._item.documentAllowed():
+ action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
+ action.triggered.connect(qtLambda(self._convertFolderToFile, loDoc))
-# tree = self.projTree
-# tHandle = self._handle
+ if isFolder:
+ action = menu.addAction(self.tr("Convert to {0}").format(trNote))
+ action.triggered.connect(qtLambda(self._convertFolderToFile, loNote))
-# trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT])
-# trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE])
-# loDoc = nwItemLayout.DOCUMENT
-# loNote = nwItemLayout.NOTE
-# isDocFile = isFile and self._item.isDocumentLayout()
-# isNoteFile = isFile and self._item.isNoteLayout()
+ if self._children and isFile:
+ action = menu.addAction(self.tr("Merge Child Items into Self"))
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, False))
+ action = menu.addAction(self.tr("Merge Child Items into New"))
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, True))
-# if isNoteFile and self._item.documentAllowed():
-# action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
-# action.triggered.connect(qtLambda(self._changeItemLayout, loDoc))
+ if self._children and isFolder:
+ action = menu.addAction(self.tr("Merge Documents in Folder"))
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, True))
-# if isDocFile:
-# action = menu.addAction(self.tr("Convert to {0}").format(trNote))
-# action.triggered.connect(qtLambda(self._changeItemLayout, loNote))
+ if isFile:
+ action = menu.addAction(self.tr("Split Document by Headings"))
+ action.triggered.connect(qtLambda(self._tree.splitDocument, self._handle))
-# if isFolder and self._item.documentAllowed():
-# action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
-# action.triggered.connect(qtLambda(self._covertFolderToFile, loDoc))
+ return
-# if isFolder:
-# action = menu.addAction(self.tr("Convert to {0}").format(trNote))
-# action.triggered.connect(qtLambda(self._covertFolderToFile, loNote))
+ def _expandCollapse(self) -> None:
+ """Add actions for expand and collapse."""
+ action = self.addAction(self.tr("Expand All"))
+ action.triggered.connect(qtLambda(self._tree.expandFromIndex, self._indices[0]))
+ action = self.addAction(self.tr("Collapse All"))
+ action.triggered.connect(qtLambda(self._tree.collapseFromIndex, self._indices[0]))
+ return
-# if hasChild and isFile:
-# action = menu.addAction(self.tr("Merge Child Items into Self"))
-# action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, False))
-# action = menu.addAction(self.tr("Merge Child Items into New"))
-# action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, True))
+ def _deleteOrTrash(self) -> None:
+ """Add move to Trash action."""
+ if self._model.trashSelection(self._indices):
+ text = self.tr("Delete Permanently")
+ else:
+ text = self.tr("Move to Trash")
+ action = self.addAction(text)
+ action.triggered.connect(self._tree.processDeleteRequest)
+ return
-# if hasChild and isFolder:
-# action = menu.addAction(self.tr("Merge Documents in Folder"))
-# action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, True))
+ ##
+ # Private Slots
+ ##
-# if isFile:
-# action = menu.addAction(self.tr("Split Document by Headings"))
-# action.triggered.connect(qtLambda(tree._splitDocument, tHandle))
+ @pyqtSlot()
+ def _toggleItemActive(self) -> None:
+ """Toggle the active status of an item."""
+ if self._item.isFileType():
+ self._item.setActive(not self._item.isActive)
+ self._item.notifyToRefresh()
+ return
-# return
+ ##
+ # Internal Functions
+ ##
-# def _itemProcess(self, isFile: bool, isFolder: bool, isRoot: bool, hasChild: bool) -> None:
-# """Add actions for item processing."""
-# tree = self.projTree
-# tHandle = self._handle
-# if hasChild:
-# action = self.addAction(self.tr("Expand All"))
-# action.triggered.connect(qtLambda(tree.setExpandedFromHandle, tHandle, True))
-# action = self.addAction(self.tr("Collapse All"))
-# action.triggered.connect(qtLambda(tree.setExpandedFromHandle, tHandle, False))
+ def _iterItemActive(self, state: bool) -> None:
+ """Set the active status of multiple items."""
+ refresh = []
+ for node in self._model.nodes(self._indices):
+ if node.item.isFileType():
+ node.item.setActive(state)
+ refresh.append(node.item.itemHandle)
+ SHARED.project.tree.refreshItems(refresh)
+ return
-# action = self.addAction(self.tr("Duplicate"))
-# action.triggered.connect(qtLambda(tree._duplicateFromHandle, tHandle))
+ def _changeItemStatus(self, key: str) -> None:
+ """Set a new status value of an item."""
+ if self._item.isFileType():
+ self._item.setStatus(key)
+ self._item.notifyToRefresh()
+ return
-# if self._item.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
-# action = self.addAction(self.tr("Delete Permanently"))
-# action.triggered.connect(qtLambda(tree.permDeleteItem, tHandle))
-# else:
-# action = self.addAction(self.tr("Move to Trash"))
-# action.triggered.connect(qtLambda(tree.moveItemToTrash, tHandle))
+ def _iterSetItemStatus(self, key: str) -> None:
+ """Change the status value for multiple items."""
+ refresh = []
+ for node in self._model.nodes(self._indices):
+ if node.item.isNovelLike():
+ node.item.setStatus(key)
+ refresh.append(node.item.itemHandle)
+ SHARED.project.tree.refreshItems(refresh)
+ return
-# return
+ def _changeItemImport(self, key: str) -> None:
+ """Set a new importance value of an item."""
+ if self._item.isFileType():
+ self._item.setImport(key)
+ self._item.notifyToRefresh()
+ return
-# def _multiMoveToTrash(self) -> None:
-# """Add move to Trash action."""
-# areTrash = [i.itemClass == nwItemClass.TRASH for i in self._items]
-# if all(areTrash):
-# action = self.addAction(self.tr("Delete Permanently"))
-# action.triggered.connect(self._iterPermDelete)
-# elif not any(areTrash):
-# action = self.addAction(self.tr("Move to Trash"))
-# action.triggered.connect(self._iterMoveToTrash)
-# return
+ def _iterSetItemImport(self, key: str) -> None:
+ """Change the status value for multiple items."""
+ refresh = []
+ for node in self._model.nodes(self._indices):
+ if not node.item.isNovelLike():
+ node.item.setImport(key)
+ refresh.append(node.item.itemHandle)
+ SHARED.project.tree.refreshItems(refresh)
+ return
-# ##
-# # Private Slots
-# ##
+ def _changeItemLayout(self, itemLayout: nwItemLayout) -> None:
+ """Set a new item layout value of an item."""
+ if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
+ self._item.setLayout(nwItemLayout.DOCUMENT)
+ self._item.notifyToRefresh()
+ elif itemLayout == nwItemLayout.NOTE:
+ self._item.setLayout(nwItemLayout.NOTE)
+ self._item.notifyToRefresh()
+ return
-# @pyqtSlot()
-# def _iterMoveToTrash(self) -> None:
-# """Iterate through files and move them to Trash."""
-# if SHARED.question(self.tr("Move {0} items to Trash?").format(len(self._items))):
-# for tItem in self._items:
-# if tItem.isFileType() and tItem.itemClass != nwItemClass.TRASH:
-# self.projTree.moveItemToTrash(tItem.itemHandle, askFirst=False, flush=False)
-# # self.projTree.saveTreeOrder()
-# return
-
-# @pyqtSlot()
-# def _iterPermDelete(self) -> None:
-# """Iterate through files and delete them."""
-# if SHARED.question(self.projTree.trPermDelete.format(len(self._items))):
-# for tItem in self._items:
-# if tItem.isFileType() and tItem.itemClass == nwItemClass.TRASH:
-# self.projTree.permDeleteItem(tItem.itemHandle, askFirst=False, flush=False)
-# # self.projTree.saveTreeOrder()
-# return
-
-# @pyqtSlot()
-# def _toggleItemActive(self) -> None:
-# """Toggle the active status of an item."""
-# self._item.setActive(not self._item.isActive)
-# self.projTree.setTreeItemValues(self._item)
-# self.projTree._alertTreeChange(self._handle, flush=False)
-# return
-
-# ##
-# # Internal Functions
-# ##
-
-# def _iterItemActive(self, isActive: bool) -> None:
-# """Set the active status of multiple items."""
-# for tItem in self._items:
-# if tItem and tItem.isFileType():
-# tItem.setActive(isActive)
-# self.projTree.setTreeItemValues(tItem)
-# self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
-# return
-
-# def _changeItemStatus(self, key: str) -> None:
-# """Set a new status value of an item."""
-# self._item.setStatus(key)
-# self.projTree.setTreeItemValues(self._item)
-# self.projTree._alertTreeChange(self._handle, flush=False)
-# return
-
-# def _iterSetItemStatus(self, key: str) -> None:
-# """Change the status value for multiple items."""
-# for tItem in self._items:
-# if tItem and tItem.isNovelLike():
-# tItem.setStatus(key)
-# self.projTree.setTreeItemValues(tItem)
-# self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
-# return
-
-# def _changeItemImport(self, key: str) -> None:
-# """Set a new importance value of an item."""
-# self._item.setImport(key)
-# self.projTree.setTreeItemValues(self._item)
-# self.projTree._alertTreeChange(self._handle, flush=False)
-# return
-
-# def _iterSetItemImport(self, key: str) -> None:
-# """Change the status value for multiple items."""
-# for tItem in self._items:
-# if tItem and not tItem.isNovelLike():
-# tItem.setImport(key)
-# self.projTree.setTreeItemValues(tItem)
-# self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
-# return
-
-# def _changeItemLayout(self, itemLayout: nwItemLayout) -> None:
-# """Set a new item layout value of an item."""
-# if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
-# self._item.setLayout(nwItemLayout.DOCUMENT)
-# self.projTree.setTreeItemValues(self._item)
-# self.projTree._alertTreeChange(self._handle, flush=False)
-# elif itemLayout == nwItemLayout.NOTE:
-# self._item.setLayout(nwItemLayout.NOTE)
-# self.projTree.setTreeItemValues(self._item)
-# self.projTree._alertTreeChange(self._handle, flush=False)
-# return
-
-# def _covertFolderToFile(self, itemLayout: nwItemLayout) -> None:
-# """Convert a folder to a note or document."""
-# if self._item.isFolderType():
-# msgYes = SHARED.question(self.tr(
-# "Do you want to convert the folder to a {0}? "
-# "This action cannot be reversed."
-# ).format(trConst(nwLabels.LAYOUT_NAME[itemLayout])))
-# if msgYes and itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
-# self._item.setType(nwItemType.FILE)
-# self._item.setLayout(nwItemLayout.DOCUMENT)
-# self.projTree.setTreeItemValues(self._item)
-# self.projTree._alertTreeChange(self._handle, flush=False)
-# elif msgYes and itemLayout == nwItemLayout.NOTE:
-# self._item.setType(nwItemType.FILE)
-# self._item.setLayout(nwItemLayout.NOTE)
-# self.projTree.setTreeItemValues(self._item)
-# self.projTree._alertTreeChange(self._handle, flush=False)
-# else:
-# logger.info("Folder conversion cancelled")
-# return
+ def _convertFolderToFile(self, itemLayout: nwItemLayout) -> None:
+ """Convert a folder to a note or document."""
+ if self._item.isFolderType():
+ msgYes = SHARED.question(self.tr(
+ "Do you want to convert the folder to a {0}? "
+ "This action cannot be reversed."
+ ).format(trConst(nwLabels.LAYOUT_NAME[itemLayout])))
+ if msgYes and itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
+ self._item.setType(nwItemType.FILE)
+ self._item.setLayout(nwItemLayout.DOCUMENT)
+ self._item.notifyToRefresh()
+ elif msgYes and itemLayout == nwItemLayout.NOTE:
+ self._item.setType(nwItemType.FILE)
+ self._item.setLayout(nwItemLayout.NOTE)
+ self._item.notifyToRefresh()
+ else:
+ logger.info("Folder conversion cancelled")
+ return
diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py
index 5aaedacc..903b5f80 100644
--- a/tests/test_gui/test_gui_projtree.py
+++ b/tests/test_gui/test_gui_projtree.py
@@ -1321,11 +1321,11 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Click no on the dialog
with monkeypatch.context() as mp:
mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
- ctxMenu._covertFolderToFile(nwItemLayout.DOCUMENT)
+ ctxMenu._convertFolderToFile(nwItemLayout.DOCUMENT)
assert SHARED.project.tree[hNewFolderOne].isFolderType() # type: ignore
# Convert the first folder to a document
- ctxMenu._covertFolderToFile(nwItemLayout.DOCUMENT)
+ ctxMenu._convertFolderToFile(nwItemLayout.DOCUMENT)
assert SHARED.project.tree[hNewFolderOne].isFileType() # type: ignore
assert SHARED.project.tree[hNewFolderOne].isDocumentLayout() # type: ignore
@@ -1335,7 +1335,7 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
ctxMenu.buildSingleSelectMenu(False)
# Convert the second folder to a note
- ctxMenu._covertFolderToFile(nwItemLayout.NOTE)
+ ctxMenu._convertFolderToFile(nwItemLayout.NOTE)
assert SHARED.project.tree[hNewFolderTwo].isFileType() # type: ignore
assert SHARED.project.tree[hNewFolderTwo].isNoteLayout() # type: ignore
From bbe8683d59b03ccef7b58ca48668ddb76f2f55d2 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 20 Nov 2024 18:48:14 +0100
Subject: [PATCH 18/39] Re-implement project item duplication
---
novelwriter/core/coretools.py | 36 +++++++++---------
novelwriter/core/itemmodel.py | 1 +
novelwriter/core/project.py | 48 +----------------------
novelwriter/core/tree.py | 37 +++++-------------
novelwriter/gui/projtree.py | 61 +++++++++++-------------------
novelwriter/tools/manussettings.py | 14 ++-----
tests/tools.py | 8 ++--
7 files changed, 58 insertions(+), 147 deletions(-)
diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py
index 0f2f39c9..66bda582 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -258,29 +258,27 @@ class DocDuplicator:
# Methods
##
- def duplicate(self, items: list[str]) -> Iterable[tuple[str, str | None]]:
+ def duplicate(self, items: list[str]) -> list[str]:
"""Run through a list of items, duplicate them, and copy the
text content if they are documents.
"""
+ result = []
+ after = True
if items:
- nHandle = items[0]
hMap: dict[str, str | None] = {t: None for t in items}
for tHandle in items:
- newItem = self._project.tree.duplicate(tHandle)
- if newItem is None:
- return
- hMap[tHandle] = newItem.itemHandle
- if newItem.itemParent in hMap:
- newItem.setParent(hMap[newItem.itemParent])
- self._project.tree.updateItemData(newItem.itemHandle)
- if newItem.isFileType():
- newDoc = self._project.storage.getDocument(newItem.itemHandle)
- if newDoc.fileExists():
- return
- newDoc.writeDocument(self._project.storage.getDocumentText(tHandle))
- yield newItem.itemHandle, nHandle
- nHandle = None
- return
+ if oldItem := self._project.tree[tHandle]:
+ pHandle = hMap.get(oldItem.itemParent or "") or oldItem.itemParent
+ if newItem := self._project.tree.duplicate(tHandle, pHandle, after):
+ hMap[tHandle] = newItem.itemHandle
+ if newItem.isFileType():
+ self._project.copyFileContent(newItem.itemHandle, tHandle)
+ newItem.notifyToRefresh()
+ result.append(newItem.itemHandle)
+ after = False
+ else:
+ break
+ return result
class DocSearch:
@@ -509,8 +507,10 @@ class ProjectBuilder:
f"%Short: {bfNote}\n\n"
)
- # Also add the archive folder
+ # Also add the archive and trash folders
project.newRoot(nwItemClass.ARCHIVE)
+ project.tree.trash # Triggers the creation of Trash
+
project.saveProject()
project.closeProject()
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 5f59c505..a26b15c4 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -232,6 +232,7 @@ class ProjectNode:
else:
child.item.setParent(None)
child.item.setRoot(child.item.itemHandle)
+ child.item.setClassDefaults(child.item.itemClass)
return
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index cc51ce35..2132de79 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -26,12 +26,10 @@ from __future__ import annotations
import json
import logging
-from collections.abc import Iterable
from enum import Enum
from functools import partial
from pathlib import Path
from time import time
-from typing import TYPE_CHECKING
from PyQt5.QtCore import QCoreApplication
@@ -51,9 +49,6 @@ from novelwriter.core.tree import NWTree
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
-if TYPE_CHECKING: # pragma: no cover
- from novelwriter.core.item import NWItem
-
logger = logging.getLogger(__name__)
@@ -209,7 +204,7 @@ class NWProject:
text = self._storage.getDocumentText(sHandle)
self._storage.getDocument(tHandle).writeDocument(text)
sItem.setLayout(tItem.itemLayout)
- self._index.scanText(tHandle, text)
+ self._index.reIndexHandle(tHandle)
return True
@@ -494,47 +489,6 @@ class NWProject:
# Class Methods
##
- def iterProjectItems(self) -> Iterable[NWItem]:
- """This function ensures that the item tree loaded is sent to
- the GUI tree view in such a way that the tree can be built. That
- is, the parent item must be sent before its child. In principle,
- a proper XML file will already ensure that, but in the event the
- order has been altered, or a file is orphaned, this function is
- capable of handling it.
- """
- sentItems = set()
- iterItems = self._tree.handles()
- n = 0
- nMax = min(len(iterItems), 10000)
- while n < nMax:
- tHandle = iterItems[n]
- tItem = self._tree[tHandle]
- n += 1
- if tItem is None:
- # Technically a bug
- continue
- elif tItem.itemParent is None:
- # Item is a root, or already been identified as orphaned
- sentItems.add(tHandle)
- yield tItem
- elif tItem.itemParent in sentItems:
- # Item's parent has been sent, so all is fine
- sentItems.add(tHandle)
- yield tItem
- elif tItem.itemParent in iterItems:
- # Item's parent exists, but hasn't been sent yet, so add
- # it again to the end, but make sure this doesn't get
- # out hand, so we cap at 10000 items
- logger.warning("Item '%s' found before its parent", tHandle)
- iterItems.append(tHandle)
- nMax = min(len(iterItems), 10000)
- else:
- # Item is orphaned
- logger.error("Item '%s' has no parent in current tree", tHandle)
- tItem.setParent(None)
- yield tItem
- return
-
def updateWordCounts(self) -> None:
"""Update the total word count values."""
novel, notes = self._tree.sumWords()
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 59a0c3ac..207365f4 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -115,10 +115,6 @@ class NWTree:
del oldModel
return
- def handles(self) -> list[str]:
- """Returns a copy of the list of all the active handles."""
- return list(self._items.keys())
-
def add(self, item: NWItem, pos: int = -1) -> bool:
"""Add a project item into the project tree."""
if pHandle := item.itemParent:
@@ -128,6 +124,7 @@ class NWTree:
self._model.insertChild(node, index, pos)
self._nodes[item.itemHandle] = node
self._items[item.itemHandle] = item
+ self._project.setProjectChanged(True)
else:
logger.error("Could not locate parent of '%s'", item.itemHandle)
return False
@@ -136,6 +133,7 @@ class NWTree:
self._model.insertChild(node, QModelIndex(), pos)
self._nodes[item.itemHandle] = node
self._items[item.itemHandle] = item
+ self._project.setProjectChanged(True)
else:
logger.error("Invalid project item '%s'", item.itemHandle)
return False
@@ -181,18 +179,17 @@ class NWTree:
nwItem.setType(itemType)
nwItem.setClass(itemClass)
if self.add(nwItem, pos):
- self._project.setProjectChanged(True)
return tHandle
return None
- def duplicate(self, sHandle: str) -> NWItem | None:
+ def duplicate(self, sHandle: str, pHandle: str | None, putAfter: bool) -> NWItem | None:
"""Duplicate an item and set a new handle."""
- # sItem = self.__getitem__(sHandle)
- # if isinstance(sItem, NWItem):
- # nItem = NWItem.duplicate(sItem, self._makeHandle())
- # if self.append(nItem):
- # logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
- # return nItem
+ if sNode := self._nodes.get(sHandle):
+ nItem = NWItem.duplicate(sNode.item, self._makeHandle())
+ nItem.setParent(pHandle)
+ if self.add(nItem, (sNode.row() + 1) if putAfter else -1):
+ logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
+ return nItem
return None
def pack(self) -> list[dict]:
@@ -416,22 +413,6 @@ class NWTree:
yield node.item.itemHandle, node.item
return
- def isTrash(self, tHandle: str) -> bool:
- """Check if an item is in or is the trash folder."""
- # tItem = self.__getitem__(tHandle)
- # if tItem is None:
- # return True
- # if tItem.itemClass == nwItemClass.TRASH:
- # return True
- # if self._trash is not None:
- # if tHandle == self._trash:
- # return True
- # elif tItem.itemParent == self._trash:
- # return True
- # elif tItem.itemRoot == self._trash:
- # return True
- return False
-
def findRoot(self, itemClass: nwItemClass | None) -> str | None:
"""Find the first root item for a given class."""
for node in self._model.root.children:
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 1a883906..3827e8a0 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -41,7 +41,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import qtLambda
from novelwriter.constants import nwLabels, nwStyles, nwUnicode, trConst
-from novelwriter.core.coretools import DocMerger, DocSplitter
+from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter
from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
from novelwriter.dialogs.docmerge import GuiDocMerge
@@ -594,11 +594,11 @@ class GuiProjectTree(QTreeView):
def loadModel(self) -> None:
"""Load and prepare a new project model."""
- selModel = self.selectionModel()
+ # selModel = self.selectionModel()
self.setModel(SHARED.project.tree.model)
- if selModel:
- selModel.deleteLater()
- del selModel
+ # if selModel:
+ # selModel.deleteLater()
+ # del selModel
# Lock the column sizes
iPx = SHARED.theme.baseIconHeight
@@ -814,6 +814,22 @@ class GuiProjectTree(QTreeView):
return True
+ def duplicateFromHandle(self, tHandle: str) -> None:
+ """Duplicate the item hierarchy from a given item."""
+ itemTree = [tHandle]
+ itemTree.extend(SHARED.project.tree.subTree(tHandle))
+ if itemTree:
+ if len(itemTree) == 1:
+ question = self.tr("Do you want to duplicate this document?")
+ else:
+ question = self.tr("Do you want to duplicate this item and all child items?")
+ if SHARED.question(question):
+ docDup = DocDuplicator(SHARED.project)
+ dHandles = docDup.duplicate(itemTree)
+ if len(dHandles) != len(itemTree):
+ SHARED.warn(self.tr("Could not duplicate all items."))
+ return
+
##
# Events
##
@@ -1159,39 +1175,6 @@ class GuiProjectTree(QTreeView):
# self._scrollTimer.stop()
return
- ##
- # Internal Functions
- ##
-
- def _duplicateFromHandle(self, tHandle: str) -> bool:
- """Duplicate the item hierarchy from a given item."""
- # itemTree = self.getTreeFromHandle(tHandle)
- # nItems = len(itemTree)
- # if nItems == 0:
- # return False
- # elif nItems == 1:
- # question = self.tr("Do you want to duplicate this document?")
- # else:
- # question = self.tr("Do you want to duplicate this item and all child items?")
-
- # if not SHARED.question(question):
- # return False
-
- # docDup = DocDuplicator(SHARED.project)
- # dupCount = 0
- # for dHandle, nHandle in docDup.duplicate(itemTree):
- # SHARED.project.index.reIndexHandle(dHandle)
- # self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
- # self._alertTreeChange(dHandle, flush=False)
- # dupCount += 1
-
- # if dupCount != nItems:
- # SHARED.warn(self.tr("Could not duplicate all items."))
-
- # self.saveTreeOrder()
-
- return True
-
class _UpdatableMenu(QMenu):
@@ -1324,7 +1307,7 @@ class _TreeContextMenu(QMenu):
self._expandCollapse()
if isFile:
action = self.addAction(self.tr("Duplicate"))
- action.triggered.connect(qtLambda(self._tree._duplicateFromHandle, self._handle))
+ action.triggered.connect(qtLambda(self._tree.duplicateFromHandle, self._handle))
self._deleteOrTrash()
return
diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py
index 79dcf0bc..bae2b19d 100644
--- a/novelwriter/tools/manussettings.py
+++ b/novelwriter/tools/manussettings.py
@@ -419,8 +419,8 @@ class _FilterTab(NFixedPage):
logger.debug("Building project tree")
self._treeMap = {}
self.optTree.clear()
- for nwItem in SHARED.project.iterProjectItems():
-
+ for node in SHARED.project.tree.model.root.allChildren():
+ nwItem = node.item
tHandle = nwItem.itemHandle
pHandle = nwItem.itemParent
rHandle = nwItem.itemRoot
@@ -429,8 +429,6 @@ class _FilterTab(NFixedPage):
continue
isFile = nwItem.isFileType()
- isActive = nwItem.isActive
-
if nwItem.isInactiveClass() or not self._build.isRootAllowed(rHandle):
continue
@@ -439,18 +437,12 @@ class _FilterTab(NFixedPage):
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
)
- if isFile:
- iconName = "checked" if isActive else "unchecked"
- else:
- iconName = "noncheckable"
-
trItem = QTreeWidgetItem()
trItem.setIcon(self.C_NAME, itemIcon)
trItem.setText(self.C_NAME, nwItem.itemName)
trItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
trItem.setData(self.C_DATA, self.D_FILE, isFile)
- trItem.setIcon(self.C_ACTIVE, SHARED.theme.getIcon(iconName))
-
+ trItem.setIcon(self.C_ACTIVE, nwItem.getActiveStatus()[1])
trItem.setTextAlignment(self.C_NAME, QtAlignLeft)
if pHandle is None and nwItem.isRootType():
diff --git a/tests/tools.py b/tests/tools.py
index 504eb5b9..bb34ada2 100644
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -181,10 +181,10 @@ def buildTestProject(obj: object, projPath: Path) -> None:
# Creating a minimal project with a few root folders and a
# single chapter folder with a single file.
- nrHandle = project.newRoot(nwItemClass.NOVEL, "Novel")
- project.newRoot(nwItemClass.PLOT, "Plot")
- project.newRoot(nwItemClass.CHARACTER, "Characters")
- project.newRoot(nwItemClass.WORLD, "World")
+ nrHandle = project.newRoot(nwItemClass.NOVEL)
+ project.newRoot(nwItemClass.PLOT)
+ project.newRoot(nwItemClass.CHARACTER)
+ project.newRoot(nwItemClass.WORLD)
tdHandle = project.newFile("Title Page", nrHandle)
cfHandle = project.newFolder("New Chapter", nrHandle) or ""
From 4897f6ad69d33d65dfb3548c9fe6bc7a62d6a2de Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 20 Nov 2024 18:48:54 +0100
Subject: [PATCH 19/39] Update tests
---
tests/test_core/test_core_buildsettings.py | 2 +-
tests/test_core/test_core_coretools.py | 133 ++++++++++---------
tests/test_tools/test_tools_manussettings.py | 2 -
tests/test_tools/test_tools_noveldetails.py | 7 +-
4 files changed, 72 insertions(+), 72 deletions(-)
diff --git a/tests/test_core/test_core_buildsettings.py b/tests/test_core/test_core_buildsettings.py
index e41f5f4a..1ec73f29 100644
--- a/tests/test_core/test_core_buildsettings.py
+++ b/tests/test_core/test_core_buildsettings.py
@@ -223,7 +223,7 @@ def testCoreBuildSettings_Filters(mockGUI, fncPath: Path, mockRnd):
build = BuildSettings()
# Add some more items
- hArchRoot = project.newRoot(nwItemClass.ARCHIVE, "Archive")
+ hArchRoot = project.newRoot(nwItemClass.ARCHIVE)
hPlotDoc = project.newFile("Main Plot", C.hPlotRoot)
hCharDoc = project.newFile("Jane Doe", C.hCharRoot)
diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py
index 785b6b58..3638fafe 100644
--- a/tests/test_core/test_core_coretools.py
+++ b/tests/test_core/test_core_coretools.py
@@ -54,33 +54,42 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
# =====================
hChapter1 = project.newFile("Chapter 1", C.hNovelRoot)
- hSceneOne11 = project.newFile("Scene 1.1", hChapter1) # type: ignore
- hSceneOne12 = project.newFile("Scene 1.2", hChapter1) # type: ignore
- hSceneOne13 = project.newFile("Scene 1.3", hChapter1) # type: ignore
+ assert hChapter1 is not None
+
+ hSceneOne11 = project.newFile("Scene 1.1", hChapter1)
+ hSceneOne12 = project.newFile("Scene 1.2", hChapter1)
+ hSceneOne13 = project.newFile("Scene 1.3", hChapter1)
+ assert hSceneOne11 is not None
+ assert hSceneOne12 is not None
+ assert hSceneOne13 is not None
docText1 = "\n\n".join(ipsumText[0:2]) + "\n\n"
docText2 = "\n\n".join(ipsumText[1:3]) + "\n\n"
docText3 = "\n\n".join(ipsumText[2:4]) + "\n\n"
docText4 = "\n\n".join(ipsumText[3:5]) + "\n\n"
- project.writeNewFile(hChapter1, 2, True, docText1) # type: ignore
- project.writeNewFile(hSceneOne11, 3, True, docText2) # type: ignore
- project.writeNewFile(hSceneOne12, 3, True, docText3) # type: ignore
- project.writeNewFile(hSceneOne13, 3, True, docText4) # type: ignore
+ project.writeNewFile(hChapter1, 2, True, docText1)
+ project.writeNewFile(hSceneOne11, 3, True, docText2)
+ project.writeNewFile(hSceneOne12, 3, True, docText3)
+ project.writeNewFile(hSceneOne13, 3, True, docText4)
# Basic Checks
# ============
docMerger = DocMerger(project)
+ assert docMerger.targetHandle is None
# No writing without a target set
assert docMerger.writeTargetDoc() is False
+ assert docMerger.targetHandle is None
# Cannot append invalid handle
- assert docMerger.appendText(C.hInvalid, True, "Merge") is False
+ docMerger.appendText(C.hInvalid, True, "Merge")
+ assert docMerger._text == []
# Cannot create new target from invalid handle
- assert docMerger.newTargetDoc(C.hInvalid, "Test") is None
+ docMerger.newTargetDoc(C.hInvalid, "Test")
+ assert docMerger.targetHandle is None
# Merge to New
# ============
@@ -89,12 +98,17 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
testFile = tstPaths.outDir / "coreDocTools_DocMerger_0000000000014.nwd"
compFile = tstPaths.refDir / "coreDocTools_DocMerger_0000000000014.nwd"
- assert docMerger.newTargetDoc(hChapter1, "All of Chapter 1") == "0000000000014" # type: ignore
+ docMerger.newTargetDoc(hChapter1, "All of Chapter 1")
+ assert docMerger.targetHandle == "0000000000014"
- assert docMerger.appendText(hChapter1, True, "Merge") is True # type: ignore
- assert docMerger.appendText(hSceneOne11, True, "Merge") is True # type: ignore
- assert docMerger.appendText(hSceneOne12, True, "Merge") is True # type: ignore
- assert docMerger.appendText(hSceneOne13, True, "Merge") is True # type: ignore
+ docMerger.appendText(hChapter1, True, "Merge")
+ assert len(docMerger._text) == 1
+ docMerger.appendText(hSceneOne11, True, "Merge")
+ assert len(docMerger._text) == 2
+ docMerger.appendText(hSceneOne12, True, "Merge")
+ assert len(docMerger._text) == 3
+ docMerger.appendText(hSceneOne13, True, "Merge")
+ assert len(docMerger._text) == 4
# Block writing and check error handling
with monkeypatch.context() as mp:
@@ -115,11 +129,14 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
testFile = tstPaths.outDir / "coreDocTools_DocMerger_0000000000010.nwd"
compFile = tstPaths.refDir / "coreDocTools_DocMerger_0000000000010.nwd"
- docMerger.setTargetDoc(hChapter1) # type: ignore
+ docMerger.setTargetDoc(hChapter1)
- assert docMerger.appendText(hSceneOne11, True, "Merge") is True # type: ignore
- assert docMerger.appendText(hSceneOne12, True, "Merge") is True # type: ignore
- assert docMerger.appendText(hSceneOne13, True, "Merge") is True # type: ignore
+ docMerger.appendText(hSceneOne11, True, "Merge")
+ assert len(docMerger._text) == 1
+ docMerger.appendText(hSceneOne12, True, "Merge")
+ assert len(docMerger._text) == 2
+ docMerger.appendText(hSceneOne13, True, "Merge")
+ assert len(docMerger._text) == 3
assert docMerger.writeTargetDoc() is True
copyfile(saveFile, testFile)
@@ -180,50 +197,46 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText)
docSplitter.splitDocument(splitData, docRaw) # type: ignore
for i, (lineNo, hLevel, hLabel) in enumerate(splitData):
assert docSplitter._rawData[i] == (docRaw[lineNo:lineNo+4], hLevel, hLabel)
+ assert project.tree.subTree(C.hNovelRoot) == [
+ "000000000000c", "000000000000d", "000000000000e", "000000000000f", "0000000000010",
+ ]
# Test flat split into same parent
docSplitter.setParentItem(C.hNovelRoot)
assert docSplitter._inFolder is False
+ assert docSplitter._parHandle is not None
# Cause write error on all chunks
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
- resStatus = []
- for status, _, _ in docSplitter.writeDocuments(False):
- resStatus.append(status)
- assert not any(resStatus)
+ assert not any(docSplitter.writeDocuments(False))
assert docSplitter.getError() == "OSError: Mock OSError"
+ assert project.tree.subTree(C.hNovelRoot) == [
+ "000000000000c", "000000000000d", "000000000000e", "000000000000f", "0000000000010",
+ "0000000000011", "0000000000012", "0000000000013", "0000000000014", "0000000000015",
+ "0000000000016", "0000000000017", "0000000000018", "0000000000019", "000000000001a",
+ ]
# Generate as flat structure in root folder
- resStatus = []
- resDocHandle = []
- resNearHandle = []
- for status, dHandle, nHandle in docSplitter.writeDocuments(False):
- resStatus.append(status)
- resDocHandle.append(dHandle)
- resNearHandle.append(nHandle)
+ resStatus = list(docSplitter.writeDocuments(False))
+ resDocHandle = project.tree.subTree(C.hNovelRoot)
assert all(resStatus)
assert resDocHandle == [
+ "000000000000c", "000000000000d", "000000000000e", "000000000000f", "0000000000010",
+ "0000000000011", "0000000000012", "0000000000013", "0000000000014", "0000000000015",
+ "0000000000016", "0000000000017", "0000000000018", "0000000000019", "000000000001a",
"000000000001b", "000000000001c", "000000000001d", "000000000001e", "000000000001f",
"0000000000020", "0000000000021", "0000000000022", "0000000000023", "0000000000024",
]
- assert resNearHandle == [ # Each document should be next to the previous one
- hSplitDoc, "000000000001b", "000000000001c", "000000000001d", "000000000001e",
- "000000000001f", "0000000000020", "0000000000021", "0000000000022", "0000000000023",
- ]
# Generate as hierarchy in new folder
- hSplitFolder = docSplitter.newParentFolder(C.hNovelRoot, "Split Folder")
+ docSplitter.newParentFolder(C.hNovelRoot, "Split Folder")
assert docSplitter._inFolder is True
+ assert docSplitter._parHandle is not None
- resStatus = []
- resDocHandle = []
- resNearHandle = []
- for status, dHandle, nHandle in docSplitter.writeDocuments(True):
- resStatus.append(status)
- resDocHandle.append(dHandle)
- resNearHandle.append(nHandle)
+ resStatus = list(docSplitter.writeDocuments(True))
+ resDocHandle = project.tree.subTree(docSplitter._parHandle)
assert all(resStatus)
assert resDocHandle == [
@@ -238,18 +251,6 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText)
"000000000002e", # Scene Four
"000000000002f", # Scene Five
]
- assert resNearHandle == [
- hSplitFolder, # Part One is after Split Folder
- "0000000000026", # Chapter One is after Part One
- "0000000000027", # Scene One is after Chapter One
- "0000000000028", # Section One is after Scene One
- "0000000000029", # Section Two is after Section One
- "0000000000028", # Scene Two is after Scene One
- "0000000000027", # Chapter Two is after Chapter One
- "000000000002c", # Scene Three is after Chapter Two
- "000000000002d", # Scene Four is after Scene Three
- "000000000002e", # Scene Five is after Scene Four
- ]
# Check that status and importance has been preserved
for rHandle in resDocHandle:
@@ -292,9 +293,9 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
# A new copy is created
assert list(dup.duplicate([C.hSceneDoc])) == [
- ("0000000000010", C.hSceneDoc), # The Scene
+ "0000000000010" # The Scene
]
- assert project.tree._order == [
+ assert list(project.tree._items.keys()) == [
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000010",
@@ -311,11 +312,11 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
# The folder is copied, with two docs
assert list(dup.duplicate([C.hChapterDir, C.hChapterDoc, C.hSceneDoc])) == [
- ("0000000000011", C.hChapterDir), # The Folder
- ("0000000000012", None), # The Chapter
- ("0000000000013", None), # The Scene
+ "0000000000011", # The Folder
+ "0000000000012", # The Chapter
+ "0000000000013", # The Scene
]
- assert project.tree._order == [
+ assert list(project.tree._items.keys()) == [
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000010",
@@ -340,13 +341,13 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
assert list(dup.duplicate(
[C.hNovelRoot, C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc]
)) == [
- ("0000000000014", C.hNovelRoot), # The Root
- ("0000000000015", None), # The Title Page
- ("0000000000016", None), # The Folder
- ("0000000000017", None), # The Chapter
- ("0000000000018", None), # The Scene
+ "0000000000014", # The Root
+ "0000000000015", # The Title Page
+ "0000000000016", # The Folder
+ "0000000000017", # The Chapter
+ "0000000000018", # The Scene
]
- assert project.tree._order == [
+ assert list(project.tree._items.keys()) == [
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000010",
@@ -388,7 +389,7 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
assert isinstance(content, Path)
(content / "0000000000019.nwd").touch()
assert (content / "0000000000019.nwd").exists()
- assert list(dup.duplicate([C.hChapterDoc, C.hSceneDoc])) == []
+ assert list(dup.duplicate([C.hChapterDoc, C.hSceneDoc])) == ["0000000000019", "000000000001a"]
# Save and Close
project.saveProject()
diff --git a/tests/test_tools/test_tools_manussettings.py b/tests/test_tools/test_tools_manussettings.py
index 8adfd824..fcbda573 100644
--- a/tests/test_tools/test_tools_manussettings.py
+++ b/tests/test_tools/test_tools_manussettings.py
@@ -121,8 +121,6 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
hPlotDoc = SHARED.project.newFile("Main Plot", C.hPlotRoot)
hCharDoc = SHARED.project.newFile("Jane Doe", C.hCharRoot)
- nwGUI.projView.projTree.revealNewTreeItem(hPlotDoc)
- nwGUI.projView.projTree.revealNewTreeItem(hCharDoc)
SHARED.project.tree[hPlotDoc].setActive(False) # type: ignore
# Create the dialog and populate it
diff --git a/tests/test_tools/test_tools_noveldetails.py b/tests/test_tools/test_tools_noveldetails.py
index 98cbf22e..4cc2b12a 100644
--- a/tests/test_tools/test_tools_noveldetails.py
+++ b/tests/test_tools/test_tools_noveldetails.py
@@ -38,12 +38,13 @@ def testToolNovelDetails_Main(qtbot, nwGUI, prjLipsum, ipsumText):
# Add a second Novel folder
project = SHARED.project
secondText = "#! Second\n\n" + "\n\n".join(ipsumText)
- sHandle = project.newRoot(nwItemClass.NOVEL, "Second")
+ sHandle = project.newRoot(nwItemClass.NOVEL)
dHandle = project.newFile("Document", sHandle)
+ if item := project.tree[sHandle]:
+ item.setName("Second")
+
project.storage.getDocument(dHandle).writeDocument(secondText)
project.index.reIndexHandle(dHandle)
- nwGUI.projView.projTree.revealNewTreeItem(sHandle)
- nwGUI.projView.projTree.revealNewTreeItem(dHandle)
# Create the dialog
nwGUI.mainMenu.aNovelDetails.activate(QAction.ActionEvent.Trigger)
From 26b4da9eb4ad0661dc8596c7e55331c12dbb3148 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 20 Nov 2024 19:58:34 +0100
Subject: [PATCH 20/39] Fix most tests with minor issues
---
tests/lipsum/nwProject.nwx | 12 ++-
.../coreProject_NewFileFolder_nwProject.nwx | 24 ++---
.../coreProject_NewRoot_nwProject.nwx | 24 ++---
.../coreTools_DocDuplicator_nwProject.nwx | 42 +++++----
.../reference/fmtToDocX_SaveDocument_core.xml | 6 +-
tests/test_core/test_core_docbuild.py | 24 ++---
tests/test_core/test_core_index.py | 25 +++--
tests/test_core/test_core_project.py | 94 +------------------
tests/test_dialogs/test_dlg_docsplit.py | 3 -
tests/test_formats/test_fmt_todocx.py | 2 +-
tests/test_gui/test_gui_doceditor.py | 2 -
tests/test_gui/test_gui_docviewer.py | 4 +-
tests/test_gui/test_gui_docviewerpanel.py | 4 +-
tests/test_gui/test_gui_guimain.py | 3 +-
tests/test_gui/test_gui_mainmenu.py | 1 -
tests/test_gui/test_gui_noveltree.py | 2 +-
tests/test_gui/test_gui_outline.py | 2 -
tests/test_gui/test_gui_projtree.py | 17 ++--
tests/test_gui/test_gui_statusbar.py | 1 -
tests/test_tools/test_tools_manussettings.py | 8 ++
tests/tools.py | 1 +
21 files changed, 109 insertions(+), 192 deletions(-)
diff --git a/tests/lipsum/nwProject.nwx b/tests/lipsum/nwProject.nwx
index 8da7d6cc..8287a1e5 100644
--- a/tests/lipsum/nwProject.nwx
+++ b/tests/lipsum/nwProject.nwx
@@ -1,6 +1,6 @@
-
-
+
+
Lorem Ipsum
lipsum.com
@@ -25,13 +25,13 @@
Finished
- New
+ New
Minor
Major
Main
-
+
-
Novel
@@ -116,5 +116,9 @@
Ancient Europe
+ -
+
+ Trash
+
diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
index 4aba7481..fbfa9e9c 100644
--- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx
+++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
@@ -33,18 +33,6 @@
Novel
- -
-
- Plot
-
- -
-
- Characters
-
- -
-
- World
-
-
Title Page
@@ -69,6 +57,14 @@
Hello
+ -
+
+ Plot
+
+ -
+
+ Characters
+
-
Jane
@@ -77,5 +73,9 @@
John
+ -
+
+ Locations
+
diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx
index 4bfbb568..1a6938aa 100644
--- a/tests/reference/coreProject_NewRoot_nwProject.nwx
+++ b/tests/reference/coreProject_NewRoot_nwProject.nwx
@@ -33,18 +33,6 @@
Novel
- -
-
- Plot
-
- -
-
- Characters
-
- -
-
- World
-
-
Title Page
@@ -61,6 +49,18 @@
New Scene
+ -
+
+ Plot
+
+ -
+
+ Characters
+
+ -
+
+ Locations
+
-
Novel
diff --git a/tests/reference/coreTools_DocDuplicator_nwProject.nwx b/tests/reference/coreTools_DocDuplicator_nwProject.nwx
index 5f6685a6..431cc119 100644
--- a/tests/reference/coreTools_DocDuplicator_nwProject.nwx
+++ b/tests/reference/coreTools_DocDuplicator_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -16,7 +16,7 @@
- New
+ New
Note
Draft
Finished
@@ -28,23 +28,11 @@
Main
-
+
-
Novel
- -
-
- Plot
-
- -
-
- Characters
-
- -
-
- World
-
-
Title Page
@@ -57,6 +45,10 @@
New Chapter
+ -
+
+ New Chapter
+
-
New Scene
@@ -65,6 +57,10 @@
New Scene
+ -
+
+ New Scene
+
-
New Chapter
@@ -77,7 +73,7 @@
New Scene
- -
+
-
Novel
@@ -97,9 +93,17 @@
New Scene
- -
-
- New Chapter
+
-
+
+ Plot
+
+ -
+
+ Characters
+
+ -
+
+ Locations
diff --git a/tests/reference/fmtToDocX_SaveDocument_core.xml b/tests/reference/fmtToDocX_SaveDocument_core.xml
index 2d07ced8..a46abe5d 100644
--- a/tests/reference/fmtToDocX_SaveDocument_core.xml
+++ b/tests/reference/fmtToDocX_SaveDocument_core.xml
@@ -1,10 +1,10 @@
- 2024-10-28T20:12:57
- 2024-10-28T20:12:57
+ 2024-11-20T19:45:15
+ 2024-11-20T19:45:15
lipsum.com
Lorem Ipsum
en_GB
- 50
+ 51
lipsum.com
diff --git a/tests/test_core/test_core_docbuild.py b/tests/test_core/test_core_docbuild.py
index 166b281a..a8d85dcc 100644
--- a/tests/test_core/test_core_docbuild.py
+++ b/tests/test_core/test_core_docbuild.py
@@ -93,7 +93,7 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP
assert docBuild._outline is True
- assert len(docBuild) == 21
+ assert len(docBuild) == 22
# Check FODT Build
# ================
@@ -151,7 +151,7 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP
with monkeypatch.context() as mp:
mp.setattr("novelwriter.formats.toodt.ToOdt.doConvert", causeException)
- assert len(docBuild) == 21
+ assert len(docBuild) == 22
count = 0
error = []
@@ -196,7 +196,7 @@ def testCoreDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths):
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
- assert len(docBuild) == 21
+ assert len(docBuild) == 22
# Check HTML5 Build
# =================
@@ -264,7 +264,7 @@ def testCoreDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
- assert len(docBuild) == 21
+ assert len(docBuild) == 22
# Check Standard Markdown Build
# =============================
@@ -332,7 +332,7 @@ def testCoreDocBuild_DocX(mockGUI, prjLipsum, fncPath):
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
- assert len(docBuild) == 21
+ assert len(docBuild) == 22
# Check Build
# ===========
@@ -365,7 +365,7 @@ def testCoreDocBuild_PDF(mockGUI, prjLipsum, fncPath):
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
- assert len(docBuild) == 21
+ assert len(docBuild) == 22
# Check Build
# ===========
@@ -397,7 +397,7 @@ def testCoreDocBuild_NWD(mockGUI, prjLipsum, fncPath, tstPaths):
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
- assert len(docBuild) == 21
+ assert len(docBuild) == 22
# Check NWD Build
# ===============
@@ -474,8 +474,8 @@ def testCoreDocBuild_Custom(mockGUI, fncPath: Path):
# Add an invalid item to the project
nHandle = "0123456789def"
- project.tree._order.append(nHandle)
- project.tree._tree[nHandle] = None # type: ignore
+ project.tree._items[nHandle] = None # type: ignore
+ project.tree._nodes[nHandle] = None # type: ignore
docBuild.queueAll()
assert len(docBuild) == 8
@@ -517,12 +517,6 @@ def testCoreDocBuild_IterBuild(mockGUI, fncPath: Path, mockRnd):
project.storage.getDocument(hPlotDoc).writeDocument("# Main Plot\n**Text**")
project.storage.getDocument(hCharDoc).writeDocument("# Jane Doe\n~~Text~~")
- # Fix project order as this has never been opened in a GUI
- project.tree.setOrder([ # type: ignore
- C.hNovelRoot, C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
- C.hPlotRoot, hPlotDoc, C.hCharRoot, hCharDoc, C.hWorldRoot
- ])
-
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
assert len(docBuild) == 10
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index f4562d5b..94eea4e4 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -59,6 +59,7 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
"67a8707f2f249": False, # Character ROOT
"6c6afb1247750": False, # Plot ROOT
"60bdf227455cc": False, # World ROOT
+ "1ace7ab1a0fc6": False, # Trash ROOT
}
for tItem in project.tree:
index.reIndexHandle(tItem.itemHandle)
@@ -346,6 +347,7 @@ def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
# Some items for fail to scan tests
dHandle = project.newFolder("Folder", C.hNovelRoot)
xHandle = project.newFile("No Layout", C.hNovelRoot)
+ xIndex = project.tree.model.indexFromHandle(xHandle)
assert isinstance(dHandle, str)
assert isinstance(xHandle, str)
@@ -363,10 +365,13 @@ def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
assert index.scanText(xHandle, "Hello World!") is False
# Create the trash folder
- tHandle = project.trashFolder()
+ tNode = project.tree.trash
+ assert tNode is not None
+ tIndex = project.tree.model.indexFromNode(tNode)
+ tHandle = tNode.item.itemHandle
assert project.tree[tHandle] is not None
- xItem.setParent(tHandle)
- project.tree.updateItemData(xItem.itemHandle)
+
+ project.tree.model.multiMove([xIndex], tIndex)
assert xItem.itemRoot == tHandle
assert xItem.itemClass == nwItemClass.TRASH
assert index.scanText(xHandle, "## Hello World!") is True
@@ -374,9 +379,10 @@ def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
# Create the archive root
aHandle = project.newRoot(nwItemClass.ARCHIVE)
+ aIndex = project.tree.model.indexFromHandle(aHandle)
assert project.tree[aHandle] is not None
- xItem.setParent(aHandle)
- project.tree.updateItemData(xItem.itemHandle)
+
+ project.tree.model.multiMove([xIndex], aIndex)
assert index.scanText(xHandle, "### Hello World!") is True
assert xItem.mainHeading == "H3"
@@ -901,7 +907,8 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
]
# Add a fake handle to the tree and check that it's ignored
- project.tree._order.append("0000000000000")
+ project.tree._items["0000000000000"] = None # type: ignore
+ project.tree._nodes["0000000000000"] = None # type: ignore
assert [(h, t) for h, t, _ in index._itemIndex.iterNovelStructure(activeOnly=False)] == [
(C.hTitlePage, "T0001"),
(C.hChapterDoc, "T0001"),
@@ -912,7 +919,8 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
(sHandle, "T0001"),
(tHandle, "T0001"),
]
- project.tree._order.remove("0000000000000")
+ del project.tree._items["0000000000000"]
+ del project.tree._nodes["0000000000000"]
# Extract stats
assert index.getNovelWordCount(activeOnly=False) == 43
@@ -1281,7 +1289,8 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
assert nStruct[0][0] == uHandle
# Inject garbage into tree
- project.tree._order.append("stuff")
+ project.tree._items["stuff"] = None # type: ignore
+ project.tree._nodes["stuff"] = None # type: ignore
nStruct = list(itemIndex.iterNovelStructure())
assert len(nStruct) == 4
assert nStruct[0][0] == nHandle
diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py
index 30be4157..3cda98d4 100644
--- a/tests/test_core/test_core_project.py
+++ b/tests/test_core/test_core_project.py
@@ -29,12 +29,8 @@ from PyQt5.QtWidgets import QMessageBox
from novelwriter import CONFIG, SHARED
from novelwriter.constants import nwFiles
-from novelwriter.core.index import NWIndex
-from novelwriter.core.item import NWItem
-from novelwriter.core.options import OptionState
from novelwriter.core.project import NWProject, NWProjectState
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
-from novelwriter.core.tree import NWTree
from novelwriter.enum import nwItemClass
from tests.mocked import causeOSError
@@ -284,7 +280,7 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd):
# Fail checking items should still pass
with monkeypatch.context() as mp:
- mp.setattr("novelwriter.core.tree.NWTree.updateItemData", lambda *a: False)
+ mp.setattr("novelwriter.core.tree.NWTree.checkConsistency", lambda *a: (1, 0))
assert project.openProject(fncPath, clearLock=True) is True
# Trigger an index rebuild
@@ -326,70 +322,6 @@ def testCoreProject_Save(monkeypatch, mockGUI, mockRnd, fncPath):
project.closeProject()
-@pytest.mark.core
-def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd):
- """Test helper functions for the project folder."""
- project = NWProject()
- buildTestProject(project, fncPath)
-
- # Storage Objects
- assert isinstance(project.index, NWIndex)
- assert isinstance(project.tree, NWTree)
- assert isinstance(project.options, OptionState)
-
- # Move Novel ROOT to after its files
- oldOrder = [
- C.hNovelRoot,
- C.hPlotRoot,
- C.hCharRoot,
- C.hWorldRoot,
- C.hTitlePage,
- C.hChapterDir,
- C.hChapterDoc,
- C.hSceneDoc,
- ]
- newOrder = [
- C.hTitlePage,
- C.hChapterDoc,
- C.hSceneDoc,
- C.hChapterDir,
- C.hNovelRoot,
- C.hPlotRoot,
- C.hCharRoot,
- C.hWorldRoot,
- ]
- assert project.tree.handles() == oldOrder
- project.setTreeOrder(newOrder)
- assert project.tree.handles() == newOrder
-
- # Add a non-existing item
- project.tree._order.append(C.hInvalid)
-
- # Add an item with a non-existent parent
- nHandle = project.newFile("Test File", C.hChapterDir)
- nItem = project.tree[nHandle]
- assert isinstance(nItem, NWItem)
- nItem.setParent("cba9876543210")
- assert nItem.itemParent == "cba9876543210"
-
- retOrder = []
- for tItem in project.iterProjectItems():
- retOrder.append(tItem.itemHandle)
-
- assert retOrder == [
- C.hNovelRoot,
- C.hPlotRoot,
- C.hCharRoot,
- C.hWorldRoot,
- nHandle,
- C.hTitlePage,
- C.hChapterDir,
- C.hChapterDoc,
- C.hSceneDoc,
- ]
- assert nItem.itemParent is None
-
-
@pytest.mark.core
def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
"""Test other project class methods and functions."""
@@ -411,13 +343,6 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
mp.setattr("novelwriter.core.project.time", lambda: 1600005600)
assert project.currentEditTime == 6834
- # Trash folder
- # Should create on first call, and just returned on later calls
- hTrash = "0000000000010"
- assert project.tree[hTrash] is None
- assert project.trashFolder() == hTrash
- assert project.trashFolder() == hTrash
-
# Spell check
project.setProjectChanged(False)
project.data.setSpellCheck(True)
@@ -480,23 +405,6 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
assert project.data.autoReplace == {"A": "B", "C": "D"}
assert project.projChanged
- # Change project tree order
- oldOrder = [
- "0000000000008", "0000000000009", "000000000000a",
- "000000000000b", "000000000000c", "000000000000d",
- "000000000000e", "000000000000f", "0000000000010",
- ]
- newOrder = [
- "000000000000b", "000000000000c", "000000000000d",
- "0000000000008", "0000000000009", "000000000000a",
- "000000000000e", "000000000000f",
- ]
- assert project.tree.handles() == oldOrder
- project.setTreeOrder(newOrder)
- assert project.tree.handles() == newOrder
- project.setTreeOrder(oldOrder)
- assert project.tree.handles() == oldOrder
-
@pytest.mark.core
def testCoreProject_Backup(monkeypatch, mockGUI, fncPath, tstPaths):
diff --git a/tests/test_dialogs/test_dlg_docsplit.py b/tests/test_dialogs/test_dlg_docsplit.py
index 01ad8436..e8709d90 100644
--- a/tests/test_dialogs/test_dlg_docsplit.py
+++ b/tests/test_dialogs/test_dlg_docsplit.py
@@ -38,8 +38,6 @@ def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
buildTestProject(nwGUI, projPath)
project = SHARED.project
- projTree = nwGUI.projView.projTree
-
docText = (
"Text\n\n"
"##! Prologue\n\nText\n\n"
@@ -58,7 +56,6 @@ def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
hSplitDoc = project.newFile("Split Doc", C.hNovelRoot)
assert hSplitDoc is not None
project.writeNewFile(hSplitDoc, 1, True, docText)
- projTree.revealNewTreeItem(hSplitDoc, nHandle=C.hNovelRoot, wordCount=True)
docText = f"# Split Doc\n\n{docText}"
diff --git a/tests/test_formats/test_fmt_todocx.py b/tests/test_formats/test_fmt_todocx.py
index 53e9fd58..4acab27a 100644
--- a/tests/test_formats/test_fmt_todocx.py
+++ b/tests/test_formats/test_fmt_todocx.py
@@ -661,7 +661,7 @@ def testFmtToDocX_SaveDocument(mockGUI, prjLipsum, fncPath, tstPaths):
(0, True), (1, True), (2, True), (3, True), (4, True), (5, False),
(6, True), (7, True), (8, True), (9, False), (10, False), (11, True),
(12, True), (13, True), (14, True), (15, True), (16, True), (17, True),
- (18, True), (19, True), (20, True),
+ (18, True), (19, True), (20, True), (21, False),
]
assert docPath.exists()
diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py
index 0f1fc5c2..5ca249ae 100644
--- a/tests/test_gui/test_gui_doceditor.py
+++ b/tests/test_gui/test_gui_doceditor.py
@@ -1615,7 +1615,6 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert nwGUI.openDocument(cHandle) is True
docEditor.replaceText(text)
nwGUI.saveDocument()
- assert nwGUI.projView.projTree.revealNewTreeItem(cHandle)
# Follow Tag
# ==========
@@ -1715,7 +1714,6 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd):
assert nwGUI.openDocument(cHandle) is True
docEditor.replaceText(text)
nwGUI.saveDocument()
- assert nwGUI.projView.projTree.revealNewTreeItem(cHandle)
docEditor.replaceText("")
completer = docEditor._completer
diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py
index 4f391cd8..afcad9a2 100644
--- a/tests/test_gui/test_gui_docviewer.py
+++ b/tests/test_gui/test_gui_docviewer.py
@@ -52,8 +52,8 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
assert docViewer.loadText("b3643d0f92e32") is False
# Middle-click the selected item
- item = nwGUI.projView.projTree._getTreeItem("88243afbe5ed8")
- rect = nwGUI.projView.projTree.visualItemRect(item)
+ index = SHARED.project.tree.model.indexFromHandle("88243afbe5ed8")
+ rect = nwGUI.projView.projTree.visualRect(index)
qtbot.mouseClick(nwGUI.projView.projTree.viewport(), Qt.MidButton, pos=rect.center())
assert docViewer.docHandle == "88243afbe5ed8"
diff --git a/tests/test_gui/test_gui_docviewerpanel.py b/tests/test_gui/test_gui_docviewerpanel.py
index e6c1ca7e..5456a427 100644
--- a/tests/test_gui/test_gui_docviewerpanel.py
+++ b/tests/test_gui/test_gui_docviewerpanel.py
@@ -39,7 +39,7 @@ def testGuiViewerPanel_BackRefs(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
buildTestProject(nwGUI, projPath)
projTree = nwGUI.projView.projTree
- projTree._getTreeItem(C.hChapterDir).setExpanded(True)
+ projTree.expandAll()
viewPanel = nwGUI.docViewerPanel
tabBackRefs = viewPanel.tabBackRefs
@@ -128,7 +128,7 @@ def testGuiViewerPanel_Tags(qtbot, monkeypatch, caplog, nwGUI, projPath, mockRnd
buildTestProject(nwGUI, projPath)
projTree = nwGUI.projView.projTree
- projTree._getTreeItem(C.hChapterDir).setExpanded(True)
+ projTree.expandAll()
viewPanel = nwGUI.docViewerPanel
nwGUI.openDocument(C.hSceneDoc)
diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py
index 8bdbc45e..596183f6 100644
--- a/tests/test_gui/test_gui_guimain.py
+++ b/tests/test_gui/test_gui_guimain.py
@@ -144,7 +144,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
with monkeypatch.context() as mp:
mp.setattr(GuiProjectTree, "hasFocus", lambda *a: True)
assert nwGUI.docEditor.docHandle is None
- nwGUI.projView.projTree._getTreeItem(sHandle).setSelected(True)
+ nwGUI.projView.projTree.setSelectedHandle(sHandle)
nwGUI._keyPressReturn()
assert nwGUI.docEditor.docHandle == sHandle
nwGUI.closeDocument()
@@ -711,7 +711,6 @@ def testGuiMain_Features(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
cHandle = SHARED.project.newFile("Jane", C.hCharRoot)
newDoc = SHARED.project.storage.getDocument(cHandle)
newDoc.writeDocument("# Jane\n\n@tag: Jane\n\n")
- nwGUI.projView.projTree.revealNewTreeItem(cHandle)
nwGUI.rebuildIndex(beQuiet=True)
assert SHARED.focusMode is False
diff --git a/tests/test_gui/test_gui_mainmenu.py b/tests/test_gui/test_gui_mainmenu.py
index fced796e..507fad2a 100644
--- a/tests/test_gui/test_gui_mainmenu.py
+++ b/tests/test_gui/test_gui_mainmenu.py
@@ -374,7 +374,6 @@ def testGuiMainMenu_Insert(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd
"""Test the Insert menu."""
buildTestProject(nwGUI, projPath)
- assert nwGUI.projView.projTree._getTreeItem(C.hSceneDoc) is not None
assert nwGUI.openDocument(C.hSceneDoc) is True
mainMenu = nwGUI.mainMenu
docEditor = nwGUI.docEditor
diff --git a/tests/test_gui/test_gui_noveltree.py b/tests/test_gui/test_gui_noveltree.py
index f71c7e53..04c80b2e 100644
--- a/tests/test_gui/test_gui_noveltree.py
+++ b/tests/test_gui/test_gui_noveltree.py
@@ -46,7 +46,7 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
nwGUI._switchFocus(nwFocus.TREE)
nwGUI.projView.projTree.clearSelection()
- nwGUI.projView.projTree._getTreeItem(C.hCharRoot).setSelected(True)
+ nwGUI.projView.projTree.setSelectedHandle(C.hCharRoot)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE)
contentPath = SHARED.project.storage.contentPath
diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py
index be762fc5..e92b5a01 100644
--- a/tests/test_gui/test_gui_outline.py
+++ b/tests/test_gui/test_gui_outline.py
@@ -198,7 +198,6 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat
# Add a second novel folder
newHandle = SHARED.project.newRoot(nwItemClass.NOVEL)
- nwGUI.projView.projTree.revealNewTreeItem(newHandle)
# Check new values in dropdown list
assert outlineBar.novelValue.itemData(0) == lipHandle
@@ -217,7 +216,6 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat
aHandle = SHARED.project.newFile(dTitle, newHandle)
hHash = "#"*hLevel
writeFile(prjLipsum / "content" / f"{aHandle}.nwd", f"{hHash} {dTitle}\n\n")
- nwGUI.projView.projTree.revealNewTreeItem(aHandle)
nwGUI.rebuildIndex()
diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py
index 903b5f80..f5c65a46 100644
--- a/tests/test_gui/test_gui_projtree.py
+++ b/tests/test_gui/test_gui_projtree.py
@@ -742,29 +742,28 @@ def testGuiProjTree_Duplicate(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mock
assert len(SHARED.project.tree) == 8
projTree = nwGUI.projView.projTree
- projTree._getTreeItem(C.hNovelRoot).setExpanded(True) # type: ignore
- projTree._getTreeItem(C.hChapterDir).setExpanded(True) # type: ignore
+ projTree.expandAll()
# Nothing to do
- assert projTree._duplicateFromHandle(C.hInvalid) is False
+ assert projTree.duplicateFromHandle(C.hInvalid) is False
assert len(SHARED.project.tree) == 8
# Duplicate title page, but select no
with monkeypatch.context() as mp:
mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
- assert projTree._duplicateFromHandle(C.hTitlePage) is False
+ assert projTree.duplicateFromHandle(C.hTitlePage) is False
assert len(SHARED.project.tree) == 8
# Duplicate title page
- assert projTree._duplicateFromHandle(C.hTitlePage) is True
+ assert projTree.duplicateFromHandle(C.hTitlePage) is True
assert len(SHARED.project.tree) == 9
# Duplicate folder
- assert projTree._duplicateFromHandle(C.hChapterDir) is True
+ assert projTree.duplicateFromHandle(C.hChapterDir) is True
assert len(SHARED.project.tree) == 12
# Duplicate novel root
- assert projTree._duplicateFromHandle(C.hNovelRoot) is True
+ assert projTree.duplicateFromHandle(C.hNovelRoot) is True
assert len(SHARED.project.tree) == 21
# Check tree order that all items are next to each other
@@ -783,7 +782,7 @@ def testGuiProjTree_Duplicate(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mock
# Should only create the folder, and skip the two files because the
# next handle is already a file
- assert projTree._duplicateFromHandle(C.hChapterDir) is True
+ assert projTree.duplicateFromHandle(C.hChapterDir) is True
assert len(SHARED.project.tree) == 22
# qtbot.stop()
@@ -801,7 +800,7 @@ def testGuiProjTree_AutoScroll(qtbot, monkeypatch, nwGUI: GuiMain, projPath, moc
monkeypatch.setattr(QTimer, "start", lambda *a: None)
projTree.setSelectedHandle(C.hChapterDir, True)
- projTree._getTreeItem(C.hChapterDir).setExpanded(True) # type: ignore
+ projTree.expandAll()
for i in range(100):
projTree.newTreeItem(nwItemType.FILE, None, 3, False)
diff --git a/tests/test_gui/test_gui_statusbar.py b/tests/test_gui/test_gui_statusbar.py
index 15e66921..48649ba9 100644
--- a/tests/test_gui/test_gui_statusbar.py
+++ b/tests/test_gui/test_gui_statusbar.py
@@ -37,7 +37,6 @@ def testGuiStatusBar_Main(qtbot, nwGUI, projPath, mockRnd):
cHandle = SHARED.project.newFile("A Note", C.hCharRoot)
newDoc = SHARED.project.storage.getDocument(cHandle)
newDoc.writeDocument("# A Note\n\n")
- nwGUI.projView.projTree.revealNewTreeItem(cHandle)
nwGUI.rebuildIndex(beQuiet=True)
# Reference Time
diff --git a/tests/test_tools/test_tools_manussettings.py b/tests/test_tools/test_tools_manussettings.py
index fcbda573..354a731c 100644
--- a/tests/test_tools/test_tools_manussettings.py
+++ b/tests/test_tools/test_tools_manussettings.py
@@ -167,6 +167,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (False, FilterMode.FILTERED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
+ C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Switch on note docs
@@ -182,6 +183,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (True, FilterMode.FILTERED), # Now enabled
C.hWorldRoot: (False, FilterMode.SKIPPED),
+ C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Switch on inactive docs
@@ -197,6 +199,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (True, FilterMode.FILTERED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
+ C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Set chapter and scene docs to included
@@ -214,6 +217,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (True, FilterMode.FILTERED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
+ C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Set char and plot docs to excluded
@@ -232,6 +236,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (False, FilterMode.EXCLUDED), # Now excluded
C.hWorldRoot: (False, FilterMode.SKIPPED),
+ C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Switch on novel docs
@@ -247,6 +252,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (False, FilterMode.EXCLUDED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
+ C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Selecting only novel root should iterate through all children
@@ -264,6 +270,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (False, FilterMode.EXCLUDED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
+ C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Set everything back to filtered
@@ -284,6 +291,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (True, FilterMode.FILTERED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
+ C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Check handling of invalid project items
diff --git a/tests/tools.py b/tests/tools.py
index bb34ada2..547c83e3 100644
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -52,6 +52,7 @@ class C:
hInvalid = "0000000000000"
hNovelRoot = "0000000000008"
hPlotRoot = "0000000000009"
+ hTrashRoot = "0000000000010"
hCharRoot = "000000000000a"
hWorldRoot = "000000000000b"
hTitlePage = "000000000000c"
From a45ab2f01720e4fe2f45527c98d50c4bc1b17e34 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 20 Nov 2024 22:36:44 +0100
Subject: [PATCH 21/39] Remove tooltip for non-checkable items
---
novelwriter/constants.py | 5 ++---
novelwriter/core/item.py | 10 ++++++++--
novelwriter/gui/projtree.py | 26 --------------------------
3 files changed, 10 insertions(+), 31 deletions(-)
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index 1477ddbd..be575b5b 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -275,9 +275,8 @@ class nwLabels:
"note": QT_TRANSLATE_NOOP("Constant", "Project Note"),
}
ACTIVE_NAME = {
- "checked": QT_TRANSLATE_NOOP("Constant", "Active"),
- "unchecked": QT_TRANSLATE_NOOP("Constant", "Inactive"),
- "noncheckable": QT_TRANSLATE_NOOP("Constant", "Not Available"),
+ "checked": QT_TRANSLATE_NOOP("Constant", "Active"),
+ "unchecked": QT_TRANSLATE_NOOP("Constant", "Inactive"),
}
KEY_NAME = {
nwKeyWords.TAG_KEY: QT_TRANSLATE_NOOP("Constant", "Tag"),
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index 2dcc7664..8265b299 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -335,8 +335,14 @@ class NWItem:
"""Return the relevant active status label and icon for
the current item based on its type.
"""
- key = ("checked" if self._active else "unchecked") if self.isFileType() else "noncheckable"
- return trConst(nwLabels.ACTIVE_NAME[key]), SHARED.theme.getIcon(key)
+ if self.isFileType():
+ key = "checked" if self._active else "unchecked"
+ text = trConst(nwLabels.ACTIVE_NAME[key])
+ icon = SHARED.theme.getIcon(key)
+ else:
+ text = ""
+ icon = SHARED.theme.getIcon("noncheckable")
+ return text, icon
##
# Checker Methods
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 3827e8a0..1314e958 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -528,10 +528,6 @@ class GuiProjectTree(QTreeView):
self.setDragEnabled(True)
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
- # Disable built-in auto scroll as it isn't working in some Qt
- # releases (see #1561) and instead use our own implementation
- # self.setAutoScroll(False)
-
# Set selection options
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
@@ -546,13 +542,6 @@ class GuiProjectTree(QTreeView):
self.collapsed.connect(self._onNodeCollapsed)
self.expanded.connect(self._onNodeExpanded)
- # Auto Scroll
- # self._scrollMargin = SHARED.theme.baseIconHeight
- # self._scrollDirection = 0
- # self._scrollTimer = QTimer(self)
- # self._scrollTimer.timeout.connect(self._doAutoScroll)
- # self._scrollTimer.setInterval(250)
-
# Set custom settings
self.initSettings()
@@ -1160,21 +1149,6 @@ class GuiProjectTree(QTreeView):
# self._actHandle = tHandle or None
return
- ##
- # Private Slots
- ##
-
- @pyqtSlot()
- def _doAutoScroll(self) -> None:
- """Scroll one item up or down based on direction value."""
- # if self._scrollDirection == -1:
- # self.scrollToItem(self.itemAbove(self.itemAt(1, 1)))
- # elif self._scrollDirection == 1:
- # self.scrollToItem(self.itemBelow(self.itemAt(1, self.height() - 1)))
- # self._scrollDirection = 0
- # self._scrollTimer.stop()
- return
-
class _UpdatableMenu(QMenu):
From 2f71ec68d8ae7e6c2d48a0fdbacbaa2c480c1e9e Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Wed, 20 Nov 2024 23:07:40 +0100
Subject: [PATCH 22/39] Re-implement row highlight, empty trash, and fix
recursive permanent delete
---
novelwriter/core/project.py | 42 ++++++-----
novelwriter/gui/projtree.py | 137 +++++++++++++-----------------------
novelwriter/guimain.py | 2 +-
novelwriter/shared.py | 7 ++
4 files changed, 81 insertions(+), 107 deletions(-)
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index 2132de79..cf6f03eb 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -164,6 +164,22 @@ class NWProject:
"""Add a new file with a given label and parent item."""
return self._tree.create(label, parent, nwItemType.FILE, pos=pos)
+ def removeItem(self, tHandle: str) -> bool:
+ """Remove an item from the project. This will delete both the
+ project entry and a document file if it exists.
+ """
+ if self._tree.checkType(tHandle, nwItemType.FILE):
+ doc = self._storage.getDocument(tHandle)
+ if not doc.deleteDocument():
+ SHARED.error(
+ self.tr("Could not delete document file."),
+ info=doc.getError()
+ )
+ return False
+ self._index.deleteHandle(tHandle)
+ self._tree.remove(tHandle)
+ return True
+
def writeNewFile(self, tHandle: str, hLevel: int, isDocument: bool, text: str = "") -> bool:
"""Write content to a new document after it is created. This
will not run if the file exists and is not empty.
@@ -208,23 +224,17 @@ class NWProject:
return True
- def removeItem(self, tHandle: str) -> bool:
- """Remove an item from the project. This will delete both the
- project entry and a document file if it exists.
+ def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
+ """Create a new note. This function is used by the document
+ editor to create note files for unknown tags.
"""
- if self._tree.checkType(tHandle, nwItemType.FILE):
- doc = self._storage.getDocument(tHandle)
- if not doc.deleteDocument():
- SHARED.error(
- self.tr("Could not delete document file."),
- info=doc.getError()
- )
- return False
-
- self._index.deleteHandle(tHandle)
- self._tree.remove(tHandle)
-
- return True
+ if itemClass != nwItemClass.NO_CLASS:
+ if not (rHandle := self._tree.findRoot(itemClass)):
+ rHandle = self.newRoot(itemClass)
+ if rHandle and (tHandle := SHARED.project.newFile(tag.title(), rHandle)):
+ self.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
+ self._tree.refreshItems([tHandle])
+ return
##
# Project Methods
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 1314e958..ee46fca7 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -32,10 +32,10 @@ import logging
from enum import Enum
from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
-from PyQt5.QtGui import QIcon, QMouseEvent, QPalette
+from PyQt5.QtGui import QIcon, QMouseEvent, QPainter, QPalette
from PyQt5.QtWidgets import (
QAbstractItemView, QAction, QFrame, QHBoxLayout, QLabel, QMenu, QShortcut,
- QTreeView, QVBoxLayout, QWidget
+ QStyleOptionViewItem, QTreeView, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
@@ -254,12 +254,6 @@ class GuiProjectView(QWidget):
self.projBar.buildQuickLinksMenu()
return
- @pyqtSlot(str, nwItemClass)
- def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
- """Process new not request."""
- self.projTree.createNewNote(tag, itemClass)
- return
-
@pyqtSlot(str)
def refreshUserLabels(self, kind: str) -> None:
"""Refresh status or importance labels."""
@@ -359,7 +353,7 @@ class GuiProjectToolBar(QWidget):
self.aCollapse.triggered.connect(self.projTree.collapseAll)
self.aEmptyTrash = self.mMore.addAction(self.tr("Empty Trash"))
- self.aEmptyTrash.triggered.connect(qtLambda(self.projTree.emptyTrash))
+ self.aEmptyTrash.triggered.connect(self.projTree.emptyTrash)
self.tbMore = NIconToolButton(self, iSz)
self.tbMore.setToolTip(self.tr("More Options"))
@@ -506,7 +500,7 @@ class GuiProjectTree(QTreeView):
self.projView = projView
# Internal Variables
- # self._actHandle = None
+ self._actHandle = None
# Cached Translations
self.trActive = trConst(nwLabels.ACTIVE_NAME["checked"])
@@ -566,6 +560,11 @@ class GuiProjectTree(QTreeView):
# External Methods
##
+ def setActiveHandle(self, tHandle: str | None) -> None:
+ """Set the handle to be highlighted."""
+ self._actHandle = tHandle
+ return
+
def getSelectedHandle(self) -> str | None:
"""Get the currently selected handle."""
if (indexes := self.selectedIndexes()) and (node := self._getNode(indexes[0])):
@@ -583,11 +582,7 @@ class GuiProjectTree(QTreeView):
def loadModel(self) -> None:
"""Load and prepare a new project model."""
- # selModel = self.selectionModel()
self.setModel(SHARED.project.tree.model)
- # if selModel:
- # selModel.deleteLater()
- # del selModel
# Lock the column sizes
iPx = SHARED.theme.baseIconHeight
@@ -820,7 +815,7 @@ class GuiProjectTree(QTreeView):
return
##
- # Events
+ # Events and Overloads
##
def mousePressEvent(self, event: QMouseEvent) -> None:
@@ -839,6 +834,13 @@ class GuiProjectTree(QTreeView):
)
return
+ def drawRow(self, painter: QPainter, opt: QStyleOptionViewItem, index: QModelIndex) -> None:
+ """Draw a box on the active row."""
+ if (node := self._getNode(index)) and node.item.itemHandle == self._actHandle:
+ painter.fillRect(opt.rect, self.palette().alternateBase())
+ super().drawRow(painter, opt, index)
+ return
+
##
# Public Slots
##
@@ -913,33 +915,55 @@ class GuiProjectTree(QTreeView):
def processDeleteRequest(self, handles: list[str] = [], askFirst: bool = True) -> None:
"""Move selected items to Trash."""
if handles and (model := self._getModel()):
- items = [model.indexFromHandle(handle) for handle in handles]
+ indices = [model.indexFromHandle(handle) for handle in handles]
else:
- items = self._selectedRows()
+ indices = self._selectedRows()
- if items and (model := self._getModel()):
- if len(items) == 1 and (node := model.node(items[0])) and node.item.isRootType():
+ if indices and (model := self._getModel()):
+ if len(indices) == 1 and (node := model.node(indices[0])) and node.item.isRootType():
if node.childCount() == 0:
SHARED.project.removeItem(node.item.itemHandle)
else:
SHARED.error(self.tr("Root folders can only be deleted when they are empty."))
return
- if model.trashSelection(items):
+ if model.trashSelection(indices):
if not SHARED.question(self.tr("Permanently delete selected item(s)?")):
logger.info("Action cancelled by user")
return
- for node in model.nodes(items):
- SHARED.project.removeItem(node.item.itemHandle)
+ for index in indices:
+ if node := model.node(index):
+ for child in reversed(node.allChildren()):
+ SHARED.project.removeItem(child.item.itemHandle)
+ SHARED.project.removeItem(node.item.itemHandle)
elif trashNode := SHARED.project.tree.trash:
if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")):
logger.info("Action cancelled by user")
return
- model.multiMove(items, model.indexFromNode(trashNode))
+ model.multiMove(indices, model.indexFromNode(trashNode))
return
+ @pyqtSlot()
+ def emptyTrash(self) -> None:
+ """Permanently delete all documents in the Trash folder. This
+ function only asks for confirmation once, and calls the regular
+ deleteItem function for each document in the Trash folder.
+ """
+ if trash := SHARED.project.tree.trash:
+ if not (nodes := trash.allChildren()):
+ SHARED.info(self.tr("The Trash folder is already empty."))
+ return
+ if not SHARED.question(
+ self.tr("Permanently delete {0} file(s) from Trash?").format(len(nodes))
+ ):
+ logger.info("Action cancelled by user")
+ return
+ for node in reversed(nodes):
+ SHARED.project.removeItem(node.item.itemHandle)
+ return
+
@pyqtSlot()
@pyqtSlot("QPoint")
def openContextMenu(self, point: QPoint | None = None) -> None:
@@ -1031,60 +1055,6 @@ class GuiProjectTree(QTreeView):
# Old Code
# =========================================================================================== #
- def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
- """Create a new note. This function is used by the document
- editor to create note files for unknown tags.
- """
- # if itemClass != nwItemClass.NO_CLASS:
- # if not (rHandle := SHARED.project.tree.findRoot(itemClass)):
- # self.newTreeItem(nwItemType.ROOT, itemClass)
- # rHandle = SHARED.project.tree.findRoot(itemClass)
- # if rHandle and (tHandle := SHARED.project.newFile(tag, rHandle)):
- # SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
- # self.revealNewTreeItem(tHandle, wordCount=True)
- return
-
- @pyqtSlot()
- def emptyTrash(self) -> bool:
- """Permanently delete all documents in the Trash folder. This
- function only asks for confirmation once, and calls the regular
- deleteItem function for each document in the Trash folder.
- """
- # if not SHARED.hasProject:
- # logger.error("No project open")
- # return False
-
- # trashHandle = SHARED.project.tree.trashRoot
-
- # logger.debug("Emptying Trash folder")
- # if trashHandle is None:
- # SHARED.info(self.tr("There is currently no Trash folder in this project."))
- # return False
-
- # trashItems = self.getTreeFromHandle(trashHandle)
- # if trashHandle in trashItems:
- # trashItems.remove(trashHandle)
-
- # nTrash = len(trashItems)
- # if nTrash == 0:
- # SHARED.info(self.tr("The Trash folder is already empty."))
- # return False
-
- # if not SHARED.question(self.trPermDelete.format(nTrash)):
- # logger.info("Action cancelled by user")
- # return False
-
- # logger.debug("Deleting %d file(s) from Trash", nTrash)
- # for tHandle in reversed(self.getTreeFromHandle(trashHandle)):
- # if tHandle == trashHandle:
- # continue
- # self.permDeleteItem(tHandle, askFirst=False, flush=False)
-
- # if nTrash > 0:
- # self._alertTreeChange(trashHandle, flush=True)
-
- return True
-
def refreshUserLabels(self, kind: str) -> None:
"""Refresh status or importance labels."""
# if kind == "s":
@@ -1136,19 +1106,6 @@ class GuiProjectTree(QTreeView):
return
- def setActiveHandle(self, tHandle: str | None) -> None:
- """Highlight the rows associated with a given handle."""
- # brushOn = self.palette().alternateBase()
- # brushOff = self.palette().base()
- # if (pHandle := self._actHandle) and (item := self._treeMap.get(pHandle)):
- # for i in range(self.columnCount()):
- # item.setBackground(i, brushOff)
- # if tHandle and (item := self._treeMap.get(tHandle)):
- # for i in range(self.columnCount()):
- # item.setBackground(i, brushOn)
- # self._actHandle = tHandle or None
- return
-
class _UpdatableMenu(QMenu):
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 2dc5aec6..b4df7adb 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -261,7 +261,7 @@ class GuiMain(QMainWindow):
self.docEditor.novelItemMetaChanged.connect(self.novelView.updateNovelItemMeta)
self.docEditor.novelStructureChanged.connect(self.novelView.refreshTree)
self.docEditor.openDocumentRequest.connect(self._openDocument)
- self.docEditor.requestNewNoteCreation.connect(self.projView.createNewNote)
+ self.docEditor.requestNewNoteCreation.connect(SHARED.createNewNote)
self.docEditor.requestNextDocument.connect(self.openNextDocument)
self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem)
self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle)
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index 5e42fd1b..7455b834 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -37,6 +37,7 @@ from PyQt5.QtWidgets import QFileDialog, QFontDialog, QMessageBox, QWidget
from novelwriter.common import formatFileFilter
from novelwriter.constants import nwFiles
from novelwriter.core.spellcheck import NWSpellEnchant
+from novelwriter.enum import nwItemClass
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
@@ -303,6 +304,12 @@ class SharedData(QObject):
QDesktopServices.openUrl(QUrl(url))
return
+ @pyqtSlot(str, nwItemClass)
+ def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
+ """Process new note request."""
+ self.project.createNewNote(tag, itemClass)
+ return
+
##
# Signal Proxy
##
From 55eed706fbda2852311768c72c7e2623a3d330bf Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 21 Nov 2024 00:14:54 +0100
Subject: [PATCH 23/39] Re-implement word counters
---
novelwriter/core/itemmodel.py | 19 +++++++++++---
novelwriter/core/project.py | 5 ++++
novelwriter/core/tree.py | 18 +++++++-------
novelwriter/gui/doceditor.py | 3 +--
novelwriter/gui/projtree.py | 47 -----------------------------------
novelwriter/guimain.py | 34 ++++++++++++++-----------
6 files changed, 50 insertions(+), 76 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index a26b15c4..fd6a365b 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -79,6 +79,7 @@ class ProjectNode:
self._cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {}
self._flags = NODE_FLAGS
self.refresh()
+ self.updateCount()
return
def __repr__(self) -> str:
@@ -95,12 +96,19 @@ class ProjectNode:
@property
def item(self) -> NWItem:
+ """The project item of the node."""
return self._item
@property
def children(self) -> list[ProjectNode]:
+ """All children of the node."""
return self._children
+ @property
+ def count(self) -> int:
+ """The count of the node."""
+ return self._count
+
##
# Data Maintenance
##
@@ -127,11 +135,10 @@ class ProjectNode:
self._cache[C_STATUS_TIP] = sText
self._cache[C_STATUS_ICON] = sIcon
- self.updateCount()
-
return
def updateCount(self, propagate: bool = True) -> None:
+ """Update counts, and propagate upwards in the tree."""
self._count = self._item.wordCount + sum(c._count for c in self._children)
self._cache[C_COUNT_TEXT] = f"{self._count:n}"
if propagate and (parent := self._parent):
@@ -143,9 +150,11 @@ class ProjectNode:
##
def row(self) -> int:
+ """Return the node's row number."""
return self._row
def childCount(self) -> int:
+ """Return the number of children of the node."""
return len(self._children)
def data(self, column: int, role: Qt.ItemDataRole) -> T_NodeData:
@@ -157,9 +166,11 @@ class ProjectNode:
return self._flags
def parent(self) -> ProjectNode | None:
+ """Return the parent of the node."""
return self._parent
def child(self, row: int) -> ProjectNode | None:
+ """Return a child ofg the node."""
if 0 <= row < len(self._children):
return self._children[row]
return None
@@ -366,9 +377,9 @@ class ProjectModel(QAbstractItemModel):
return self.createIndex(node.row(), 0, node)
return QModelIndex()
- def indexFromNode(self, node: ProjectNode) -> QModelIndex:
+ def indexFromNode(self, node: ProjectNode, column: int = 0) -> QModelIndex:
"""Get the index representing a node in the model."""
- return self.createIndex(node.row(), 0, node)
+ return self.createIndex(node.row(), column, node)
def rootIndex(self) -> QModelIndex:
"""Get the index representing the root."""
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index cf6f03eb..be383d0e 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -145,6 +145,11 @@ class NWProject:
"""Return total edit time, including the current session."""
return self._data.editTime + round(time() - self._session.start)
+ @property
+ def currentTotalCount(self) -> int:
+ """Return the current total word count from the tree."""
+ return self._tree.model.root.count
+
##
# Item Methods
##
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 207365f4..cad53098 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -230,22 +230,22 @@ class NWTree:
return
- def refreshItems(self, items: list[str], isRange: bool = False) -> None:
+ def refreshItems(self, items: list[str]) -> None:
"""Refresh these items on the GUI. If they are an ordered range,
also set the isRange flag to True.
"""
- indices = []
+ change = False
for tHandle in items:
if node := self._nodes.get(tHandle):
node.refresh()
+ node.updateCount()
SHARED.projectSignalProxy({"event": "projectItem", "handle": tHandle})
- indices.append(self._model.indexFromNode(node))
- if isRange and len(indices) >= 2:
- self._model.dataChanged.emit(indices[0], indices[-1])
- else:
- for index in indices:
- self._model.dataChanged.emit(index, index)
- self._project.setProjectChanged(len(indices) > 0)
+ indexS = self._model.indexFromNode(node, 0)
+ indexE = self._model.indexFromNode(node, 3)
+ self._model.dataChanged.emit(indexS, indexE)
+ change = True
+ if change:
+ self._project.setProjectChanged(True)
return
def checkConsistency(self, prefix: str) -> tuple[int, int]:
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index c0b9a4c6..d6e326b8 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -110,7 +110,6 @@ class GuiDocEditor(QPlainTextEdit):
# Custom Signals
closeEditorRequest = pyqtSignal()
- docCountsChanged = pyqtSignal(str, int, int, int)
docTextChanged = pyqtSignal(str, float)
editedStatusChanged = pyqtSignal(bool)
itemHandleChanged = pyqtSignal(str)
@@ -1281,7 +1280,7 @@ class GuiDocEditor(QPlainTextEdit):
self._nwItem.setCharCount(cCount)
self._nwItem.setWordCount(wCount)
self._nwItem.setParaCount(pCount)
- self.docCountsChanged.emit(self._docHandle, cCount, wCount, pCount)
+ self._nwItem.notifyToRefresh()
self.docFooter.updateWordCount(wCount, False)
return
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index ee46fca7..93f31cc9 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -67,7 +67,6 @@ class GuiProjectView(QWidget):
# Signals triggered when the meta data values of items change
rootFolderChanged = pyqtSignal(str)
- wordCountsChanged = pyqtSignal()
# Signals for user interaction with the project tree
selectedItemChanged = pyqtSignal(str)
@@ -241,13 +240,6 @@ class GuiProjectView(QWidget):
self.projTree.newTreeItem(nwItemType.FILE, copyDoc=tHandle)
return
- @pyqtSlot(str, int, int, int)
- def updateCounts(self, tHandle: str, cCount: int, wCount: int, pCount: int) -> None:
- """Slot for updating the word count of a specific item."""
- self.projTree.propagateCount(tHandle, wCount, countChildren=True)
- self.wordCountsChanged.emit()
- return
-
@pyqtSlot(str)
def updateRootItem(self, tHandle: str) -> None:
"""Process root item changes."""
@@ -1067,45 +1059,6 @@ class GuiProjectTree(QTreeView):
# self.setTreeItemValues(nwItem)
return
- def propagateCount(self, tHandle: str, newCount: int, countChildren: bool = False) -> None:
- """Recursive function setting the word count for a given item,
- and propagating that count upwards in the tree until reaching a
- root item. This function is more efficient than recalculating
- everything each time the word count is updated, but is also
- prone to diverging from the true values if the counts are not
- properly reported to the function.
- """
- # tItem = self._getTreeItem(tHandle)
- # if tItem is None:
- # return
-
- # if countChildren:
- # for i in range(tItem.childCount()):
- # newCount += int(tItem.child(i).data(self.C_DATA, self.D_WORDS))
-
- # tItem.setText(self.C_COUNT, f"{newCount:n}")
- # tItem.setData(self.C_DATA, self.D_WORDS, int(newCount))
-
- # pItem = tItem.parent()
- # if pItem is None:
- # return
-
- # pCount = 0
- # pHandle = None
- # for i in range(pItem.childCount()):
- # pCount += int(pItem.child(i).data(self.C_DATA, self.D_WORDS))
- # pHandle = pItem.data(self.C_DATA, self.D_HANDLE)
-
- # if pHandle:
- # if SHARED.project.tree.checkType(pHandle, nwItemType.FILE):
- # # A file has an internal word count we need to account
- # # for, but a folder always has 0 words on its own.
- # pCount += SHARED.project.index.getCounts(pHandle)[1]
-
- # self.propagateCount(pHandle, pCount, countChildren=False)
-
- return
-
class _UpdatableMenu(QMenu):
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index b4df7adb..93396f8d 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -242,7 +242,6 @@ class GuiMain(QMainWindow):
self.projView.rootFolderChanged.connect(self.outlineView.updateRootItem)
self.projView.rootFolderChanged.connect(self.projView.updateRootItem)
self.projView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
- self.projView.wordCountsChanged.connect(self._updateStatusWordCount)
self.novelView.openDocumentRequest.connect(self._openDocument)
self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
@@ -251,8 +250,6 @@ class GuiMain(QMainWindow):
self.projSearch.selectedItemChanged.connect(self.itemDetails.updateViewBox)
self.docEditor.closeEditorRequest.connect(self.closeDocEditor)
- self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts)
- self.docEditor.docCountsChanged.connect(self.projView.updateCounts)
self.docEditor.docTextChanged.connect(self.projSearch.textChanged)
self.docEditor.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus)
self.docEditor.itemHandleChanged.connect(self.novelView.setActiveHandle)
@@ -315,6 +312,9 @@ class GuiMain(QMainWindow):
self.keyEscape.setKey("Esc")
self.keyEscape.activated.connect(self._keyPressEscape)
+ # Internal Variables
+ self._lastTotalCount = 0
+
# Initialise Main GUI
self.initMain()
self.asProjTimer.start()
@@ -1226,8 +1226,10 @@ class GuiMain(QMainWindow):
self.mainStatus.setUserIdle(editIdle or userIdle)
SHARED.updateIdleTime(currTime, editIdle or userIdle)
self.mainStatus.updateTime(idleTime=SHARED.projectIdleTime)
- if CONFIG.memInfo and int(currTime) % 5 == 0: # pragma: no cover
- self.mainStatus.memInfo()
+ if int(currTime) % 5 == 0:
+ self._updateStatusWordCount()
+ if CONFIG.memInfo: # pragma: no cover
+ self.mainStatus.memInfo()
return
@pyqtSlot()
@@ -1255,15 +1257,19 @@ class GuiMain(QMainWindow):
if not SHARED.hasProject:
self.mainStatus.setProjectStats(0, 0)
- SHARED.project.updateWordCounts()
- if CONFIG.incNotesWCount:
- iTotal = sum(SHARED.project.data.initCounts)
- cTotal = sum(SHARED.project.data.currCounts)
- self.mainStatus.setProjectStats(cTotal, cTotal - iTotal)
- else:
- iNovel, _ = SHARED.project.data.initCounts
- cNovel, _ = SHARED.project.data.currCounts
- self.mainStatus.setProjectStats(cNovel, cNovel - iNovel)
+ currentTotalCount = SHARED.project.currentTotalCount
+ if self._lastTotalCount != currentTotalCount:
+ self._lastTotalCount = currentTotalCount
+
+ SHARED.project.updateWordCounts()
+ if CONFIG.incNotesWCount:
+ iTotal = sum(SHARED.project.data.initCounts)
+ cTotal = sum(SHARED.project.data.currCounts)
+ self.mainStatus.setProjectStats(cTotal, cTotal - iTotal)
+ else:
+ iNovel, _ = SHARED.project.data.initCounts
+ cNovel, _ = SHARED.project.data.currCounts
+ self.mainStatus.setProjectStats(cNovel, cNovel - iNovel)
return
From d55f53be0a69936aa3c88455acf581b6ac41076d Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 21 Nov 2024 01:18:28 +0100
Subject: [PATCH 24/39] Clean up item notification and item main icon
---
novelwriter/core/index.py | 1 +
novelwriter/core/item.py | 4 ++
novelwriter/core/itemmodel.py | 6 +--
novelwriter/core/status.py | 3 +-
novelwriter/core/tree.py | 8 ++++
novelwriter/dialogs/docmerge.py | 24 +++-------
novelwriter/gui/docviewerpanel.py | 12 +----
novelwriter/gui/itemdetails.py | 5 +-
novelwriter/gui/projtree.py | 77 +++++++++++-------------------
novelwriter/gui/search.py | 6 +--
novelwriter/guimain.py | 16 +++----
novelwriter/tools/manusbuild.py | 6 +--
novelwriter/tools/manussettings.py | 12 +----
13 files changed, 63 insertions(+), 117 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 812b15e2..30d32413 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -296,6 +296,7 @@ class NWIndex:
self._indexChange = nowTime
self._rootChange[tItem.itemRoot] = nowTime
if not blockSignal:
+ tItem.notifyToRefresh()
SHARED.indexSignalProxy({
"event": "scanText",
"handle": tHandle,
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index 8265b299..2448e314 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -321,6 +321,10 @@ class NWItem:
return trConst(nwLabels.ITEM_DESCRIPTION.get(descKey, ""))
+ def getMainIcon(self) -> QIcon:
+ """Get the main item icon."""
+ return SHARED.theme.getItemIcon(self._type, self._class, self._layout, self._heading)
+
def getImportStatus(self) -> tuple[str, QIcon]:
"""Return the relevant importance or status label and icon for
the current item based on its class.
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index fd6a365b..4f9828c0 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -31,7 +31,6 @@ from typing import TYPE_CHECKING
from PyQt5.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt
from PyQt5.QtGui import QIcon
-from novelwriter import SHARED
from novelwriter.common import decodeMimeHandles, minmax
from novelwriter.constants import nwConst
from novelwriter.core.item import NWItem
@@ -116,10 +115,7 @@ class ProjectNode:
def refresh(self) -> None:
"""Refresh data values."""
# Label
- self._cache[C_LABEL_ICON] = SHARED.theme.getItemIcon(
- self._item.itemType, self._item.itemClass,
- self._item.itemLayout, self._item.mainHeading
- )
+ self._cache[C_LABEL_ICON] = self._item.getMainIcon()
self._cache[C_LABEL_TEXT] = self._item.itemName
# Count
diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py
index dc0ef397..a5e2e4d6 100644
--- a/novelwriter/core/status.py
+++ b/novelwriter/core/status.py
@@ -130,8 +130,9 @@ class NWStatus:
if self._default not in self._store:
self._default = next(iter(self._store)) if self._store else None
- # Emit the change signal
+ # Emit the change signal and refresh tree
SHARED.projectSignalProxy({"event": "statusLabels", "kind": self._prefix})
+ SHARED.project.tree.refreshAllItems()
return
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index cad53098..5231f90c 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -248,6 +248,14 @@ class NWTree:
self._project.setProjectChanged(True)
return
+ def refreshAllItems(self) -> None:
+ """Refresh all items in the tree."""
+ for node in reversed(self._model.root.allChildren()):
+ node.refresh()
+ node.updateCount(propagate=False)
+ 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/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py
index d745dadb..02c06cb7 100644
--- a/novelwriter/dialogs/docmerge.py
+++ b/novelwriter/dialogs/docmerge.py
@@ -162,23 +162,13 @@ class GuiDocMerge(NDialog):
self._data = {}
self._data["sHandle"] = sHandle
self._data["origItems"] = itemList
-
self.listBox.clear()
for tHandle in itemList:
- nwItem = SHARED.project.tree[tHandle]
- if nwItem is None or not nwItem.isFileType():
- continue
-
- itemIcon = SHARED.theme.getItemIcon(
- nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, nwItem.mainHeading
- )
-
- newItem = QListWidgetItem()
- newItem.setIcon(itemIcon)
- newItem.setText(nwItem.itemName)
- newItem.setData(self.D_HANDLE, tHandle)
- newItem.setCheckState(Qt.CheckState.Checked)
-
- self.listBox.addItem(newItem)
-
+ if (nwItem := SHARED.project.tree[tHandle]) and nwItem.isFileType():
+ item = QListWidgetItem()
+ item.setIcon(nwItem.getMainIcon())
+ item.setText(nwItem.itemName)
+ item.setData(self.D_HANDLE, tHandle)
+ item.setCheckState(Qt.CheckState.Checked)
+ self.listBox.addItem(item)
return
diff --git a/novelwriter/gui/docviewerpanel.py b/novelwriter/gui/docviewerpanel.py
index 8529ceef..01c9d2f1 100644
--- a/novelwriter/gui/docviewerpanel.py
+++ b/novelwriter/gui/docviewerpanel.py
@@ -339,17 +339,13 @@ class _ViewPanelBackRefs(QTreeWidget):
def _setTreeItemValues(self, tHandle: str, sTitle: str, hItem: IndexHeading) -> None:
"""Add or update a tree item."""
if nwItem := SHARED.project.tree[tHandle]:
- docIcon = SHARED.theme.getItemIcon(
- nwItem.itemType, nwItem.itemClass,
- nwItem.itemLayout, nwItem.mainHeading
- )
iLevel = nwStyles.H_LEVEL.get(hItem.level, 0) if nwItem.isDocumentLayout() else 5
hDec = SHARED.theme.getHeaderDecorationNarrow(iLevel)
tKey = f"{tHandle}:{sTitle}"
trItem = self._treeMap[tKey] if tKey in self._treeMap else QTreeWidgetItem()
- trItem.setIcon(self.C_DOC, docIcon)
+ trItem.setIcon(self.C_DOC, nwItem.getMainIcon())
trItem.setText(self.C_DOC, nwItem.itemName)
trItem.setToolTip(self.C_DOC, nwItem.itemName)
trItem.setIcon(self.C_EDIT, self._editIcon)
@@ -448,10 +444,6 @@ class _ViewPanelKeyWords(QTreeWidget):
def addUpdateEntry(self, tag: str, name: str, iItem: IndexItem, hItem: IndexHeading) -> None:
"""Add a new entry, or update an existing one."""
nwItem = iItem.item
- docIcon = SHARED.theme.getItemIcon(
- nwItem.itemType, nwItem.itemClass,
- nwItem.itemLayout, nwItem.mainHeading
- )
impLabel, impIcon = nwItem.getImportStatus()
iLevel = nwStyles.H_LEVEL.get(hItem.level, 0) if nwItem.isDocumentLayout() else 5
hDec = SHARED.theme.getHeaderDecorationNarrow(iLevel)
@@ -468,7 +460,7 @@ class _ViewPanelKeyWords(QTreeWidget):
trItem.setIcon(self.C_IMPORT, impIcon)
trItem.setText(self.C_IMPORT, impLabel)
trItem.setToolTip(self.C_IMPORT, impLabel)
- trItem.setIcon(self.C_DOC, docIcon)
+ trItem.setIcon(self.C_DOC, nwItem.getMainIcon())
trItem.setText(self.C_DOC, nwItem.itemName)
trItem.setToolTip(self.C_DOC, nwItem.itemName)
trItem.setData(self.C_TITLE, QtDecoration, hDec)
diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py
index 6d67d74c..2b98f730 100644
--- a/novelwriter/gui/itemdetails.py
+++ b/novelwriter/gui/itemdetails.py
@@ -269,10 +269,7 @@ class GuiItemDetails(QWidget):
# Layout
# ======
- usageIcon = SHARED.theme.getItemIcon(
- nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, nwItem.mainHeading
- )
- self.usageIcon.setPixmap(usageIcon.pixmap(iPx, iPx))
+ self.usageIcon.setPixmap(nwItem.getMainIcon().pixmap(iPx, iPx))
self.usageData.setText(nwItem.describeMe())
# Counts
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 93f31cc9..793976be 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -130,7 +130,6 @@ class GuiProjectView(QWidget):
# Signals
self.selectedItemChanged.connect(self.projBar.treeSelectionChanged)
- self.projTree.itemRefreshed.connect(self.projBar.treeItemRefreshed)
self.projBar.newDocumentFromTemplate.connect(self.createFileFromTemplate)
# Function Mappings
@@ -145,7 +144,6 @@ class GuiProjectView(QWidget):
def updateTheme(self) -> None:
"""Update theme elements."""
self.projBar.updateTheme()
- self.populateTree()
return
def initSettings(self) -> None:
@@ -162,21 +160,12 @@ class GuiProjectView(QWidget):
def openProjectTasks(self) -> None:
"""Run open project tasks."""
- self.populateTree()
+ self.projTree.loadModel()
+ self.projBar.buildTemplatesMenu()
self.projBar.buildQuickLinksMenu()
self.projBar.setEnabled(True)
return
- def saveProjectTasks(self) -> None:
- """Run save project tasks."""
- # self.projTree.saveTreeOrder()
- return
-
- def populateTree(self) -> None:
- """Build the tree structure from project data."""
- self.projTree.loadModel()
- return
-
def setTreeFocus(self) -> None:
"""Forward the set focus call to the tree widget."""
self.projTree.setFocus()
@@ -227,10 +216,9 @@ class GuiProjectView(QWidget):
return
@pyqtSlot(str)
- def updateItemValues(self, tHandle: str) -> None:
- """Update tree item."""
- # if nwItem := SHARED.project.tree[tHandle]:
- # self.projTree.setTreeItemValues(nwItem)
+ def projectItemChanged(self, tHandle: str) -> None:
+ """Refresh other content when project item changed."""
+ self.projBar.processTemplateDocuments(tHandle)
return
@pyqtSlot(str)
@@ -246,12 +234,6 @@ class GuiProjectView(QWidget):
self.projBar.buildQuickLinksMenu()
return
- @pyqtSlot(str)
- def refreshUserLabels(self, kind: str) -> None:
- """Refresh status or importance labels."""
- self.projTree.refreshUserLabels(kind)
- return
-
class GuiProjectToolBar(QWidget):
@@ -398,6 +380,7 @@ class GuiProjectToolBar(QWidget):
self.aAddNote.setIcon(SHARED.theme.getIcon("proj_note"))
self.aAddFolder.setIcon(SHARED.theme.getIcon("proj_folder"))
+ self.buildTemplatesMenu()
self.buildQuickLinksMenu()
self._buildRootMenu()
@@ -422,19 +405,26 @@ class GuiProjectToolBar(QWidget):
)
return
+ def buildTemplatesMenu(self) -> None:
+ """Build the templates menu."""
+ for tHandle, _ in SHARED.project.tree.iterRoots(nwItemClass.TEMPLATE):
+ for dHandle in SHARED.project.tree.subTree(tHandle):
+ self.processTemplateDocuments(dHandle)
+ return
+
+ def processTemplateDocuments(self, tHandle: str) -> None:
+ """Process change in tree items to update menu content."""
+ if item := SHARED.project.tree[tHandle]:
+ if item.isTemplateFile() and item.isActive:
+ self.mTemplates.addUpdate(tHandle, item.itemName, item.getMainIcon())
+ elif tHandle in self.mTemplates:
+ self.mTemplates.remove(tHandle)
+ return
+
##
# Public Slots
##
- @pyqtSlot(str, NWItem, QIcon)
- def treeItemRefreshed(self, tHandle: str, nwItem: NWItem, icon: QIcon) -> None:
- """Process change in tree items to update menu content."""
- if nwItem.isTemplateFile() and nwItem.isActive:
- self.mTemplates.addUpdate(tHandle, nwItem.itemName, icon)
- elif tHandle in self.mTemplates:
- self.mTemplates.remove(tHandle)
- return
-
@pyqtSlot(str)
def treeSelectionChanged(self, tHandle: str) -> None:
"""Toggle the visibility of the new item entries for novel
@@ -482,8 +472,6 @@ class GuiProjectToolBar(QWidget):
class GuiProjectTree(QTreeView):
- itemRefreshed = pyqtSignal(str, NWItem, QIcon)
-
def __init__(self, projView: GuiProjectView) -> None:
super().__init__(parent=projView)
@@ -635,6 +623,7 @@ class GuiProjectTree(QTreeView):
SHARED.project.newRoot(itemClass, pos)
self.restoreExpandedState()
+ self.projView.rootFolderChanged.emit(tHandle)
elif itemType in (nwItemType.FILE, nwItemType.FOLDER):
@@ -927,7 +916,9 @@ class GuiProjectTree(QTreeView):
if node := model.node(index):
for child in reversed(node.allChildren()):
SHARED.project.removeItem(child.item.itemHandle)
+ self.projView.projBar.processTemplateDocuments(child.item.itemHandle)
SHARED.project.removeItem(node.item.itemHandle)
+ self.projView.projBar.processTemplateDocuments(node.item.itemHandle)
elif trashNode := SHARED.project.tree.trash:
if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")):
@@ -1043,22 +1034,6 @@ class GuiProjectTree(QTreeView):
return node
return None
- # =========================================================================================== #
- # Old Code
- # =========================================================================================== #
-
- def refreshUserLabels(self, kind: str) -> None:
- """Refresh status or importance labels."""
- # if kind == "s":
- # for nwItem in SHARED.project.tree:
- # if nwItem.isNovelLike():
- # self.setTreeItemValues(nwItem)
- # elif kind == "i":
- # for nwItem in SHARED.project.tree:
- # if not nwItem.isNovelLike():
- # self.setTreeItemValues(nwItem)
- return
-
class _UpdatableMenu(QMenu):
@@ -1350,6 +1325,8 @@ class _TreeContextMenu(QMenu):
"""Add move to Trash action."""
if self._model.trashSelection(self._indices):
text = self.tr("Delete Permanently")
+ elif len(self._indices) == 1 and self._item.isRootType():
+ text = self.tr("Delete Permanently")
else:
text = self.tr("Move to Trash")
action = self.addAction(text)
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index f18e98ee..cbe66442 100644
--- a/novelwriter/gui/search.py
+++ b/novelwriter/gui/search.py
@@ -334,15 +334,11 @@ class GuiProjectSearch(QWidget):
"""Populate the result tree."""
if results and nwItem:
tHandle = nwItem.itemHandle
- docIcon = SHARED.theme.getItemIcon(
- nwItem.itemType, nwItem.itemClass,
- nwItem.itemLayout, nwItem.mainHeading
- )
ext = "+" if capped else ""
tItem = QTreeWidgetItem()
tItem.setText(self.C_NAME, nwItem.itemName)
- tItem.setIcon(self.C_NAME, docIcon)
+ tItem.setIcon(self.C_NAME, nwItem.getMainIcon())
tItem.setData(self.C_NAME, self.D_HANDLE, tHandle)
tItem.setText(self.C_COUNT, f"({len(results):n}{ext})")
tItem.setTextAlignment(self.C_COUNT, QtAlignRight)
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 93396f8d..6a724e4a 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -215,8 +215,8 @@ class GuiMain(QMainWindow):
SHARED.indexCleared.connect(self.docViewerPanel.indexWasCleared)
SHARED.indexScannedText.connect(self.docViewerPanel.projectItemChanged)
SHARED.indexScannedText.connect(self.itemDetails.updateViewBox)
- SHARED.indexScannedText.connect(self.projView.updateItemValues)
SHARED.mainClockTick.connect(self._timeTick)
+ SHARED.projectItemChanged.connect(self.projView.projectItemChanged)
SHARED.projectItemChanged.connect(self.docEditor.updateDocInfo)
SHARED.projectItemChanged.connect(self.docViewer.updateDocInfo)
SHARED.projectItemChanged.connect(self.docViewerPanel.projectItemChanged)
@@ -225,7 +225,6 @@ class GuiMain(QMainWindow):
SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage)
SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage)
SHARED.statusLabelsChanged.connect(self.docViewerPanel.updateStatusLabels)
- SHARED.statusLabelsChanged.connect(self.projView.refreshUserLabels)
self.mainMenu.requestDocAction.connect(self._passDocumentAction)
self.mainMenu.requestDocInsert.connect(self._passDocumentInsert)
@@ -512,11 +511,9 @@ class GuiMain(QMainWindow):
def saveProject(self, autoSave: bool = False) -> bool:
"""Save the current project."""
- if not SHARED.hasProject:
- logger.error("No project open")
- return False
- self.projView.saveProjectTasks()
- return SHARED.saveProject(autoSave=autoSave)
+ if SHARED.hasProject:
+ return SHARED.saveProject(autoSave=autoSave)
+ return False
##
# Document Actions
@@ -743,9 +740,8 @@ class GuiMain(QMainWindow):
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
tStart = time()
- self.projView.saveProjectTasks()
SHARED.project.index.rebuildIndex()
- self.projView.populateTree()
+ SHARED.project.tree.refreshAllItems()
self.novelView.refreshTree()
tEnd = time()
@@ -1063,7 +1059,7 @@ class GuiMain(QMainWindow):
))
if tree:
- self.projView.populateTree()
+ SHARED.project.tree.refreshAllItems()
if theme:
# We are doing this manually instead of connecting to
diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py
index 3eba76ac..6577290d 100644
--- a/novelwriter/tools/manusbuild.py
+++ b/novelwriter/tools/manusbuild.py
@@ -394,13 +394,9 @@ class GuiManuscriptBuild(NDialog):
if isinstance(rItem, NWItem):
rootMap[rHandle] = rItem.itemName
- itemIcon = SHARED.theme.getItemIcon(
- nwItem.itemType, nwItem.itemClass,
- nwItem.itemLayout, nwItem.mainHeading
- )
rootName = rootMap.get(rHandle, "??????")
item = QListWidgetItem(f"{rootName}: {nwItem.itemName}")
- item.setIcon(itemIcon)
+ item.setIcon(nwItem.getMainIcon())
self.listContent.addItem(item)
return
diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py
index bae2b19d..bca7cbaa 100644
--- a/novelwriter/tools/manussettings.py
+++ b/novelwriter/tools/manussettings.py
@@ -432,13 +432,8 @@ class _FilterTab(NFixedPage):
if nwItem.isInactiveClass() or not self._build.isRootAllowed(rHandle):
continue
- hLevel = nwItem.mainHeading
- itemIcon = SHARED.theme.getItemIcon(
- nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
- )
-
trItem = QTreeWidgetItem()
- trItem.setIcon(self.C_NAME, itemIcon)
+ trItem.setIcon(self.C_NAME, nwItem.getMainIcon())
trItem.setText(self.C_NAME, nwItem.itemName)
trItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
trItem.setData(self.C_DATA, self.D_FILE, isFile)
@@ -489,11 +484,8 @@ class _FilterTab(NFixedPage):
self.filterOpt.addLabel(self.tr("Select Root Folders"))
for tHandle, nwItem in SHARED.project.tree.iterRoots(None):
if not nwItem.isInactiveClass():
- itemIcon = SHARED.theme.getItemIcon(
- nwItem.itemType, nwItem.itemClass, nwItem.itemLayout
- )
self.filterOpt.addItem(
- itemIcon, nwItem.itemName, f"root:{tHandle}",
+ nwItem.getMainIcon(), nwItem.itemName, f"root:{tHandle}",
default=self._build.isRootAllowed(tHandle)
)
From 7ed619cf2e26959909d81cb2e97be7774b92aade Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 21 Nov 2024 01:33:09 +0100
Subject: [PATCH 25/39] Restore project item emphasis
---
novelwriter/core/item.py | 13 +++++++++++--
novelwriter/core/itemmodel.py | 8 +++++---
novelwriter/gui/theme.py | 3 +++
tests/tools.py | 2 +-
4 files changed, 20 insertions(+), 6 deletions(-)
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index 2448e314..dc008650 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -27,9 +27,9 @@ import logging
from typing import TYPE_CHECKING, Any
-from PyQt5.QtGui import QIcon
+from PyQt5.QtGui import QFont, QIcon
-from novelwriter import SHARED
+from novelwriter import CONFIG, SHARED
from novelwriter.common import (
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified,
yesNo
@@ -325,6 +325,15 @@ class NWItem:
"""Get the main item icon."""
return SHARED.theme.getItemIcon(self._type, self._class, self._layout, self._heading)
+ def getMainFont(self) -> QFont:
+ """Get the main item icon."""
+ if CONFIG.emphLabels and self._layout == nwItemLayout.DOCUMENT:
+ if self._heading == "H1":
+ return SHARED.theme.guiFontBU
+ elif self._heading == "H2":
+ return SHARED.theme.guiFontB
+ return SHARED.theme.guiFont
+
def getImportStatus(self) -> tuple[str, QIcon]:
"""Return the relevant importance or status label and icon for
the current item based on its class.
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 4f9828c0..0766c905 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -29,7 +29,7 @@ import logging
from typing import TYPE_CHECKING
from PyQt5.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt
-from PyQt5.QtGui import QIcon
+from PyQt5.QtGui import QFont, QIcon
from novelwriter.common import decodeMimeHandles, minmax
from novelwriter.constants import nwConst
@@ -46,6 +46,7 @@ COL_MASK = 0x0100
C_LABEL_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole
C_LABEL_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole
+C_LABEL_FONT = 0x0000 | Qt.ItemDataRole.FontRole
C_COUNT_TEXT = 0x0100 | Qt.ItemDataRole.DisplayRole
C_COUNT_ICON = 0x0100 | Qt.ItemDataRole.DecorationRole
C_COUNT_ALIGN = 0x0100 | Qt.ItemDataRole.TextAlignmentRole
@@ -58,7 +59,7 @@ NODE_FLAGS = Qt.ItemFlag.ItemIsEnabled
NODE_FLAGS |= Qt.ItemFlag.ItemIsSelectable
NODE_FLAGS |= Qt.ItemFlag.ItemIsDropEnabled
-T_NodeData = str | QIcon | Qt.AlignmentFlag | None
+T_NodeData = str | QIcon | QFont | Qt.AlignmentFlag | None
class ProjectNode:
@@ -75,7 +76,7 @@ class ProjectNode:
self._children: list[ProjectNode] = []
self._parent: ProjectNode | None = None
self._row = 0
- self._cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {}
+ self._cache: dict[int, T_NodeData] = {}
self._flags = NODE_FLAGS
self.refresh()
self.updateCount()
@@ -117,6 +118,7 @@ class ProjectNode:
# Label
self._cache[C_LABEL_ICON] = self._item.getMainIcon()
self._cache[C_LABEL_TEXT] = self._item.itemName
+ self._cache[C_LABEL_FONT] = self._item.getMainFont()
# Count
self._cache[C_COUNT_ALIGN] = QtAlignRight
diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py
index 40f65531..1ed7e27b 100644
--- a/novelwriter/gui/theme.py
+++ b/novelwriter/gui/theme.py
@@ -151,6 +151,9 @@ class GuiTheme:
self.guiFont = QApplication.font()
self.guiFontB = QApplication.font()
self.guiFontB.setBold(True)
+ self.guiFontBU = QApplication.font()
+ self.guiFontBU.setBold(True)
+ self.guiFontBU.setUnderline(True)
self.guiFontSmall = QApplication.font()
self.guiFontSmall.setPointSizeF(0.9*self.guiFont.pointSizeF())
diff --git a/tests/tools.py b/tests/tools.py
index 547c83e3..c48e3216 100644
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -210,7 +210,7 @@ def buildTestProject(obj: object, projPath: Path) -> None:
project._valid = True
if nwGUI is not None:
- nwGUI.projView.populateTree()
+ nwGUI.projView.openProjectTasks()
return
From e0b27317f384197a283958841c9bcc6714b22350 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 21 Nov 2024 22:24:34 +0100
Subject: [PATCH 26/39] Make sure deleted documents are closed if open
---
novelwriter/core/project.py | 1 +
novelwriter/shared.py | 4 +++-
tests/mocked.py | 5 ++++-
3 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index be383d0e..bc121b74 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -174,6 +174,7 @@ class NWProject:
project entry and a document file if it exists.
"""
if self._tree.checkType(tHandle, nwItemType.FILE):
+ SHARED.closeDocument(tHandle)
doc = self._storage.getDocument(tHandle)
if not doc.deleteDocument():
SHARED.error(
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index 7455b834..cdeb7614 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -175,10 +175,12 @@ class SharedData(QObject):
logger.debug("Thread Pool Max Count: %d", QThreadPool.globalInstance().maxThreadCount())
return
- def closeEditor(self, tHandle: str | None = None) -> None:
+ def closeDocument(self, tHandle: str | None = None) -> None:
"""Close the document editor, optionally a specific document."""
if tHandle is None or tHandle == self.mainGui.docEditor.docHandle:
self.mainGui.closeDocument()
+ if tHandle is None or tHandle == self.mainGui.docViewer.docHandle:
+ self.mainGui.closeViewerPanel()
return
def saveEditor(self, tHandle: str | None = None) -> None:
diff --git a/tests/mocked.py b/tests/mocked.py
index 986fd244..f5df63bf 100644
--- a/tests/mocked.py
+++ b/tests/mocked.py
@@ -20,7 +20,7 @@ along with this program. If not, see .
"""
from __future__ import annotations
-from PyQt5.QtGui import QIcon, QPixmap
+from PyQt5.QtGui import QFont, QIcon, QPixmap
from PyQt5.QtWidgets import QWidget
@@ -68,6 +68,9 @@ class MockTheme:
def __init__(self):
self.baseIconHeight = 20
+ self.guiFont = QFont()
+ self.guiFontB = QFont()
+ self.guiFontBU = QFont()
return
def getPixmap(self, *a):
From ffe6cd0a6593a186c7a2aebddb08d27648ca719a Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 22 Nov 2024 00:15:12 +0100
Subject: [PATCH 27/39] Update item change signals
---
novelwriter/core/index.py | 4 ----
novelwriter/core/itemmodel.py | 10 +++++++---
novelwriter/core/tree.py | 28 ++++++++++++++++++----------
novelwriter/enum.py | 7 +++++++
novelwriter/gui/doceditor.py | 10 +++++-----
novelwriter/gui/docviewer.py | 8 ++++----
novelwriter/gui/docviewerpanel.py | 6 +++---
novelwriter/gui/itemdetails.py | 24 ++++++++++++++++++------
novelwriter/gui/projtree.py | 27 ++++++++++++++-------------
novelwriter/guimain.py | 12 +++++-------
novelwriter/shared.py | 18 +++++++++---------
11 files changed, 90 insertions(+), 64 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 30d32413..a78e3b06 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -297,10 +297,6 @@ class NWIndex:
self._rootChange[tItem.itemRoot] = nowTime
if not blockSignal:
tItem.notifyToRefresh()
- SHARED.indexSignalProxy({
- "event": "scanText",
- "handle": tHandle,
- })
return True
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 0766c905..aab17c61 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -78,6 +78,7 @@ class ProjectNode:
self._row = 0
self._cache: dict[int, T_NodeData] = {}
self._flags = NODE_FLAGS
+ self._count = 0
self.refresh()
self.updateCount()
return
@@ -193,7 +194,6 @@ class ProjectNode:
else:
child._row = len(self._children)
self._children.append(child)
- self.updateCount()
return
def takeChild(self, pos: int) -> ProjectNode | None:
@@ -450,8 +450,12 @@ class ProjectModel(QAbstractItemModel):
for node in pruned:
if node.item.itemParent not in handles:
index = self.indexFromNode(node)
- if child := self.removeChild(index.parent(), index.row()):
- self.insertChild(child, target, pos)
+ if temp := self.removeChild(index.parent(), index.row()):
+ self.insertChild(temp, target, pos)
+ for child in reversed(node.allChildren()):
+ node._updateRelationships(child)
+ child.item.notifyToRefresh()
+ node.item.notifyToRefresh()
return
##
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 5231f90c..4a7a85d2 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -36,7 +36,7 @@ from novelwriter import SHARED
from novelwriter.constants import nwFiles, nwLabels, trConst
from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
-from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
+from novelwriter.enum import nwChange, nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
if TYPE_CHECKING: # pragma: no cover
@@ -90,6 +90,8 @@ class NWTree:
@property
def trash(self) -> ProjectNode | None:
"""Return trash node, if it exists."""
+ if self._trash:
+ return self._trash
return self._getTrashNode()
@property
@@ -108,8 +110,8 @@ class NWTree:
"""Clear the item tree entirely."""
oldModel = self._model
self._model = ProjectModel(self)
- self._items = {}
- self._nodes = {}
+ self._items.clear()
+ self._nodes.clear()
self._trash = None
oldModel.deleteLater()
del oldModel
@@ -124,7 +126,7 @@ class NWTree:
self._model.insertChild(node, index, pos)
self._nodes[item.itemHandle] = node
self._items[item.itemHandle] = item
- self._project.setProjectChanged(True)
+ self._itemChange(item.itemHandle, nwChange.CREATE)
else:
logger.error("Could not locate parent of '%s'", item.itemHandle)
return False
@@ -133,7 +135,7 @@ class NWTree:
self._model.insertChild(node, QModelIndex(), pos)
self._nodes[item.itemHandle] = node
self._items[item.itemHandle] = item
- self._project.setProjectChanged(True)
+ self._itemChange(item.itemHandle, nwChange.CREATE)
else:
logger.error("Invalid project item '%s'", item.itemHandle)
return False
@@ -144,6 +146,7 @@ class NWTree:
if (node := self._nodes.get(tHandle)) and tHandle in self._items:
index = self._model.indexFromNode(node)
if index.isValid() and self._model.removeChild(index.parent(), index.row()):
+ self._itemChange(tHandle, nwChange.DELETE)
del self._nodes[tHandle]
del self._items[tHandle]
return True
@@ -234,18 +237,14 @@ class NWTree:
"""Refresh these items on the GUI. If they are an ordered range,
also set the isRange flag to True.
"""
- change = False
for tHandle in items:
if node := self._nodes.get(tHandle):
node.refresh()
node.updateCount()
- SHARED.projectSignalProxy({"event": "projectItem", "handle": tHandle})
indexS = self._model.indexFromNode(node, 0)
indexE = self._model.indexFromNode(node, 3)
self._model.dataChanged.emit(indexS, indexE)
- change = True
- if change:
- self._project.setProjectChanged(True)
+ self._itemChange(tHandle, nwChange.UPDATE)
return
def refreshAllItems(self) -> None:
@@ -463,6 +462,13 @@ class NWTree:
# Internal Functions
##
+ def _itemChange(self, tHandle: str, change: nwChange) -> None:
+ """Signal item change and notify project."""
+ logger.debug("Item change: %s -> %s", tHandle, change.name)
+ self._project.setProjectChanged(True)
+ SHARED.projectSignalProxy({"event": "itemChanged", "handle": tHandle, "change": change})
+ return
+
def _getTrashNode(self) -> ProjectNode | None:
"""Get the trash node. If it doesn't exist, create it."""
for node in self._model.root.children:
@@ -483,6 +489,7 @@ class NWTree:
if parent := self._nodes.get(pHandle):
node = ProjectNode(item)
parent.addChild(node)
+ parent.updateCount()
self._items[handle] = item
self._nodes[handle] = node
elif pHandle in items:
@@ -491,6 +498,7 @@ class NWTree:
elif item.isRootType():
node = ProjectNode(item)
self._model.root.addChild(node)
+ self._model.root.updateCount()
self._items[handle] = item
self._nodes[handle] = node
return remains
diff --git a/novelwriter/enum.py b/novelwriter/enum.py
index 7441bc9b..c736b765 100644
--- a/novelwriter/enum.py
+++ b/novelwriter/enum.py
@@ -75,6 +75,13 @@ class nwTrinary(Enum):
POSITIVE = 1
+class nwChange(Enum):
+
+ CREATE = 0
+ UPDATE = 1
+ DELETE = 2
+
+
class nwDocMode(Enum):
VIEW = 0
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index d6e326b8..5a5a6ba3 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -57,8 +57,8 @@ from novelwriter.common import decodeMimeHandles, minmax, qtLambda, transferCase
from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
from novelwriter.core.document import NWDocument
from novelwriter.enum import (
- nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwItemType,
- nwTrinary
+ nwChange, nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass,
+ nwItemType, nwTrinary
)
from novelwriter.extensions.configlayout import NColourLabel
from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -1064,12 +1064,12 @@ class GuiDocEditor(QPlainTextEdit):
# Public Slots
##
- @pyqtSlot(str)
- def updateDocInfo(self, tHandle: str) -> None:
+ @pyqtSlot(str, Enum)
+ def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Called when an item label is changed to check if the document
title bar needs updating,
"""
- if tHandle and tHandle == self._docHandle:
+ if tHandle == self._docHandle and change == nwChange.UPDATE:
self.docHeader.setHandle(tHandle)
self.docFooter.updateInfo()
self.updateDocMargins()
diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py
index 2675eaa1..735c15e1 100644
--- a/novelwriter/gui/docviewer.py
+++ b/novelwriter/gui/docviewer.py
@@ -43,7 +43,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import decodeMimeHandles, qtLambda
from novelwriter.constants import nwConst, nwStyles, nwUnicode
-from novelwriter.enum import nwDocAction, nwDocMode, nwItemType
+from novelwriter.enum import nwChange, nwDocAction, nwDocMode, nwItemType
from novelwriter.error import logException
from novelwriter.extensions.configlayout import NColourLabel
from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -346,10 +346,10 @@ class GuiDocViewer(QTextBrowser):
# Public Slots
##
- @pyqtSlot(str)
- def updateDocInfo(self, tHandle: str) -> None:
+ @pyqtSlot(str, Enum)
+ def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Update the header title bar if needed."""
- if tHandle and tHandle == self._docHandle:
+ if tHandle == self._docHandle and change == nwChange.UPDATE:
self.docHeader.setHandle(tHandle)
self.updateDocMargins()
return
diff --git a/novelwriter/gui/docviewerpanel.py b/novelwriter/gui/docviewerpanel.py
index 01c9d2f1..91c4eb61 100644
--- a/novelwriter/gui/docviewerpanel.py
+++ b/novelwriter/gui/docviewerpanel.py
@@ -37,7 +37,7 @@ from novelwriter import CONFIG, SHARED
from novelwriter.common import checkInt
from novelwriter.constants import nwLabels, nwLists, nwStyles, trConst
from novelwriter.core.index import IndexHeading, IndexItem
-from novelwriter.enum import nwDocMode, nwItemClass
+from novelwriter.enum import nwChange, nwDocMode, nwItemClass
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_FLAT_TABS, STYLES_MIN_TOOLBUTTON
from novelwriter.types import QtDecoration, QtHeaderFixed, QtHeaderToContents, QtUserRole
@@ -151,8 +151,8 @@ class GuiDocViewerPanel(QWidget):
self.updateHandle(self._lastHandle)
return
- @pyqtSlot(str)
- def projectItemChanged(self, tHandle: str) -> None:
+ @pyqtSlot(str, Enum)
+ def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Update meta data for project item."""
self.tabBackRefs.refreshDocument(tHandle)
activeOnly = self.aInactive.isChecked()
diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py
index 2b98f730..2a90c7b5 100644
--- a/novelwriter/gui/itemdetails.py
+++ b/novelwriter/gui/itemdetails.py
@@ -25,12 +25,15 @@ from __future__ import annotations
import logging
+from enum import Enum
+
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtWidgets import QGridLayout, QLabel, QWidget
from novelwriter import CONFIG, SHARED
from novelwriter.common import elide
from novelwriter.constants import nwLabels, nwStats, trConst
+from novelwriter.enum import nwChange
from novelwriter.types import (
QtAlignLeft, QtAlignLeftBase, QtAlignRight, QtAlignRightBase,
QtAlignRightMiddle
@@ -220,12 +223,7 @@ class GuiItemDetails(QWidget):
self.updateViewBox(self._handle)
return
- ##
- # Public Slots
- ##
-
- @pyqtSlot(str)
- def updateViewBox(self, tHandle: str) -> None:
+ def updateViewBox(self, tHandle: str | None) -> None:
"""Populate the details box from a given handle."""
if tHandle is None:
self.clearDetails()
@@ -286,6 +284,20 @@ class GuiItemDetails(QWidget):
return
+ ##
+ # Public Slots
+ ##
+
+ @pyqtSlot(str, Enum)
+ def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
+ """Process project item change."""
+ if tHandle == self._handle:
+ if change == nwChange.UPDATE:
+ self.updateViewBox(tHandle)
+ elif change == nwChange.DELETE:
+ self.updateViewBox(None)
+ return
+
@pyqtSlot(str, int, int, int)
def updateCounts(self, tHandle: str, cC: int, wC: int, pC: int) -> None:
"""Update the counts if the handle is the same as the one we're
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 793976be..373f9275 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -48,7 +48,7 @@ from novelwriter.dialogs.docmerge import GuiDocMerge
from novelwriter.dialogs.docsplit import GuiDocSplit
from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.dialogs.projectsettings import GuiProjectSettings
-from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType
+from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwItemLayout, nwItemType
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
from novelwriter.types import (
@@ -215,10 +215,10 @@ class GuiProjectView(QWidget):
self.projTree.setActiveHandle(tHandle)
return
- @pyqtSlot(str)
- def projectItemChanged(self, tHandle: str) -> None:
+ @pyqtSlot(str, Enum)
+ def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Refresh other content when project item changed."""
- self.projBar.processTemplateDocuments(tHandle)
+ self.projBar.processTemplateDocuments(tHandle, change)
return
@pyqtSlot(str)
@@ -409,16 +409,19 @@ class GuiProjectToolBar(QWidget):
"""Build the templates menu."""
for tHandle, _ in SHARED.project.tree.iterRoots(nwItemClass.TEMPLATE):
for dHandle in SHARED.project.tree.subTree(tHandle):
- self.processTemplateDocuments(dHandle)
+ self.processTemplateDocuments(dHandle, nwChange.CREATE)
return
- def processTemplateDocuments(self, tHandle: str) -> None:
+ def processTemplateDocuments(self, tHandle: str, change: nwChange) -> None:
"""Process change in tree items to update menu content."""
- if item := SHARED.project.tree[tHandle]:
- if item.isTemplateFile() and item.isActive:
- self.mTemplates.addUpdate(tHandle, item.itemName, item.getMainIcon())
- elif tHandle in self.mTemplates:
- self.mTemplates.remove(tHandle)
+ if change in (nwChange.CREATE, nwChange.UPDATE):
+ if item := SHARED.project.tree[tHandle]:
+ if item.isTemplateFile() and item.isActive:
+ self.mTemplates.addUpdate(tHandle, item.itemName, item.getMainIcon())
+ elif tHandle in self.mTemplates:
+ self.mTemplates.remove(tHandle)
+ elif change == nwChange.DELETE and tHandle in self.mTemplates:
+ self.mTemplates.remove(tHandle)
return
##
@@ -916,9 +919,7 @@ class GuiProjectTree(QTreeView):
if node := model.node(index):
for child in reversed(node.allChildren()):
SHARED.project.removeItem(child.item.itemHandle)
- self.projView.projBar.processTemplateDocuments(child.item.itemHandle)
SHARED.project.removeItem(node.item.itemHandle)
- self.projView.projBar.processTemplateDocuments(node.item.itemHandle)
elif trashNode := SHARED.project.tree.trash:
if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")):
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 6a724e4a..802b1791 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -213,14 +213,12 @@ class GuiMain(QMainWindow):
SHARED.indexChangedTags.connect(self.docEditor.updateChangedTags)
SHARED.indexChangedTags.connect(self.docViewerPanel.updateChangedTags)
SHARED.indexCleared.connect(self.docViewerPanel.indexWasCleared)
- SHARED.indexScannedText.connect(self.docViewerPanel.projectItemChanged)
- SHARED.indexScannedText.connect(self.itemDetails.updateViewBox)
SHARED.mainClockTick.connect(self._timeTick)
- SHARED.projectItemChanged.connect(self.projView.projectItemChanged)
- SHARED.projectItemChanged.connect(self.docEditor.updateDocInfo)
- SHARED.projectItemChanged.connect(self.docViewer.updateDocInfo)
- SHARED.projectItemChanged.connect(self.docViewerPanel.projectItemChanged)
- SHARED.projectItemChanged.connect(self.itemDetails.updateViewBox)
+ SHARED.projectItemChanged.connect(self.docEditor.onProjectItemChanged)
+ SHARED.projectItemChanged.connect(self.docViewer.onProjectItemChanged)
+ SHARED.projectItemChanged.connect(self.docViewerPanel.onProjectItemChanged)
+ SHARED.projectItemChanged.connect(self.itemDetails.onProjectItemChanged)
+ SHARED.projectItemChanged.connect(self.projView.onProjectItemChanged)
SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus)
SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage)
SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage)
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index cdeb7614..148f84d0 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -26,6 +26,7 @@ from __future__ import annotations
import logging
+from enum import Enum
from pathlib import Path
from time import time
from typing import TYPE_CHECKING, TypeVar
@@ -37,7 +38,7 @@ from PyQt5.QtWidgets import QFileDialog, QFontDialog, QMessageBox, QWidget
from novelwriter.common import formatFileFilter
from novelwriter.constants import nwFiles
from novelwriter.core.spellcheck import NWSpellEnchant
-from novelwriter.enum import nwItemClass
+from novelwriter.enum import nwChange, nwItemClass
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
@@ -60,9 +61,8 @@ class SharedData(QObject):
indexAvailable = pyqtSignal()
indexChangedTags = pyqtSignal(list, list)
indexCleared = pyqtSignal()
- indexScannedText = pyqtSignal(str)
mainClockTick = pyqtSignal()
- projectItemChanged = pyqtSignal(str)
+ projectItemChanged = pyqtSignal(str, Enum)
projectStatusChanged = pyqtSignal(bool)
projectStatusMessage = pyqtSignal(str)
spellLanguageChanged = pyqtSignal(str, str)
@@ -319,11 +319,9 @@ class SharedData(QObject):
def indexSignalProxy(self, data: dict) -> None:
"""Emit signals on behalf of the index."""
event = data.get("event")
- logger.debug("Received '%s' event from the index", event)
+ logger.debug("Signal Proxy: %s", event)
if event == "updateTags":
self.indexChangedTags.emit(data.get("updated", []), data.get("deleted", []))
- elif event == "scanText":
- self.indexScannedText.emit(data.get("handle", ""))
elif event == "clearIndex":
self.indexCleared.emit()
elif event == "buildIndex":
@@ -333,11 +331,13 @@ class SharedData(QObject):
def projectSignalProxy(self, data: dict) -> None:
"""Emit signals on project data change."""
event = data.get("event")
- logger.debug("Received '%s' event from project data", event)
+ logger.debug("Signal Proxy: %s", event)
if event == "statusLabels":
self.statusLabelsChanged.emit(data.get("kind", ""))
- elif event == "projectItem":
- self.projectItemChanged.emit(data.get("handle", ""))
+ elif event == "itemChanged":
+ self.projectItemChanged.emit(
+ data.get("handle", ""), data.get("change", nwChange.UPDATE)
+ )
return
##
From a64ed405f3c76155178f1225032a576ec5366134 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 22 Nov 2024 00:44:14 +0100
Subject: [PATCH 28/39] Add some clear data functions
---
novelwriter/core/index.py | 6 +++---
novelwriter/core/itemmodel.py | 6 ++++++
novelwriter/core/project.py | 10 ++++++++--
novelwriter/core/tree.py | 1 +
novelwriter/guimain.py | 2 +-
novelwriter/shared.py | 1 +
tests/test_core/test_core_index.py | 10 +++++-----
tests/test_gui/test_gui_docviewerpanel.py | 8 ++++----
8 files changed, 29 insertions(+), 15 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index a78e3b06..81f5e684 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -116,7 +116,7 @@ class NWIndex:
# Public Methods
##
- def clearIndex(self) -> None:
+ def clear(self) -> None:
"""Clear the index dictionaries and time stamps."""
self._tagsIndex.clear()
self._itemIndex.clear()
@@ -125,9 +125,9 @@ class NWIndex:
SHARED.indexSignalProxy({"event": "clearIndex"})
return
- def rebuildIndex(self) -> None:
+ def rebuild(self) -> None:
"""Rebuild the entire index from scratch."""
- self.clearIndex()
+ self.clear()
for nwItem in self._project.tree:
if nwItem.isFileType():
text = self._project.storage.getDocumentText(nwItem.itemHandle)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index aab17c61..6da4360f 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -138,6 +138,7 @@ class ProjectNode:
def updateCount(self, propagate: bool = True) -> None:
"""Update counts, and propagate upwards in the tree."""
+ # print("Counting", self._item.itemHandle)
self._count = self._item.wordCount + sum(c._count for c in self._children)
self._cache[C_COUNT_TEXT] = f"{self._count:n}"
if propagate and (parent := self._parent):
@@ -462,6 +463,11 @@ class ProjectModel(QAbstractItemModel):
# Other Methods
##
+ def clear(self) -> None:
+ """Clear the project model."""
+ self._root._children.clear()
+ return
+
def allExpanded(self) -> list[QModelIndex]:
"""Return a list of all expanded items."""
expanded = []
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index bc121b74..28fad307 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -89,6 +89,12 @@ class NWProject:
logger.debug("Delete: NWProject")
return
+ def clear(self) -> None:
+ """Clear the project."""
+ self._tree.clear()
+ self._index.clear()
+ return
+
##
# Properties
##
@@ -352,7 +358,7 @@ class NWProject:
self._index.loadIndex()
if xmlReader.state == XMLReadState.WAS_LEGACY:
# Often, the index needs to be rebuilt when updating format
- self._index.rebuildIndex()
+ self._index.rebuild()
self.updateWordCounts()
self._session.startSession()
@@ -417,7 +423,7 @@ class NWProject:
def closeProject(self, idleTime: float = 0.0) -> None:
"""Close the project."""
logger.info("Closing project")
- self._index.clearIndex() # Triggers clear signal, see #1718
+ self._index.clear() # Triggers clear signal, see #1718
self._options.saveSettings()
self._tree.writeToCFile()
self._session.appendSession(idleTime)
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 4a7a85d2..4bc6df60 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -109,6 +109,7 @@ class NWTree:
def clear(self) -> None:
"""Clear the item tree entirely."""
oldModel = self._model
+ oldModel.clear()
self._model = ProjectModel(self)
self._items.clear()
self._nodes.clear()
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 802b1791..ee8c7b32 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -738,7 +738,7 @@ class GuiMain(QMainWindow):
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
tStart = time()
- SHARED.project.index.rebuildIndex()
+ SHARED.project.index.rebuild()
SHARED.project.tree.refreshAllItems()
self.novelView.refreshTree()
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index 148f84d0..f5fa135e 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -398,6 +398,7 @@ class SharedData(QObject):
"""Create a new project and spell checking instance."""
from novelwriter.core.project import NWProject
if isinstance(self._project, NWProject):
+ self._project.clear()
del self._project
del self._spelling
self._project = NWProject()
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index 94eea4e4..2b64273b 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -90,7 +90,7 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
assert index._itemIndex["4c4f28287af27"] is None
# Clear the index
- index.clearIndex()
+ index.clear()
assert index._tagsIndex._tags == {}
assert index._itemIndex._items == {}
@@ -113,8 +113,8 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
assert str(index._itemIndex.packData()) == itemsIndex
# Rebuild index
- index.clearIndex()
- index.rebuildIndex()
+ index.clear()
+ index.rebuild()
assert str(index._tagsIndex.packData()) == tagIndex
assert str(index._itemIndex.packData()) == itemsIndex
@@ -221,7 +221,7 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd):
mockRnd.reset()
buildTestProject(project, fncPath)
index = project.index
- index.clearIndex()
+ index.clear()
nHandle = project.newFile("Hello", C.hNovelRoot)
cHandle = project.newFile("Jane", C.hCharRoot)
@@ -1155,7 +1155,7 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
- project.index.clearIndex()
+ project.index.clear()
nHandle = C.hTitlePage
cHandle = C.hChapterDoc
diff --git a/tests/test_gui/test_gui_docviewerpanel.py b/tests/test_gui/test_gui_docviewerpanel.py
index 5456a427..729c5690 100644
--- a/tests/test_gui/test_gui_docviewerpanel.py
+++ b/tests/test_gui/test_gui_docviewerpanel.py
@@ -87,11 +87,11 @@ def testGuiViewerPanel_BackRefs(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
assert item.text(tabBackRefs.C_TITLE) == "Scene One"
# Clear Index
- SHARED.project.index.clearIndex()
+ SHARED.project.index.clear()
assert tabBackRefs.topLevelItemCount() == 0
# Rebuild Index
- SHARED.project.index.rebuildIndex()
+ SHARED.project.index.rebuild()
assert tabBackRefs.topLevelItemCount() == 1
# Test Update Theme
@@ -182,11 +182,11 @@ def testGuiViewerPanel_Tags(qtbot, monkeypatch, caplog, nwGUI, projPath, mockRnd
assert item.text(charTab.C_TITLE) == "Jane Smith"
# Clear Index
- SHARED.project.index.clearIndex()
+ SHARED.project.index.clear()
assert charTab.topLevelItemCount() == 0
# Rebuild Index
- SHARED.project.index.rebuildIndex()
+ SHARED.project.index.rebuild()
assert charTab.topLevelItemCount() == 2
# Test Update Theme
From fea989c1f856d38caef28c755af2bc5bbc19b66e Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 22 Nov 2024 00:56:33 +0100
Subject: [PATCH 29/39] Update comments
---
novelwriter/core/itemmodel.py | 13 ++++++++-----
novelwriter/core/tree.py | 3 ++-
novelwriter/gui/projtree.py | 3 ++-
3 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 6da4360f..34579b95 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -3,8 +3,8 @@ novelWriter – Project Item Model
================================
File History:
-Created: 2024-11-16 [2.7b1] ProjectNode
-Created: 2024-11-16 [2.7b1] ProjectModel
+Created: 2024-11-16 [2.6b2] ProjectNode
+Created: 2024-11-16 [2.6b2] ProjectModel
This file is a part of novelWriter
Copyright 2018–2024, Veronica Berglyd Olsen
@@ -42,7 +42,7 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__)
-COL_MASK = 0x0100
+C_FACTOR = 0x0100
C_LABEL_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole
C_LABEL_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole
@@ -138,7 +138,6 @@ class ProjectNode:
def updateCount(self, propagate: bool = True) -> None:
"""Update counts, and propagate upwards in the tree."""
- # print("Counting", self._item.itemHandle)
self._count = self._item.wordCount + sum(c._count for c in self._children)
self._cache[C_COUNT_TEXT] = f"{self._count:n}"
if propagate and (parent := self._parent):
@@ -159,7 +158,7 @@ class ProjectNode:
def data(self, column: int, role: Qt.ItemDataRole) -> T_NodeData:
"""Return cached node data."""
- return self._cache.get(COL_MASK*column | role)
+ return self._cache.get(C_FACTOR*column | role)
def flags(self) -> Qt.ItemFlag:
"""Return cached node flags."""
@@ -262,6 +261,10 @@ class ProjectModel(QAbstractItemModel):
logger.debug("Delete: ProjectModel")
return
+ ##
+ # Properties
+ ##
+
@property
def root(self) -> ProjectNode:
"""Return the model root item."""
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 4bc6df60..e97b8931 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -3,7 +3,8 @@ novelWriter – Project Tree Class
================================
File History:
-Created: 2020-05-07 [0.4.5] NWTree
+Created: 2020-05-07 [0.4.5] NWTree
+Rewritten: 2024-11-16 [2.6b2] NWTree
This file is a part of novelWriter
Copyright 2018–2024, Veronica Berglyd Olsen
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 373f9275..4f996c1b 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -7,7 +7,8 @@ Created: 2018-09-29 [0.0.1] GuiProjectTree
Created: 2022-06-06 [2.0rc1] GuiProjectView
Created: 2022-06-06 [2.0rc1] GuiProjectToolBar
Created: 2023-11-22 [2.2rc1] _TreeContextMenu
-Rewritten: 2024-11-17 [2.7b1] GuiProjectTree
+Rewritten: 2024-11-17 [2.6b2] GuiProjectTree
+Rewritten: 2024-11-20 [2.6b2] _TreeContextMenu
This file is a part of novelWriter
Copyright 2018–2024, Veronica Berglyd Olsen
From ca8bd8d271d4c53006e09e6a18fca77c77665301 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Fri, 22 Nov 2024 17:04:53 +0100
Subject: [PATCH 30/39] Disable project tree tests in core and gui
---
tests/test_core/test_core_tree.py | 8 ++++++++
tests/test_gui/test_gui_projtree.py | 14 ++++++++++++++
2 files changed, 22 insertions(+)
diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py
index 421522e2..b76d20a1 100644
--- a/tests/test_core/test_core_tree.py
+++ b/tests/test_core/test_core_tree.py
@@ -111,6 +111,7 @@ def mockItems(mockGUI, mockRnd):
@pytest.mark.core
+@pytest.mark.skip
def testCoreTree_BuildTree(mockGUI, mockItems):
"""Test building a project tree from a list of items."""
project = NWProject()
@@ -266,6 +267,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems):
@pytest.mark.core
+@pytest.mark.skip
def testCoreTree_PackUnpack(mockGUI, mockItems):
"""Test packing and unpacking data."""
project = NWProject()
@@ -293,6 +295,7 @@ def testCoreTree_PackUnpack(mockGUI, mockItems):
@pytest.mark.core
+@pytest.mark.skip
def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fncPath, mockRnd):
"""Check the project consistency."""
project = NWProject()
@@ -360,6 +363,7 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc
@pytest.mark.core
+@pytest.mark.skip
def testCoreTree_Methods(monkeypatch, mockGUI, mockItems):
"""Test various class methods."""
project = NWProject()
@@ -439,6 +443,7 @@ def testCoreTree_Methods(monkeypatch, mockGUI, mockItems):
@pytest.mark.core
+@pytest.mark.skip
def testCoreTree_MakeHandles(mockGUI):
"""Test generating item handles."""
random.seed(42)
@@ -466,6 +471,7 @@ def testCoreTree_MakeHandles(mockGUI):
@pytest.mark.core
+@pytest.mark.skip
def testCoreTree_Stats(mockGUI, mockItems):
"""Test project stats methods."""
project = NWProject()
@@ -484,6 +490,7 @@ def testCoreTree_Stats(mockGUI, mockItems):
@pytest.mark.core
+@pytest.mark.skip
def testCoreTree_Reorder(caplog, mockGUI, mockItems):
"""Test changing tree order."""
project = NWProject()
@@ -517,6 +524,7 @@ def testCoreTree_Reorder(caplog, mockGUI, mockItems):
@pytest.mark.core
+@pytest.mark.skip
def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
"""Test writing the ToC.txt file."""
project = NWProject()
diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py
index f5c65a46..2c572383 100644
--- a/tests/test_gui/test_gui_projtree.py
+++ b/tests/test_gui/test_gui_projtree.py
@@ -48,6 +48,7 @@ from tests.tools import C, buildTestProject
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
"""Test adding and removing items from the project tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
@@ -196,6 +197,7 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRn
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test adding and removing items from the project tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
@@ -298,6 +300,7 @@ def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_RequestDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
"""Test external requests for removing items from project tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
@@ -378,6 +381,7 @@ def testGuiProjTree_RequestDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPat
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_MoveItemToTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
"""Test moving items to Trash."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
@@ -428,6 +432,7 @@ def testGuiProjTree_MoveItemToTrash(qtbot, caplog, monkeypatch, nwGUI, projPath,
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_PermanentlyDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
"""Test permanently deleting items."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
@@ -477,6 +482,7 @@ def testGuiProjTree_PermanentlyDeleteItem(qtbot, caplog, monkeypatch, nwGUI, pro
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_EmptyTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
"""Test emptying Trash."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
@@ -527,6 +533,7 @@ def testGuiProjTree_EmptyTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mock
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText):
"""Test the merge document function."""
mergeData = {}
@@ -626,6 +633,7 @@ def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText):
"""Test the split document function."""
splitData = {}
@@ -735,6 +743,7 @@ def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_Duplicate(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
"""Test the duplicate items function."""
# Create a project
@@ -789,6 +798,7 @@ def testGuiProjTree_Duplicate(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mock
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_AutoScroll(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
"""Test the auto scroll feature."""
buildTestProject(nwGUI, projPath)
@@ -853,6 +863,7 @@ def testGuiProjTree_AutoScroll(qtbot, monkeypatch, nwGUI: GuiMain, projPath, moc
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_DragAndDrop(qtbot, monkeypatch, caplog, nwGUI: GuiMain, projPath, mockRnd):
"""Test the auto scroll feature."""
buildTestProject(nwGUI, projPath)
@@ -941,6 +952,7 @@ def testGuiProjTree_DragAndDrop(qtbot, monkeypatch, caplog, nwGUI: GuiMain, proj
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
"""Test various parts of the project tree class not covered by
other tests.
@@ -1105,6 +1117,7 @@ def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd)
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test the building of the project tree context menu. All this does
is test that the menu builds. It doesn't open the actual menu.
@@ -1411,6 +1424,7 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
@pytest.mark.gui
+@pytest.mark.skip
def testGuiProjTree_Templates(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test the templates feature of the project tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
From 1bf42ceb554564319ea7f82a0d55aba4025d88ec Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 23 Nov 2024 19:30:29 +0100
Subject: [PATCH 31/39] Update tests other than tree-related
---
novelwriter/core/itemmodel.py | 7 ++-
novelwriter/core/project.py | 13 ++++
novelwriter/core/status.py | 11 ++--
novelwriter/core/tree.py | 19 ++++--
novelwriter/dialogs/projectsettings.py | 4 +-
novelwriter/gui/noveltree.py | 6 +-
novelwriter/gui/outline.py | 21 +++----
novelwriter/gui/projtree.py | 20 +++---
novelwriter/guimain.py | 9 ++-
novelwriter/shared.py | 5 ++
tests/mocked.py | 18 ++----
.../coreProject_NewFileFolder_nwProject.nwx | 16 ++---
.../coreProject_NewRoot_nwProject.nwx | 28 ++++-----
.../coreTools_DocDuplicator_nwProject.nwx | 28 ++++-----
.../coreTools_ProjectBuilderA_nwProject.nwx | 30 ++++-----
.../coreTools_ProjectBuilderB_nwProject.nwx | 24 +++----
.../guiEditor_Main_Final_0000000000010.nwd | 10 ---
.../guiEditor_Main_Final_0000000000011.nwd | 14 ++---
.../guiEditor_Main_Final_0000000000012.nwd | 14 ++---
.../guiEditor_Main_Final_0000000000013.nwd | 10 +++
.../guiEditor_Main_Final_nwProject.nwx | 30 +++++----
.../guiEditor_Main_Initial_nwProject.nwx | 4 +-
tests/test_core/test_core_item.py | 2 +-
.../test_dialogs/test_dlg_projectsettings.py | 2 +-
tests/test_gui/test_gui_docviewer.py | 6 +-
tests/test_gui/test_gui_docviewerpanel.py | 13 ++--
tests/test_gui/test_gui_guimain.py | 63 ++++++++++---------
tests/test_gui/test_gui_outline.py | 5 +-
tests/test_gui/test_gui_statusbar.py | 2 +
29 files changed, 229 insertions(+), 205 deletions(-)
delete mode 100644 tests/reference/guiEditor_Main_Final_0000000000010.nwd
create mode 100644 tests/reference/guiEditor_Main_Final_0000000000013.nwd
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 34579b95..948b6146 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -91,6 +91,10 @@ class ProjectNode:
f"children={len(self._children)}>"
)
+ def __bool__(self) -> bool:
+ """A node should always evaluate to True."""
+ return True
+
##
# Properties
##
@@ -190,10 +194,10 @@ class ProjectNode:
self._updateRelationships(child)
if 0 <= pos < len(self._children):
self._children.insert(pos, child)
- self._refreshChildrenPos()
else:
child._row = len(self._children)
self._children.append(child)
+ self._refreshChildrenPos()
return
def takeChild(self, pos: int) -> ProjectNode | None:
@@ -229,6 +233,7 @@ class ProjectNode:
"""Update the row value on all children."""
for n, child in enumerate(self._children):
child._row = n
+ child.item.setOrder(n)
return
def _updateRelationships(self, child: ProjectNode) -> None:
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index 28fad307..ced91fd1 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -44,6 +44,7 @@ from novelwriter.core.options import OptionState
from novelwriter.core.projectdata import NWProjectData
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.sessions import NWSessionLog
+from novelwriter.core.status import T_StatusKinds, T_UpdateEntry
from novelwriter.core.storage import NWStorage, NWStorageOpen
from novelwriter.core.tree import NWTree
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
@@ -531,6 +532,18 @@ class NWProject:
self._data.itemImport.increment(nwItem.itemImport)
return
+ def updateStatus(self, kind: T_StatusKinds, update: T_UpdateEntry) -> None:
+ """Update status or import entries."""
+ if kind == "s":
+ self._data.itemStatus.update(update)
+ SHARED.projectSignalProxy({"event": "statusLabels", "kind": kind})
+ self._tree.refreshAllItems()
+ elif kind == "i":
+ self._data.itemImport.update(update)
+ SHARED.projectSignalProxy({"event": "statusLabels", "kind": kind})
+ self._tree.refreshAllItems()
+ return
+
def localLookup(self, word: str | int) -> str:
"""Look up a word or number in the translation map for the
project and return it. The variable is cast to a string before
diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py
index a5e2e4d6..7935e5aa 100644
--- a/novelwriter/core/status.py
+++ b/novelwriter/core/status.py
@@ -65,6 +65,9 @@ class StatusEntry:
NO_ENTRY = StatusEntry("", QColor(0, 0, 0), nwStatusShape.SQUARE, QIcon(), 0)
+T_UpdateEntry = list[tuple[str | None, StatusEntry]]
+T_StatusKinds = Literal["s", "i"]
+
class NWStatus:
@@ -73,7 +76,7 @@ class NWStatus:
__slots__ = ("_store", "_default", "_prefix", "_height")
- def __init__(self, prefix: Literal["s", "i"]) -> None:
+ def __init__(self, prefix: T_StatusKinds) -> None:
self._store: dict[str, StatusEntry] = {}
self._default = None
self._prefix = prefix[:1]
@@ -120,7 +123,7 @@ class NWStatus:
return key
- def update(self, update: list[tuple[str | None, StatusEntry]]) -> None:
+ def update(self, update: T_UpdateEntry) -> None:
"""Update the list of statuses."""
self._store.clear()
for key, entry in update:
@@ -130,10 +133,6 @@ class NWStatus:
if self._default not in self._store:
self._default = next(iter(self._store)) if self._store else None
- # Emit the change signal and refresh tree
- SHARED.projectSignalProxy({"event": "statusLabels", "kind": self._prefix})
- SHARED.project.tree.refreshAllItems()
-
return
def check(self, value: str) -> str:
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index e97b8931..1f919542 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -128,7 +128,7 @@ class NWTree:
self._model.insertChild(node, index, pos)
self._nodes[item.itemHandle] = node
self._items[item.itemHandle] = item
- self._itemChange(item.itemHandle, nwChange.CREATE)
+ self._itemChange(item, nwChange.CREATE)
else:
logger.error("Could not locate parent of '%s'", item.itemHandle)
return False
@@ -137,7 +137,7 @@ class NWTree:
self._model.insertChild(node, QModelIndex(), pos)
self._nodes[item.itemHandle] = node
self._items[item.itemHandle] = item
- self._itemChange(item.itemHandle, nwChange.CREATE)
+ self._itemChange(item, nwChange.CREATE)
else:
logger.error("Invalid project item '%s'", item.itemHandle)
return False
@@ -148,7 +148,7 @@ class NWTree:
if (node := self._nodes.get(tHandle)) and tHandle in self._items:
index = self._model.indexFromNode(node)
if index.isValid() and self._model.removeChild(index.parent(), index.row()):
- self._itemChange(tHandle, nwChange.DELETE)
+ self._itemChange(node.item, nwChange.DELETE)
del self._nodes[tHandle]
del self._items[tHandle]
return True
@@ -246,7 +246,7 @@ class NWTree:
indexS = self._model.indexFromNode(node, 0)
indexE = self._model.indexFromNode(node, 3)
self._model.dataChanged.emit(indexS, indexE)
- self._itemChange(tHandle, nwChange.UPDATE)
+ self._itemChange(node.item, nwChange.UPDATE)
return
def refreshAllItems(self) -> None:
@@ -464,11 +464,18 @@ class NWTree:
# Internal Functions
##
- def _itemChange(self, tHandle: str, change: nwChange) -> None:
+ def _itemChange(self, item: NWItem, change: nwChange) -> None:
"""Signal item change and notify project."""
+ tHandle = item.itemHandle
logger.debug("Item change: %s -> %s", tHandle, change.name)
self._project.setProjectChanged(True)
- SHARED.projectSignalProxy({"event": "itemChanged", "handle": tHandle, "change": change})
+ SHARED.projectSignalProxy(
+ {"event": "itemChanged", "handle": tHandle, "change": change}
+ )
+ if item.isRootType():
+ SHARED.projectSignalProxy(
+ {"event": "rootChanged", "handle": tHandle, "change": change}
+ )
return
def _getTrashNode(self) -> ProjectNode | None:
diff --git a/novelwriter/dialogs/projectsettings.py b/novelwriter/dialogs/projectsettings.py
index 6cbff7d2..5eecdd0a 100644
--- a/novelwriter/dialogs/projectsettings.py
+++ b/novelwriter/dialogs/projectsettings.py
@@ -185,11 +185,11 @@ class GuiProjectSettings(NDialog):
if self.statusPage.changed:
logger.debug("Updating status labels")
- project.data.itemStatus.update(self.statusPage.getNewList())
+ project.updateStatus("s", self.statusPage.getNewList())
if self.importPage.changed:
logger.debug("Updating importance labels")
- project.data.itemImport.update(self.importPage.getNewList())
+ project.updateStatus("i", self.importPage.getNewList())
if self.replacePage.changed:
logger.debug("Updating auto-replace settings")
diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py
index 7a3b114a..3358738f 100644
--- a/novelwriter/gui/noveltree.py
+++ b/novelwriter/gui/noveltree.py
@@ -41,7 +41,7 @@ from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, qtLambda
from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst
from novelwriter.core.index import IndexHeading
-from novelwriter.enum import nwDocMode, nwItemClass, nwOutline
+from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwOutline
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.extensions.novelselector import NovelSelector
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
@@ -174,8 +174,8 @@ class GuiNovelView(QWidget):
self.novelTree.refreshTree(rootHandle=SHARED.project.data.getLastHandle("novelTree"))
return
- @pyqtSlot(str)
- def updateRootItem(self, tHandle: str) -> None:
+ @pyqtSlot(str, Enum)
+ def updateRootItem(self, tHandle: str, change: nwChange) -> None:
"""If any root item changes, rebuild the novel root menu."""
self.novelBar.buildNovelRootMenu()
return
diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py
index 718caee0..e07f3c84 100644
--- a/novelwriter/gui/outline.py
+++ b/novelwriter/gui/outline.py
@@ -43,7 +43,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import checkInt, formatFileFilter, makeFileNameSafe
from novelwriter.constants import nwKeyWords, nwLabels, nwStats, nwStyles, trConst
-from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
+from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
from novelwriter.error import logException
from novelwriter.extensions.configlayout import NColourLabel
from novelwriter.extensions.novelselector import NovelSelector
@@ -165,8 +165,8 @@ class GuiOutlineView(QWidget):
# Public Slots
##
- @pyqtSlot(str)
- def updateRootItem(self, tHandle: str) -> None:
+ @pyqtSlot(str, Enum)
+ def updateRootItem(self, tHandle: str, change: nwChange) -> None:
"""Handle tasks whenever a root folders changes."""
self.outlineBar.populateNovelList()
self.outlineData.updateClasses()
@@ -380,8 +380,8 @@ class GuiOutlineTree(QTreeWidget):
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setExpandsOnDoubleClick(False)
self.setDragEnabled(False)
- self.itemDoubleClicked.connect(self._treeDoubleClick)
- self.itemSelectionChanged.connect(self._itemSelected)
+ self.itemDoubleClicked.connect(self._onItemDoubleClicked)
+ self.itemSelectionChanged.connect(self._onItemSelectionChanged)
self.setIconSize(SHARED.theme.baseIconSize)
self.setIndentation(0)
@@ -563,7 +563,7 @@ class GuiOutlineTree(QTreeWidget):
##
@pyqtSlot("QTreeWidgetItem*", int)
- def _treeDoubleClick(self, tItem: QTreeWidgetItem, tCol: int) -> None:
+ def _onItemDoubleClicked(self, tItem: QTreeWidgetItem, tCol: int) -> None:
"""Extract the handle and line number of the title double-
clicked, and send it to the main gui class for opening in the
document editor.
@@ -574,14 +574,13 @@ class GuiOutlineTree(QTreeWidget):
return
@pyqtSlot()
- def _itemSelected(self) -> None:
+ def _onItemSelectionChanged(self) -> None:
"""Extract the handle and line number of the currently selected
title, and send it to the details panel.
"""
- selItems = self.selectedItems()
- if selItems:
- tHandle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
- sTitle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
+ if items := self.selectedItems():
+ tHandle = items[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
+ sTitle = items[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
self.activeItemChanged.emit(tHandle, sTitle)
return
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 4f996c1b..aa3ea021 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -66,15 +66,9 @@ class GuiProjectView(QWidget):
available are mapped through to the project tree class.
"""
- # Signals triggered when the meta data values of items change
- rootFolderChanged = pyqtSignal(str)
-
- # Signals for user interaction with the project tree
- selectedItemChanged = pyqtSignal(str)
openDocumentRequest = pyqtSignal(str, Enum, str, bool)
-
- # Requests for the main GUI
projectSettingsRequest = pyqtSignal(int)
+ selectedItemChanged = pyqtSignal(str)
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
@@ -229,8 +223,8 @@ class GuiProjectView(QWidget):
self.projTree.newTreeItem(nwItemType.FILE, copyDoc=tHandle)
return
- @pyqtSlot(str)
- def updateRootItem(self, tHandle: str) -> None:
+ @pyqtSlot(str, Enum)
+ def updateRootItem(self, tHandle: str, change: nwChange) -> None:
"""Process root item changes."""
self.projBar.buildQuickLinksMenu()
return
@@ -625,9 +619,8 @@ class GuiProjectTree(QTreeView):
if root := SHARED.project.tree.nodes.get(itemRoot):
pos = root.row() + 1
- SHARED.project.newRoot(itemClass, pos)
+ tHandle = SHARED.project.newRoot(itemClass, pos)
self.restoreExpandedState()
- self.projView.rootFolderChanged.emit(tHandle)
elif itemType in (nwItemType.FILE, nwItemType.FOLDER):
@@ -684,7 +677,10 @@ class GuiProjectTree(QTreeView):
SHARED.project.index.reIndexHandle(tHandle)
SHARED.project.tree.refreshItems([tHandle])
else:
- SHARED.project.newFolder(newLabel, sHandle, pos)
+ tHandle = SHARED.project.newFolder(newLabel, sHandle, pos)
+
+ # Select the new item automatically
+ self.setSelectedHandle(tHandle)
return
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index ee8c7b32..8d83bd92 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -221,6 +221,9 @@ class GuiMain(QMainWindow):
SHARED.projectItemChanged.connect(self.projView.onProjectItemChanged)
SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus)
SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage)
+ SHARED.rootFolderChanged.connect(self.novelView.updateRootItem)
+ SHARED.rootFolderChanged.connect(self.outlineView.updateRootItem)
+ SHARED.rootFolderChanged.connect(self.projView.updateRootItem)
SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage)
SHARED.statusLabelsChanged.connect(self.docViewerPanel.updateStatusLabels)
@@ -235,9 +238,6 @@ class GuiMain(QMainWindow):
self.projView.openDocumentRequest.connect(self._openDocument)
self.projView.projectSettingsRequest.connect(self.showProjectSettingsDialog)
- self.projView.rootFolderChanged.connect(self.novelView.updateRootItem)
- self.projView.rootFolderChanged.connect(self.outlineView.updateRootItem)
- self.projView.rootFolderChanged.connect(self.projView.updateRootItem)
self.projView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
self.novelView.openDocumentRequest.connect(self._openDocument)
@@ -1083,6 +1083,9 @@ class GuiMain(QMainWindow):
self.projView.initSettings()
self.novelView.initSettings()
self.outlineView.initSettings()
+
+ # Force update of word count
+ self._lastTotalCount = 0
self._updateStatusWordCount()
return
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index f5fa135e..b3a1b04f 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -63,6 +63,7 @@ class SharedData(QObject):
indexCleared = pyqtSignal()
mainClockTick = pyqtSignal()
projectItemChanged = pyqtSignal(str, Enum)
+ rootFolderChanged = pyqtSignal(str, Enum)
projectStatusChanged = pyqtSignal(bool)
projectStatusMessage = pyqtSignal(str)
spellLanguageChanged = pyqtSignal(str, str)
@@ -338,6 +339,10 @@ class SharedData(QObject):
self.projectItemChanged.emit(
data.get("handle", ""), data.get("change", nwChange.UPDATE)
)
+ elif event == "rootChanged":
+ self.rootFolderChanged.emit(
+ data.get("handle", ""), data.get("change", nwChange.UPDATE)
+ )
return
##
diff --git a/tests/mocked.py b/tests/mocked.py
index f5df63bf..dc663331 100644
--- a/tests/mocked.py
+++ b/tests/mocked.py
@@ -20,6 +20,8 @@ along with this program. If not, see .
"""
from __future__ import annotations
+from unittest.mock import MagicMock
+
from PyQt5.QtGui import QFont, QIcon, QPixmap
from PyQt5.QtWidgets import QWidget
@@ -28,7 +30,9 @@ class MockGuiMain(QWidget):
def __init__(self):
super().__init__()
- self.mainStatus = MockStatusBar()
+ self.mainStatus = MagicMock()
+ self.docEditor = MagicMock()
+ self.docViewer = MagicMock()
self.projPath = ""
return
@@ -52,18 +56,6 @@ class MockGuiMain(QWidget):
return "close"
-class MockStatusBar:
-
- def __init__(self):
- return
-
- def setStatus(self, text):
- return
-
- def updateProjectStatus(self, status):
- return
-
-
class MockTheme:
def __init__(self):
diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
index fbfa9e9c..40dd54ee 100644
--- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx
+++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -37,7 +37,7 @@
Title Page
- -
+
-
New Chapter
@@ -45,11 +45,11 @@
New Chapter
- -
+
-
New Scene
- -
+
-
Stuff
@@ -57,11 +57,11 @@
Hello
- -
+
-
Plot
- -
+
-
Characters
@@ -69,11 +69,11 @@
Jane
- -
+
-
John
- -
+
-
Locations
diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx
index 1a6938aa..e284a2db 100644
--- a/tests/reference/coreProject_NewRoot_nwProject.nwx
+++ b/tests/reference/coreProject_NewRoot_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -37,7 +37,7 @@
Title Page
- -
+
-
New Chapter
@@ -45,51 +45,51 @@
New Chapter
- -
+
-
New Scene
- -
+
-
Plot
- -
+
-
Characters
- -
+
-
Locations
- -
+
-
Novel
- -
+
-
Plot
- -
+
-
Characters
- -
+
-
Locations
- -
+
-
Timeline
- -
+
-
Objects
- -
+
-
Custom
- -
+
-
Custom
diff --git a/tests/reference/coreTools_DocDuplicator_nwProject.nwx b/tests/reference/coreTools_DocDuplicator_nwProject.nwx
index 431cc119..1157b376 100644
--- a/tests/reference/coreTools_DocDuplicator_nwProject.nwx
+++ b/tests/reference/coreTools_DocDuplicator_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -37,7 +37,7 @@
Title Page
- -
+
-
New Chapter
@@ -45,23 +45,23 @@
New Chapter
- -
+
-
New Chapter
- -
+
-
New Scene
- -
+
-
New Scene
- -
+
-
New Scene
- -
+
-
New Chapter
@@ -69,11 +69,11 @@
New Chapter
- -
+
-
New Scene
- -
+
-
Novel
@@ -81,7 +81,7 @@
Title Page
- -
+
-
New Chapter
@@ -89,19 +89,19 @@
New Chapter
- -
+
-
New Scene
- -
+
-
Plot
- -
+
-
Characters
- -
+
-
Locations
diff --git a/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx b/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx
index 01d3a0ef..387cf8da 100644
--- a/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx
+++ b/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
Test Project A
Jane Doe
@@ -37,7 +37,7 @@
Title Page
- -
+
-
Chapter 1
@@ -45,15 +45,15 @@
Scene 1.1
- -
+
-
Scene 1.2
- -
+
-
Scene 1.3
- -
+
-
Chapter 2
@@ -61,15 +61,15 @@
Scene 2.1
- -
+
-
Scene 2.2
- -
+
-
Scene 2.3
- -
+
-
Chapter 3
@@ -77,15 +77,15 @@
Scene 3.1
- -
+
-
Scene 3.2
- -
+
-
Scene 3.3
- -
+
-
Plot
@@ -93,7 +93,7 @@
Main Plot
- -
+
-
Characters
@@ -101,7 +101,7 @@
Protagonist
- -
+
-
Locations
@@ -109,11 +109,11 @@
Main Location
- -
+
-
Archive
- -
+
-
Trash
diff --git a/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx b/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx
index ed43d5cd..cb0a4843 100644
--- a/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx
+++ b/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
Test Project B
Jane Doe
@@ -37,31 +37,31 @@
Title Page
- -
+
-
Scene 1
- -
+
-
Scene 2
- -
+
-
Scene 3
- -
+
-
Scene 4
- -
+
-
Scene 5
- -
+
-
Scene 6
- -
+
-
Plot
@@ -69,7 +69,7 @@
Main Plot
- -
+
-
Characters
@@ -77,7 +77,7 @@
Protagonist
- -
+
-
Locations
@@ -85,11 +85,11 @@
Main Location
- -
+
-
Archive
- -
+
-
Trash
diff --git a/tests/reference/guiEditor_Main_Final_0000000000010.nwd b/tests/reference/guiEditor_Main_Final_0000000000010.nwd
deleted file mode 100644
index eb6ce774..00000000
--- a/tests/reference/guiEditor_Main_Final_0000000000010.nwd
+++ /dev/null
@@ -1,10 +0,0 @@
-%%~name: New Note
-%%~path: 000000000000a/0000000000010
-%%~kind: CHARACTER/NOTE
-%%~hash: 9fae6dfdd3d1c0822d3a3cf90c0142e65ad8e557
-%%~date: 2023-08-25 18:14:24/2023-08-25 18:14:24
-# Jane Doe
-
-@tag: Jane
-
-This is a file about Jane.
diff --git a/tests/reference/guiEditor_Main_Final_0000000000011.nwd b/tests/reference/guiEditor_Main_Final_0000000000011.nwd
index bd73dfd8..12cb6647 100644
--- a/tests/reference/guiEditor_Main_Final_0000000000011.nwd
+++ b/tests/reference/guiEditor_Main_Final_0000000000011.nwd
@@ -1,10 +1,10 @@
%%~name: New Note
-%%~path: 0000000000009/0000000000011
-%%~kind: PLOT/NOTE
-%%~hash: 3d3697638a70fc86cc023df6d42202a262d93905
-%%~date: 2024-04-14 23:46:49/2024-04-14 23:46:49
-# Main Plot
+%%~path: 000000000000a/0000000000011
+%%~kind: CHARACTER/NOTE
+%%~hash: 9fae6dfdd3d1c0822d3a3cf90c0142e65ad8e557
+%%~date: 2024-11-23 18:26:00/2024-11-23 18:26:00
+# Jane Doe
-@tag: MainPlot
+@tag: Jane
-This is a file [i]detailing[/i] the main plot.
+This is a file about Jane.
diff --git a/tests/reference/guiEditor_Main_Final_0000000000012.nwd b/tests/reference/guiEditor_Main_Final_0000000000012.nwd
index 2c624eca..7e9f61ca 100644
--- a/tests/reference/guiEditor_Main_Final_0000000000012.nwd
+++ b/tests/reference/guiEditor_Main_Final_0000000000012.nwd
@@ -1,10 +1,10 @@
%%~name: New Note
-%%~path: 000000000000b/0000000000012
-%%~kind: WORLD/NOTE
-%%~hash: 3f5c3c6c3ba1c27c30b8ac9e59c222fb9a1bd775
-%%~date: 2023-08-25 18:17:45/2023-08-25 18:17:45
-# Main Location
+%%~path: 0000000000009/0000000000012
+%%~kind: PLOT/NOTE
+%%~hash: 3d3697638a70fc86cc023df6d42202a262d93905
+%%~date: 2024-11-23 18:26:31/2024-11-23 18:26:31
+# Main Plot
-@tag: Home
+@tag: MainPlot
-This is a file describing Jane’s home.
+This is a file [i]detailing[/i] the main plot.
diff --git a/tests/reference/guiEditor_Main_Final_0000000000013.nwd b/tests/reference/guiEditor_Main_Final_0000000000013.nwd
new file mode 100644
index 00000000..4b749970
--- /dev/null
+++ b/tests/reference/guiEditor_Main_Final_0000000000013.nwd
@@ -0,0 +1,10 @@
+%%~name: New Note
+%%~path: 000000000000b/0000000000013
+%%~kind: WORLD/NOTE
+%%~hash: 3f5c3c6c3ba1c27c30b8ac9e59c222fb9a1bd775
+%%~date: 2024-11-23 18:28:00/2024-11-23 18:28:00
+# Main Location
+
+@tag: Home
+
+This is a file describing Jane’s home.
diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx
index 4a06631a..e90a0478 100644
--- a/tests/reference/guiEditor_Main_Final_nwProject.nwx
+++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -22,19 +22,19 @@
Finished
- New
+ New
Minor
Major
Main
-
+
-
Novel
-
-
+
Title Page
-
@@ -42,36 +42,40 @@
New Chapter
-
-
+
New Chapter
-
-
+
New Scene
-
Plot
- -
-
+
-
+
New Note
-
Characters
- -
-
+
-
+
New Note
-
- World
+ Locations
- -
-
+
-
+
New Note
+ -
+
+ Trash
+
diff --git a/tests/reference/guiEditor_Main_Initial_nwProject.nwx b/tests/reference/guiEditor_Main_Initial_nwProject.nwx
index f5119a55..af535851 100644
--- a/tests/reference/guiEditor_Main_Initial_nwProject.nwx
+++ b/tests/reference/guiEditor_Main_Initial_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -59,7 +59,7 @@
-
- World
+ Locations
diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py
index b27ab593..8ebfd787 100644
--- a/tests/test_core/test_core_item.py
+++ b/tests/test_core/test_core_item.py
@@ -275,7 +275,7 @@ def testCoreItem_Methods(mockGUI, mockRnd, fncPath):
"handle": "000000000000f",
"parent": "000000000000d",
"root": "0000000000008",
- "order": "0",
+ "order": "1",
"type": "FILE",
"class": "NOVEL",
"layout": "DOCUMENT"
diff --git a/tests/test_dialogs/test_dlg_projectsettings.py b/tests/test_dialogs/test_dlg_projectsettings.py
index 8eeceba0..151a7e8a 100644
--- a/tests/test_dialogs/test_dlg_projectsettings.py
+++ b/tests/test_dialogs/test_dlg_projectsettings.py
@@ -161,7 +161,7 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, projPath, mockRn
project.tree[hCharNote].setImport(C.iMajor) # type: ignore
project.tree[hWorldNote].setImport(C.iMain) # type: ignore
- nwGUI.projView.populateTree()
+ project.tree.refreshAllItems()
project.countStatus()
assert [e.count for _, e in project.data.itemStatus.iterItems()] == [2, 0, 2, 1]
diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py
index afcad9a2..c312f5ac 100644
--- a/tests/test_gui/test_gui_docviewer.py
+++ b/tests/test_gui/test_gui_docviewer.py
@@ -29,7 +29,7 @@ from PyQt5.QtGui import QDesktopServices, QMouseEvent, QTextCursor
from PyQt5.QtWidgets import QAction, QApplication, QMenu
from novelwriter import CONFIG, SHARED
-from novelwriter.enum import nwDocAction
+from novelwriter.enum import nwChange, nwDocAction
from novelwriter.formats.toqdoc import ToQTextDocument
from novelwriter.types import QtModNone, QtMouseLeft
@@ -206,12 +206,12 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
nwItem = SHARED.project.tree["4c4f28287af27"]
nwItem.setName("Test Title") # type: ignore
assert nwItem.itemName == "Test Title" # type: ignore
- docViewer.updateDocInfo("4c4f28287af27")
+ docViewer.onProjectItemChanged("4c4f28287af27", nwChange.UPDATE)
assert docViewer.docHeader.itemTitle.text() == "Characters \u203a Test Title"
# Title without full path
CONFIG.showFullPath = False
- docViewer.updateDocInfo("4c4f28287af27")
+ docViewer.onProjectItemChanged("4c4f28287af27", nwChange.UPDATE)
assert docViewer.docHeader.itemTitle.text() == "Test Title"
CONFIG.showFullPath = True
diff --git a/tests/test_gui/test_gui_docviewerpanel.py b/tests/test_gui/test_gui_docviewerpanel.py
index 729c5690..d5a609c8 100644
--- a/tests/test_gui/test_gui_docviewerpanel.py
+++ b/tests/test_gui/test_gui_docviewerpanel.py
@@ -38,8 +38,7 @@ def testGuiViewerPanel_BackRefs(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
buildTestProject(nwGUI, projPath)
- projTree = nwGUI.projView.projTree
- projTree.expandAll()
+ nwGUI.projView.projTree.expandAll()
viewPanel = nwGUI.docViewerPanel
tabBackRefs = viewPanel.tabBackRefs
@@ -81,7 +80,7 @@ def testGuiViewerPanel_BackRefs(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Update Label
SHARED.project.tree[C.hSceneDoc].setName("First Scene") # type: ignore
- projTree.renameTreeItem(C.hSceneDoc)
+ nwGUI.projView.renameTreeItem(C.hSceneDoc)
item = tabBackRefs.topLevelItem(0)
assert item.text(tabBackRefs.C_DOC) == "First Scene"
assert item.text(tabBackRefs.C_TITLE) == "Scene One"
@@ -127,8 +126,7 @@ def testGuiViewerPanel_Tags(qtbot, monkeypatch, caplog, nwGUI, projPath, mockRnd
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
buildTestProject(nwGUI, projPath)
- projTree = nwGUI.projView.projTree
- projTree.expandAll()
+ nwGUI.projView.projTree.expandAll()
viewPanel = nwGUI.docViewerPanel
nwGUI.openDocument(C.hSceneDoc)
@@ -175,7 +173,7 @@ def testGuiViewerPanel_Tags(qtbot, monkeypatch, caplog, nwGUI, projPath, mockRnd
nwGUI.docEditor.setPlainText("# Jane Smith\n\n@tag: Janey\n\n")
nwGUI.saveDocument()
SHARED.project.tree[hJane].setName("Awesome Jane") # type: ignore
- projTree.renameTreeItem(hJane)
+ nwGUI.projView.renameTreeItem(hJane)
item = charTab.topLevelItem(0)
assert item.text(charTab.C_NAME) == "Janey"
assert item.text(charTab.C_DOC) == "Awesome Jane"
@@ -221,8 +219,7 @@ def testGuiViewerPanel_Tags(qtbot, monkeypatch, caplog, nwGUI, projPath, mockRnd
nwJohn = SHARED.project.tree[hJohn]
assert isinstance(nwJohn, NWItem)
nwJohn.setActive(False)
- projTree.setTreeItemValues(nwJohn)
- projTree._alertTreeChange(hJohn, flush=False)
+ nwJohn.notifyToRefresh()
assert charTab.topLevelItemCount() == 1
# Update Labels
diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py
index 596183f6..7ca32b8b 100644
--- a/tests/test_gui/test_gui_guimain.py
+++ b/tests/test_gui/test_gui_guimain.py
@@ -210,9 +210,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
assert nwGUI.closeProject()
assert len(SHARED.project.tree) == 0
- assert len(SHARED.project.tree._order) == 0
- assert len(SHARED.project.tree._roots) == 0
- assert SHARED.project.tree.trashRoot is None
+ assert len(SHARED.project.tree._items) == 0
+ assert len(SHARED.project.tree._nodes) == 0
assert SHARED.project.data.name == ""
assert SHARED.project.data.author == ""
assert SHARED.project.data.spellCheck is False
@@ -228,23 +227,22 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
assert nwGUI.openProject(projPath)
# Check that we loaded the data
- assert len(SHARED.project.tree) == 8
- assert len(SHARED.project.tree._order) == 8
- assert len(SHARED.project.tree._roots) == 4
- assert SHARED.project.tree.trashRoot is None
+ assert len(SHARED.project.tree) == 9
+ assert SHARED.project.tree.model.root.childCount() == 5
+ assert SHARED.project.tree.trash is not None # Created automatically
assert SHARED.project.data.name == "New Project"
assert SHARED.project.data.author == "Jane Doe"
assert SHARED.project.data.spellCheck is False
# Check that tree items have been created
- assert nwGUI.projView.projTree._getTreeItem(C.hNovelRoot) is not None
- assert nwGUI.projView.projTree._getTreeItem(C.hPlotRoot) is not None
- assert nwGUI.projView.projTree._getTreeItem(C.hCharRoot) is not None
- assert nwGUI.projView.projTree._getTreeItem(C.hWorldRoot) is not None
- assert nwGUI.projView.projTree._getTreeItem(C.hTitlePage) is not None
- assert nwGUI.projView.projTree._getTreeItem(C.hChapterDir) is not None
- assert nwGUI.projView.projTree._getTreeItem(C.hChapterDoc) is not None
- assert nwGUI.projView.projTree._getTreeItem(C.hSceneDoc) is not None
+ assert SHARED.project.tree[C.hNovelRoot] is not None
+ assert SHARED.project.tree[C.hPlotRoot] is not None
+ assert SHARED.project.tree[C.hCharRoot] is not None
+ assert SHARED.project.tree[C.hWorldRoot] is not None
+ assert SHARED.project.tree[C.hTitlePage] is not None
+ assert SHARED.project.tree[C.hChapterDir] is not None
+ assert SHARED.project.tree[C.hChapterDoc] is not None
+ assert SHARED.project.tree[C.hSceneDoc] is not None
nwGUI.mainMenu.aSpellCheck.setChecked(True)
nwGUI.mainMenu._toggleSpellCheck()
@@ -256,10 +254,12 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
CONFIG.autoScroll = True
# Add a Character File
- nwGUI._switchFocus(nwFocus.TREE)
+ nwGUI._changeView(nwView.PROJECT)
+ nwGUI.projView.projTree.expandAll()
nwGUI.projView.projTree.clearSelection()
- nwGUI.projView.projTree._getTreeItem(C.hCharRoot).setSelected(True)
+ nwGUI.projView.projTree.setSelectedHandle(C.hCharRoot)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True)
+ nwGUI.projView.projTree.expandAll()
nwGUI.openSelectedItem()
# Text Editor
@@ -290,10 +290,11 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
# Add a Plot File
- nwGUI._switchFocus(nwFocus.TREE)
+ nwGUI.projView.projTree.expandAll()
nwGUI.projView.projTree.clearSelection()
- nwGUI.projView.projTree._getTreeItem(C.hPlotRoot).setSelected(True)
+ nwGUI.projView.projTree.setSelectedHandle(C.hPlotRoot)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True)
+ nwGUI.projView.projTree.expandAll()
nwGUI.openSelectedItem()
# Type something into the document
@@ -312,10 +313,11 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
# Add a World File
- nwGUI._switchFocus(nwFocus.TREE)
+ nwGUI.projView.projTree.expandAll()
nwGUI.projView.projTree.clearSelection()
- nwGUI.projView.projTree._getTreeItem(C.hWorldRoot).setSelected(True)
+ nwGUI.projView.projTree.setSelectedHandle(C.hWorldRoot)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True)
+ nwGUI.projView.projTree.expandAll()
nwGUI.openSelectedItem()
# Add Some Text
@@ -343,11 +345,9 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
nwGUI._autoSaveProject()
# Select the 'New Scene' file
- nwGUI._switchFocus(nwFocus.TREE)
+ nwGUI.projView.projTree.expandAll()
nwGUI.projView.projTree.clearSelection()
- nwGUI.projView.projTree._getTreeItem(C.hNovelRoot).setExpanded(True)
- nwGUI.projView.projTree._getTreeItem(C.hChapterDir).setExpanded(True)
- nwGUI.projView.projTree._getTreeItem(C.hSceneDoc).setSelected(True)
+ nwGUI.projView.projTree.setSelectedHandle(C.hSceneDoc)
nwGUI.openSelectedItem()
# Type something into the document
@@ -533,6 +533,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
# Indent and Align
# ================
+ nwGUI._switchFocus(nwView.EDITOR)
for c in "\t\"Tab-indented text\"":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
@@ -622,12 +623,6 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
- projFile = projPath / "content" / "0000000000010.nwd"
- testFile = tstPaths.outDir / "guiEditor_Main_Final_0000000000010.nwd"
- compFile = tstPaths.refDir / "guiEditor_Main_Final_0000000000010.nwd"
- copyfile(projFile, testFile)
- assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
-
projFile = projPath / "content" / "0000000000011.nwd"
testFile = tstPaths.outDir / "guiEditor_Main_Final_0000000000011.nwd"
compFile = tstPaths.refDir / "guiEditor_Main_Final_0000000000011.nwd"
@@ -640,6 +635,12 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
+ projFile = projPath / "content" / "0000000000013.nwd"
+ testFile = tstPaths.outDir / "guiEditor_Main_Final_0000000000013.nwd"
+ compFile = tstPaths.refDir / "guiEditor_Main_Final_0000000000013.nwd"
+ copyfile(projFile, testFile)
+ assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
+
# qtbot.stop()
diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py
index e92b5a01..bf2974c7 100644
--- a/tests/test_gui/test_gui_outline.py
+++ b/tests/test_gui/test_gui_outline.py
@@ -197,7 +197,8 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat
assert outlineBar.novelValue.itemData(2) == "" # All novels
# Add a second novel folder
- newHandle = SHARED.project.newRoot(nwItemClass.NOVEL)
+ with qtbot.waitSignal(SHARED.rootFolderChanged):
+ newHandle = SHARED.project.newRoot(nwItemClass.NOVEL)
# Check new values in dropdown list
assert outlineBar.novelValue.itemData(0) == lipHandle
@@ -277,7 +278,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat
assert outlineData.fileValue.text() == "Scene One"
assert outlineData.itemValue.text() == "Finished"
- outlineTree._treeDoubleClick(selItem, 0)
+ outlineTree._onItemDoubleClicked(selItem, 0)
assert nwGUI.docEditor.docHandle == "88243afbe5ed8"
# Dump to CSV
diff --git a/tests/test_gui/test_gui_statusbar.py b/tests/test_gui/test_gui_statusbar.py
index 48649ba9..624f1f84 100644
--- a/tests/test_gui/test_gui_statusbar.py
+++ b/tests/test_gui/test_gui_statusbar.py
@@ -86,9 +86,11 @@ def testGuiStatusBar_Main(qtbot, nwGUI, projPath, mockRnd):
# Project Stats
CONFIG.incNotesWCount = False
+ nwGUI._lastTotalCount = 0
nwGUI._updateStatusWordCount()
assert nwGUI.mainStatus.statsText.text() == "Words: 9 (+9)"
CONFIG.incNotesWCount = True
+ nwGUI._lastTotalCount = 0
nwGUI._updateStatusWordCount()
assert nwGUI.mainStatus.statsText.text() == "Words: 11 (+11)"
From c1ed0647fef4ee5c23826e8b672c4b1b0f80bc51 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 23 Nov 2024 19:56:24 +0100
Subject: [PATCH 32/39] Refactor project signal proxies
---
novelwriter/core/index.py | 17 +++------
novelwriter/core/project.py | 8 ++---
novelwriter/core/status.py | 4 +--
novelwriter/core/tree.py | 8 ++---
novelwriter/shared.py | 57 ++++++++++++++++++------------
tests/test_core/test_core_index.py | 1 +
6 files changed, 48 insertions(+), 47 deletions(-)
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 81f5e684..382fae34 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -122,7 +122,7 @@ class NWIndex:
self._itemIndex.clear()
self._indexChange = 0.0
self._rootChange = {}
- SHARED.indexSignalProxy({"event": "clearIndex"})
+ SHARED.emitIndexCleared(self._project)
return
def rebuild(self) -> None:
@@ -133,7 +133,7 @@ class NWIndex:
text = self._project.storage.getDocumentText(nwItem.itemHandle)
self.scanText(nwItem.itemHandle, text, blockSignal=True)
self._indexBroken = False
- SHARED.indexSignalProxy({"event": "buildIndex"})
+ SHARED.emitIndexAvailable(self._project)
return
def deleteHandle(self, tHandle: str) -> None:
@@ -143,10 +143,7 @@ class NWIndex:
for tTag in delTags:
del self._tagsIndex[tTag]
del self._itemIndex[tHandle]
- SHARED.indexSignalProxy({
- "event": "updateTags",
- "deleted": delTags,
- })
+ SHARED.emitIndexChangedTags(self._project, [], delTags)
return
def reIndexHandle(self, tHandle: str | None) -> None:
@@ -212,7 +209,7 @@ class NWIndex:
self.reIndexHandle(fHandle)
self._indexChange = time()
- SHARED.indexSignalProxy({"event": "buildIndex"})
+ SHARED.emitIndexAvailable(self._project)
logger.debug("Index loaded in %.3f ms", (time() - tStart)*1000)
@@ -367,11 +364,7 @@ class NWIndex:
del self._tagsIndex[tTag]
deleted.append(tTag)
if updated or deleted:
- SHARED.indexSignalProxy({
- "event": "updateTags",
- "updated": updated,
- "deleted": deleted,
- })
+ SHARED.emitIndexChangedTags(self._project, updated, deleted)
return
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index ced91fd1..31a44919 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -44,7 +44,7 @@ from novelwriter.core.options import OptionState
from novelwriter.core.projectdata import NWProjectData
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.sessions import NWSessionLog
-from novelwriter.core.status import T_StatusKinds, T_UpdateEntry
+from novelwriter.core.status import T_StatusKind, T_UpdateEntry
from novelwriter.core.storage import NWStorage, NWStorageOpen
from novelwriter.core.tree import NWTree
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
@@ -532,15 +532,15 @@ class NWProject:
self._data.itemImport.increment(nwItem.itemImport)
return
- def updateStatus(self, kind: T_StatusKinds, update: T_UpdateEntry) -> None:
+ def updateStatus(self, kind: T_StatusKind, update: T_UpdateEntry) -> None:
"""Update status or import entries."""
if kind == "s":
self._data.itemStatus.update(update)
- SHARED.projectSignalProxy({"event": "statusLabels", "kind": kind})
+ SHARED.emitStatusLabelsChanged(self, kind)
self._tree.refreshAllItems()
elif kind == "i":
self._data.itemImport.update(update)
- SHARED.projectSignalProxy({"event": "statusLabels", "kind": kind})
+ SHARED.emitStatusLabelsChanged(self, kind)
self._tree.refreshAllItems()
return
diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py
index 7935e5aa..358f9ced 100644
--- a/novelwriter/core/status.py
+++ b/novelwriter/core/status.py
@@ -66,7 +66,7 @@ class StatusEntry:
NO_ENTRY = StatusEntry("", QColor(0, 0, 0), nwStatusShape.SQUARE, QIcon(), 0)
T_UpdateEntry = list[tuple[str | None, StatusEntry]]
-T_StatusKinds = Literal["s", "i"]
+T_StatusKind = Literal["s", "i"]
class NWStatus:
@@ -76,7 +76,7 @@ class NWStatus:
__slots__ = ("_store", "_default", "_prefix", "_height")
- def __init__(self, prefix: T_StatusKinds) -> None:
+ def __init__(self, prefix: T_StatusKind) -> None:
self._store: dict[str, StatusEntry] = {}
self._default = None
self._prefix = prefix[:1]
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 1f919542..a39b289e 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -469,13 +469,9 @@ class NWTree:
tHandle = item.itemHandle
logger.debug("Item change: %s -> %s", tHandle, change.name)
self._project.setProjectChanged(True)
- SHARED.projectSignalProxy(
- {"event": "itemChanged", "handle": tHandle, "change": change}
- )
+ SHARED.emitProjectItemChanged(self._project, tHandle, change)
if item.isRootType():
- SHARED.projectSignalProxy(
- {"event": "rootChanged", "handle": tHandle, "change": change}
- )
+ SHARED.emitRootFolderChanged(self._project, tHandle, change)
return
def _getTrashNode(self) -> ProjectNode | None:
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index b3a1b04f..3b0cae22 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -42,6 +42,7 @@ from novelwriter.enum import nwChange, nwItemClass
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
+ from novelwriter.core.status import T_StatusKind
from novelwriter.gui.theme import GuiTheme
from novelwriter.guimain import GuiMain
@@ -314,35 +315,45 @@ class SharedData(QObject):
return
##
- # Signal Proxy
+ # Signal Proxies
##
- def indexSignalProxy(self, data: dict) -> None:
- """Emit signals on behalf of the index."""
- event = data.get("event")
- logger.debug("Signal Proxy: %s", event)
- if event == "updateTags":
- self.indexChangedTags.emit(data.get("updated", []), data.get("deleted", []))
- elif event == "clearIndex":
+ def emitIndexChangedTags(
+ self, project: NWProject, updated: list[str], deleted: list[str]
+ ) -> None:
+ """Emit the indexChangedTags signal."""
+ if self._project and self._project.data.uuid == project.data.uuid:
+ self.indexChangedTags.emit(updated, deleted)
+ return
+
+ def emitIndexCleared(self, project: NWProject) -> None:
+ """Emit the indexCleared signal."""
+ if self._project and self._project.data.uuid == project.data.uuid:
self.indexCleared.emit()
- elif event == "buildIndex":
+ return
+
+ def emitIndexAvailable(self, project: NWProject) -> None:
+ """Emit the indexAvailable signal."""
+ if self._project and self._project.data.uuid == project.data.uuid:
self.indexAvailable.emit()
return
- def projectSignalProxy(self, data: dict) -> None:
- """Emit signals on project data change."""
- event = data.get("event")
- logger.debug("Signal Proxy: %s", event)
- if event == "statusLabels":
- self.statusLabelsChanged.emit(data.get("kind", ""))
- elif event == "itemChanged":
- self.projectItemChanged.emit(
- data.get("handle", ""), data.get("change", nwChange.UPDATE)
- )
- elif event == "rootChanged":
- self.rootFolderChanged.emit(
- data.get("handle", ""), data.get("change", nwChange.UPDATE)
- )
+ def emitStatusLabelsChanged(self, project: NWProject, kind: T_StatusKind) -> None:
+ """Emit the statusLabelsChanged signal."""
+ if self._project and self._project.data.uuid == project.data.uuid:
+ self.statusLabelsChanged.emit(kind)
+ return
+
+ def emitProjectItemChanged(self, project: NWProject, handle: str, change: nwChange) -> None:
+ """Emit the projectItemChanged signal."""
+ if self._project and self._project.data.uuid == project.data.uuid:
+ self.projectItemChanged.emit(handle, change)
+ return
+
+ def emitRootFolderChanged(self, project: NWProject, handle: str, change: nwChange) -> None:
+ """Emit the rootFolderChanged signal."""
+ if self._project and self._project.data.uuid == project.data.uuid:
+ self.rootFolderChanged.emit(handle, change)
return
##
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index 2b64273b..21e42f3e 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -153,6 +153,7 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
assert "7a992350f3eb6" in index._itemIndex
# Close Project
+ SHARED._project = project # Otherwise the signal is not emitted
with qtbot.waitSignal(SHARED.indexCleared, timeout=1000):
# Regression test for issue #1718
project.closeProject()
From be6888f8c855beb187b4a92ac630342a2fb0fd5d Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 23 Nov 2024 20:29:02 +0100
Subject: [PATCH 33/39] Fix Python 3.9 incompatible type
---
novelwriter/core/itemmodel.py | 4 +++-
novelwriter/core/project.py | 6 +++++-
novelwriter/core/status.py | 6 ++++--
3 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 948b6146..8042a3f2 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -59,7 +59,9 @@ NODE_FLAGS = Qt.ItemFlag.ItemIsEnabled
NODE_FLAGS |= Qt.ItemFlag.ItemIsSelectable
NODE_FLAGS |= Qt.ItemFlag.ItemIsDropEnabled
-T_NodeData = str | QIcon | QFont | Qt.AlignmentFlag | None
+if TYPE_CHECKING: # pragma: no cover
+ # Requires Python 3.10
+ T_NodeData = str | QIcon | QFont | Qt.AlignmentFlag | None
class ProjectNode:
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index 31a44919..4e16dd53 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -30,6 +30,7 @@ from enum import Enum
from functools import partial
from pathlib import Path
from time import time
+from typing import TYPE_CHECKING
from PyQt5.QtCore import QCoreApplication
@@ -44,12 +45,15 @@ from novelwriter.core.options import OptionState
from novelwriter.core.projectdata import NWProjectData
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.sessions import NWSessionLog
-from novelwriter.core.status import T_StatusKind, T_UpdateEntry
from novelwriter.core.storage import NWStorage, NWStorageOpen
from novelwriter.core.tree import NWTree
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
+if TYPE_CHECKING: # pragma: no cover
+ # Requires Python 3.10
+ from novelwriter.core.status import T_StatusKind, T_UpdateEntry
+
logger = logging.getLogger(__name__)
diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py
index 358f9ced..fa3a3dfc 100644
--- a/novelwriter/core/status.py
+++ b/novelwriter/core/status.py
@@ -65,8 +65,10 @@ class StatusEntry:
NO_ENTRY = StatusEntry("", QColor(0, 0, 0), nwStatusShape.SQUARE, QIcon(), 0)
-T_UpdateEntry = list[tuple[str | None, StatusEntry]]
-T_StatusKind = Literal["s", "i"]
+if TYPE_CHECKING: # pragma: no cover
+ # Requires Python 3.10
+ T_UpdateEntry = list[tuple[str | None, StatusEntry]]
+ T_StatusKind = Literal["s", "i"]
class NWStatus:
From 01b2b53fd590b28655e3ef84b40760baba47593e Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 24 Nov 2024 00:43:01 +0100
Subject: [PATCH 34/39] Add test coverage of the item model
---
novelwriter/core/itemmodel.py | 31 +-
novelwriter/core/project.py | 6 +-
novelwriter/core/tree.py | 5 +-
.../coreProject_NewFileFolder_nwProject.nwx | 4 +-
.../coreProject_NewRoot_nwProject.nwx | 4 +-
.../coreTools_DocDuplicator_nwProject.nwx | 8 +-
.../guiEditor_Main_Final_nwProject.nwx | 6 +-
.../guiEditor_Main_Initial_nwProject.nwx | 2 +-
tests/test_core/test_core_itemmodel.py | 623 ++++++++++++++++++
tests/test_gui/test_gui_doceditor.py | 2 +-
tests/tools.py | 2 +-
11 files changed, 657 insertions(+), 36 deletions(-)
create mode 100644 tests/test_core/test_core_itemmodel.py
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 8042a3f2..25a58796 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -42,6 +42,7 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__)
+INV_ROOT = "invisibleRoot"
C_FACTOR = 0x0100
C_LABEL_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole
@@ -244,7 +245,7 @@ class ProjectNode:
child.item.setParent(self._item.itemHandle)
child.item.setRoot(self._item.itemRoot)
child.item.setClassDefaults(self._item.itemClass)
- child._flags |= Qt.ItemFlag.ItemIsDragEnabled
+ child._flags = NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
else:
child.item.setParent(None)
child.item.setRoot(child.item.itemHandle)
@@ -259,12 +260,12 @@ class ProjectModel(QAbstractItemModel):
def __init__(self, tree: NWTree) -> None:
super().__init__()
self._tree = tree
- self._root = ProjectNode(NWItem(tree._project, "invisibleRoot"))
+ self._root = ProjectNode(NWItem(tree._project, INV_ROOT))
self._root.item.setName("Invisible Root")
logger.debug("Ready: ProjectModel")
return
- def __del__(self) -> None:
+ def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: ProjectModel")
return
@@ -390,10 +391,6 @@ class ProjectModel(QAbstractItemModel):
"""Get the index representing a node in the model."""
return self.createIndex(node.row(), column, node)
- def rootIndex(self) -> QModelIndex:
- """Get the index representing the root."""
- return self.createIndex(0, 0, self._root)
-
##
# Model Edit
##
@@ -432,15 +429,6 @@ class ProjectModel(QAbstractItemModel):
self.endMoveRows()
return
- def trashSelection(self, indices: list[QModelIndex]) -> bool:
- """Check if a selection of indices are all in trash or not."""
- for index in indices:
- if index.isValid():
- node: ProjectNode = index.internalPointer()
- if node.item.itemClass != nwItemClass.TRASH:
- return False
- return True
-
def multiMove(self, indices: list[QModelIndex], target: QModelIndex, pos: int = -1) -> None:
"""Move multiple items to a new location."""
if target.isValid():
@@ -458,7 +446,7 @@ class ProjectModel(QAbstractItemModel):
if node.item.isRootType() is False and handle not in handles:
pruned.append(node)
handles.add(handle)
- for node in pruned:
+ for node in (reversed(pruned) if pos >= 0 else pruned):
if node.item.itemParent not in handles:
index = self.indexFromNode(node)
if temp := self.removeChild(index.parent(), index.row()):
@@ -485,3 +473,12 @@ class ProjectModel(QAbstractItemModel):
if node._item.isExpanded:
expanded.append(self.createIndex(node.row(), 0, node))
return expanded
+
+ def trashSelection(self, indices: list[QModelIndex]) -> bool:
+ """Check if a selection of indices are all in trash or not."""
+ for index in indices:
+ if index.isValid():
+ node: ProjectNode = index.internalPointer()
+ if node.item.itemClass != nwItemClass.TRASH:
+ return False
+ return True
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index 4e16dd53..013a0501 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -67,6 +67,11 @@ class NWProjectState(Enum):
class NWProject:
+ __slots__ = (
+ "_options", "_storage", "_data", "_tree", "_index", "_session",
+ "_langData", "_changed", "_valid", "_state", "tr",
+ )
+
def __init__(self) -> None:
# Core Elements
@@ -433,7 +438,6 @@ class NWProject:
self._tree.writeToCFile()
self._session.appendSession(idleTime)
self._storage.closeSession()
- self._lockedBy = None
return
def backupProject(self, doNotify: bool) -> bool:
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index a39b289e..c2afe769 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -66,10 +66,7 @@ class NWTree:
also used for file names.
"""
- __slots__ = (
- "_project", "_tree", "_order", "_roots",
- "_model", "_items", "_nodes", "_trash", "_changed",
- )
+ __slots__ = ("_project", "_model", "_items", "_nodes", "_trash")
def __init__(self, project: NWProject) -> None:
self._project = project
diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
index 40dd54ee..a99d8333 100644
--- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx
+++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -39,7 +39,7 @@
-
- New Chapter
+ New Folder
-
diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx
index e284a2db..a83eb731 100644
--- a/tests/reference/coreProject_NewRoot_nwProject.nwx
+++ b/tests/reference/coreProject_NewRoot_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -39,7 +39,7 @@
-
- New Chapter
+ New Folder
-
diff --git a/tests/reference/coreTools_DocDuplicator_nwProject.nwx b/tests/reference/coreTools_DocDuplicator_nwProject.nwx
index 1157b376..30c62652 100644
--- a/tests/reference/coreTools_DocDuplicator_nwProject.nwx
+++ b/tests/reference/coreTools_DocDuplicator_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -39,7 +39,7 @@
-
- New Chapter
+ New Folder
-
@@ -63,7 +63,7 @@
-
- New Chapter
+ New Folder
-
@@ -83,7 +83,7 @@
-
- New Chapter
+ New Folder
-
diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx
index e90a0478..3b05335b 100644
--- a/tests/reference/guiEditor_Main_Final_nwProject.nwx
+++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx
@@ -1,6 +1,6 @@
-
-
+
+
New Project
Jane Doe
@@ -39,7 +39,7 @@
-
- New Chapter
+ New Folder
-
diff --git a/tests/reference/guiEditor_Main_Initial_nwProject.nwx b/tests/reference/guiEditor_Main_Initial_nwProject.nwx
index af535851..c265fa51 100644
--- a/tests/reference/guiEditor_Main_Initial_nwProject.nwx
+++ b/tests/reference/guiEditor_Main_Initial_nwProject.nwx
@@ -39,7 +39,7 @@
-
- New Chapter
+ New Folder
-
diff --git a/tests/test_core/test_core_itemmodel.py b/tests/test_core/test_core_itemmodel.py
new file mode 100644
index 00000000..5d26d2ea
--- /dev/null
+++ b/tests/test_core/test_core_itemmodel.py
@@ -0,0 +1,623 @@
+"""
+novelWriter – Item Model Tester
+===============================
+
+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 pytest
+
+from PyQt5.QtCore import QMimeData, QModelIndex, Qt
+
+from novelwriter.common import decodeMimeHandles
+from novelwriter.constants import nwConst
+from novelwriter.core.item import NWItem
+from novelwriter.core.itemmodel import INV_ROOT, NODE_FLAGS, ProjectNode
+from novelwriter.core.project import NWProject
+from novelwriter.enum import nwItemLayout, nwItemType
+
+from tests.tools import buildTestProject
+
+
+@pytest.mark.core
+def testCoreItemModel_ProjectNode_Root(mockGUI):
+ """Test the project node class for the root."""
+ project = NWProject()
+ root = ProjectNode(NWItem(project, INV_ROOT))
+
+ # Defaults
+ assert bool(root) is True
+ assert root.item.itemHandle == INV_ROOT
+ assert repr(root) == ""
+ assert root.children == []
+ assert root.count == 0
+
+ # Data
+ assert root.row() == 0
+ assert root.childCount() == 0
+ assert root.parent() is None
+ assert root.child(0) is None
+ assert root.allChildren() == []
+
+
+@pytest.mark.core
+def testCoreItemModel_ProjectNode_Children(mockGUI, mockRnd, fncPath):
+ """Test the project node class for children."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+ root = project.tree.model.root
+
+ # Check Root
+ assert root.childCount() == 4
+ assert root.count == 9
+
+ # Check Children
+ child0 = root.child(0)
+ child1 = root.child(1)
+ child2 = root.child(2)
+ child3 = root.child(3)
+
+ assert child0 is not None
+ assert child1 is not None
+ assert child2 is not None
+ assert child3 is not None
+
+ assert child0.item.itemName == "Novel"
+ assert child1.item.itemName == "Plot"
+ assert child2.item.itemName == "Characters"
+ assert child3.item.itemName == "Locations"
+
+ assert child0.childCount() == 2
+ assert child1.childCount() == 0
+ assert child2.childCount() == 0
+ assert child3.childCount() == 0
+
+ # Check Novel Content
+ child00 = child0.child(0)
+ child01 = child0.child(1)
+
+ assert child00 is not None
+ assert child01 is not None
+
+ assert child00.item.itemName == "Title Page"
+ assert child01.item.itemName == "New Folder"
+
+ child010 = child01.child(0)
+ child011 = child01.child(1)
+
+ assert child010 is not None
+ assert child011 is not None
+
+ assert child010.item.itemName == "New Chapter"
+ assert child011.item.itemName == "New Scene"
+
+ # Check Relationships
+ assert child0.parent() is root
+ assert child1.parent() is root
+ assert child2.parent() is root
+ assert child3.parent() is root
+ assert child01.parent() is child0
+ assert child010.parent() is child01
+ assert child011.parent() is child01
+
+
+@pytest.mark.core
+def testCoreItemModel_ProjectNode_Modify(mockGUI, mockRnd, fncPath):
+ """Test modifying project nodes."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+ root = project.tree.model.root
+
+ # Novel folder
+ novel = root.child(0)
+ assert novel is not None
+ assert novel.item.itemName == "Novel"
+
+ # Chapter folder
+ folder = novel.child(1)
+ assert folder is not None
+ assert folder.item.itemName == "New Folder"
+
+ # Append scene
+ project.tree.create("Scene 1", folder.item.itemHandle, nwItemType.FILE)
+ scene1 = folder.child(2)
+ assert scene1 is not None
+ assert scene1.item.itemName == "Scene 1"
+
+ # Insert scene
+ project.tree.create("Scene 2", folder.item.itemHandle, nwItemType.FILE, pos=1)
+ scene2 = folder.child(1)
+ assert scene2 is not None
+ assert scene2.item.itemName == "Scene 2"
+
+ # Scene 1 should now have moved
+ assert scene1.row() == 3
+ assert [n.item.itemName for n in folder.children] == [
+ "New Chapter", "Scene 2", "New Scene", "Scene 1",
+ ]
+
+ # Check that defaults have been set
+ assert scene1.item.itemParent == folder.item.itemHandle
+ assert scene2.item.itemParent == folder.item.itemHandle
+
+ assert scene1.item.itemClass == folder.item.itemClass
+ assert scene2.item.itemClass == folder.item.itemClass
+
+ assert scene1.item.itemLayout == nwItemLayout.DOCUMENT
+ assert scene2.item.itemLayout == nwItemLayout.DOCUMENT
+
+ # Move Scene 2, invalid position is ignored
+ folder.moveChild(1, -1)
+ scene2 = folder.child(1)
+ assert scene2 is not None
+ assert scene2.item.itemName == "Scene 2"
+
+ # Move Scene 2, past end is ignored
+ folder.moveChild(1, 20)
+ scene2 = folder.child(1)
+ assert scene2 is not None
+ assert scene2.item.itemName == "Scene 2"
+
+ # Move Scene 2, last position is ok
+ folder.moveChild(1, 3)
+ scene2 = folder.child(3)
+ assert scene2 is not None
+ assert scene2.item.itemName == "Scene 2"
+ assert [n.item.itemName for n in folder.children] == [
+ "New Chapter", "New Scene", "Scene 1", "Scene 2",
+ ]
+
+ # Remove original scene, invalid position
+ removed = folder.takeChild(-1)
+ assert removed is None
+ assert folder.childCount() == 4
+
+ # Remove original scene, past end
+ removed = folder.takeChild(20)
+ assert removed is None
+ assert folder.childCount() == 4
+
+ # Remove original scene, ok
+ removed = folder.takeChild(1)
+ assert removed is not None
+ assert removed.item.itemName == "New Scene"
+ assert folder.childCount() == 3
+ assert [n.item.itemName for n in folder.children] == [
+ "New Chapter", "Scene 1", "Scene 2",
+ ]
+
+
+@pytest.mark.core
+def testCoreItemModel_ProjectNode_Data(mockGUI, mockRnd, fncPath):
+ """Test data access from project nodes."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+ root = project.tree.model.root
+
+ # Novel folder
+ novel = root.child(0)
+ assert novel is not None
+ assert novel.item.itemName == "Novel"
+
+ # Chapter folder
+ folder = novel.child(1)
+ assert folder is not None
+ assert folder.item.itemName == "New Folder"
+
+ # Scene document
+ scene = folder.child(1)
+ assert scene is not None
+ assert scene.item.itemName == "New Scene"
+
+ # Check Data
+ assert novel.data(0, Qt.ItemDataRole.DisplayRole) == "Novel"
+ assert novel.data(1, Qt.ItemDataRole.DisplayRole) == "9"
+ assert scene.data(0, Qt.ItemDataRole.DisplayRole) == "New Scene"
+ assert scene.data(1, Qt.ItemDataRole.DisplayRole) == "2"
+
+ assert novel.data(2, Qt.ItemDataRole.ToolTipRole) == ""
+ assert novel.data(3, Qt.ItemDataRole.ToolTipRole) == "New"
+ assert scene.data(2, Qt.ItemDataRole.ToolTipRole) == "Active"
+ assert scene.data(3, Qt.ItemDataRole.ToolTipRole) == "New"
+
+ # Check Flags
+ assert novel.flags() == NODE_FLAGS
+ assert scene.flags() == NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
+
+
+@pytest.mark.core
+def testCoreItemModel_ProjectModel_Interface(mockGUI, mockRnd, fncPath):
+ """Test the model interface for the project model."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+ model = project.tree.model
+
+ # Init
+ assert isinstance(model.root, ProjectNode)
+ assert model.root.item.itemHandle == INV_ROOT
+
+ # Indices
+ rootIdx = QModelIndex()
+ novelIdx = model.index(0, 0, rootIdx)
+ folderIdx = model.index(1, 0, novelIdx)
+ sceneIdx = model.index(1, 0, folderIdx)
+ invalidIdx = model.index(-1, -1)
+
+ assert rootIdx.isValid() is False
+ assert novelIdx.isValid() is True
+ assert folderIdx.isValid() is True
+ assert sceneIdx.isValid() is True
+ assert invalidIdx.isValid() is False
+
+ # Columns and Rows
+ assert model.rowCount(rootIdx) == 4
+ assert model.columnCount(rootIdx) == 4
+ assert model.rowCount(novelIdx) == 2
+ assert model.columnCount(novelIdx) == 4
+
+ # Parent of Novel
+ parent = model.parent(novelIdx)
+ assert parent.row() == 0
+ assert parent.column() == 0
+ assert parent.internalPointer() is model.root
+
+ # Parent of Root
+ parent = model.parent(rootIdx)
+ assert parent.isValid() is False
+
+ # Data and Flags
+ assert model.data(novelIdx, Qt.ItemDataRole.DisplayRole) == "Novel"
+ assert model.data(sceneIdx, Qt.ItemDataRole.DisplayRole) == "New Scene"
+ assert model.data(invalidIdx, Qt.ItemDataRole.DisplayRole) is None
+ assert model.flags(novelIdx) == NODE_FLAGS
+ assert model.flags(sceneIdx) == NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
+ assert model.flags(invalidIdx) == Qt.ItemFlag.NoItemFlags
+
+
+@pytest.mark.core
+def testCoreItemModel_ProjectModel_DragNDrop(mockGUI, mockRnd, fncPath):
+ """Test drag and drop for the project model."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+ model = project.tree.model
+
+ # Nodes
+ novel = model.root.child(0)
+ assert novel is not None
+ folder = novel.child(1)
+ assert folder is not None
+ chapter = folder.child(0)
+ assert chapter is not None
+ scene = folder.child(1)
+ assert scene is not None
+
+ assert novel.item.itemName == "Novel"
+ assert folder.item.itemName == "New Folder"
+ assert chapter.item.itemName == "New Chapter"
+ assert scene.item.itemName == "New Scene"
+
+ # Indices
+ rootIdx = QModelIndex()
+ novelIdx = model.index(0, 0, rootIdx)
+ folderIdx = model.index(1, 0, novelIdx)
+ chapterIdx = model.index(0, 0, folderIdx)
+ sceneIdx = model.index(1, 0, folderIdx)
+ invalidIdx = model.index(-1, -1)
+
+ # Only move is allowed
+ assert model.supportedDragActions() == Qt.DropAction.MoveAction
+ assert model.supportedDropActions() == Qt.DropAction.MoveAction
+
+ # Only handles are dragged and dropped
+ assert model.mimeTypes() == [nwConst.MIME_HANDLE]
+
+ # Get mime data
+ novelMime = model.mimeData([novelIdx])
+ assert decodeMimeHandles(novelMime) == [novel.item.itemHandle]
+
+ sceneMime = model.mimeData([sceneIdx])
+ assert decodeMimeHandles(sceneMime) == [scene.item.itemHandle]
+
+ sceneChapterMime = model.mimeData([chapterIdx, sceneIdx])
+ assert decodeMimeHandles(sceneChapterMime) == [
+ chapter.item.itemHandle, scene.item.itemHandle,
+ ]
+
+ multiMime = model.mimeData([novelIdx, folderIdx, sceneIdx, invalidIdx])
+ assert decodeMimeHandles(multiMime) == [
+ novel.item.itemHandle, folder.item.itemHandle, scene.item.itemHandle,
+ ]
+
+ # Check that drop is possible
+ invalidMime = QMimeData()
+ invalidMime.setData("plain/text", b"foobar")
+
+ assert model.canDropMimeData(invalidMime, Qt.DropAction.MoveAction, 0, 0, novelIdx) is False
+ assert model.canDropMimeData(sceneMime, Qt.DropAction.MoveAction, 0, 0, novelIdx) is True
+
+ # Drop the scene on the novel folder
+ assert [n.item.itemName for n in model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations",
+ ]
+ assert model.dropMimeData(invalidMime, Qt.DropAction.MoveAction, 0, 0, novelIdx) is False
+ assert model.dropMimeData(sceneChapterMime, Qt.DropAction.MoveAction, 0, 0, novelIdx) is True
+ assert [n.item.itemName for n in model.root.allChildren()] == [
+ "Novel", "New Chapter", "New Scene", "Title Page", "New Folder",
+ "Plot", "Characters", "Locations",
+ ]
+
+
+@pytest.mark.core
+def testCoreItemModel_ProjectModel_Data(mockGUI, mockRnd, fncPath):
+ """Test data access for the project model."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+ model = project.tree.model
+
+ # Nodes
+ root = model.root
+ novel = root.child(0)
+ assert novel is not None
+ folder = novel.child(1)
+ assert folder is not None
+ chapter = folder.child(0)
+ assert chapter is not None
+ scene = folder.child(1)
+ assert scene is not None
+
+ assert novel.item.itemName == "Novel"
+ assert folder.item.itemName == "New Folder"
+ assert chapter.item.itemName == "New Chapter"
+ assert scene.item.itemName == "New Scene"
+
+ # Indices
+ rootIdx = QModelIndex()
+ novelIdx = model.index(0, 0, rootIdx)
+ folderIdx = model.index(1, 0, novelIdx)
+ chapterIdx = model.index(0, 0, folderIdx)
+ sceneIdx = model.index(1, 0, folderIdx)
+ invalidIdx = model.index(-1, -1)
+
+ # Check Rows
+ assert model.row(rootIdx) == -1
+ assert model.row(novelIdx) == 0
+ assert model.row(folderIdx) == 1
+ assert model.row(chapterIdx) == 0
+ assert model.row(sceneIdx) == 1
+ assert model.row(invalidIdx) == -1
+
+ # Check Nodes
+ assert model.node(rootIdx) is None
+ assert model.node(novelIdx) is novel
+ assert model.node(folderIdx) is folder
+ assert model.node(chapterIdx) is chapter
+ assert model.node(sceneIdx) is scene
+ assert model.node(invalidIdx) is None
+
+ nodes = model.nodes([novelIdx, folderIdx, chapterIdx, sceneIdx])
+ assert nodes[0] is novel
+ assert nodes[1] is folder
+ assert nodes[2] is chapter
+ assert nodes[3] is scene
+
+ # Index from Handle
+ assert model.indexFromHandle(None).isValid() is False
+ assert model.node(model.indexFromHandle(novel.item.itemHandle)) is novel
+ assert model.node(model.indexFromHandle(folder.item.itemHandle)) is folder
+ assert model.node(model.indexFromHandle(chapter.item.itemHandle)) is chapter
+ assert model.node(model.indexFromHandle(scene.item.itemHandle)) is scene
+
+ # Index from Node
+ assert model.indexFromHandle(None).isValid() is False
+ assert model.node(model.indexFromNode(novel)) is novel
+ assert model.node(model.indexFromNode(folder)) is folder
+ assert model.node(model.indexFromNode(chapter)) is chapter
+ assert model.node(model.indexFromNode(scene)) is scene
+
+
+@pytest.mark.core
+def testCoreItemModel_ProjectModel_Edit(qtbot, mockGUI, mockRnd, fncPath):
+ """Test editing the project model."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+ model = project.tree.model
+
+ # Nodes
+ root = model.root
+ novel = root.child(0)
+ assert novel is not None
+ title = novel.child(0)
+ assert title is not None
+ folder = novel.child(1)
+ assert folder is not None
+ chapter = folder.child(0)
+ assert chapter is not None
+ scene = folder.child(1)
+ assert scene is not None
+
+ assert novel.item.itemName == "Novel"
+ assert title.item.itemName == "Title Page"
+ assert folder.item.itemName == "New Folder"
+ assert chapter.item.itemName == "New Chapter"
+ assert scene.item.itemName == "New Scene"
+
+ # Indices
+ novelIdx = model.indexFromNode(novel)
+ titleIdx = model.indexFromNode(title)
+ folderIdx = model.indexFromNode(folder)
+ chapterIdx = model.indexFromNode(chapter)
+ sceneIdx = model.indexFromNode(scene)
+ invalidIdx = model.index(-1, -1)
+
+ # Initial Order
+ assert [n.item.itemName for n in model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations",
+ ]
+
+ # Remove Child, invalid index
+ assert model.removeChild(novelIdx, -1) is None
+ assert model.removeChild(novelIdx, 99) is None
+
+ # Remove Child
+ with qtbot.waitSignal(model.rowsAboutToBeRemoved) as signal:
+ child = model.removeChild(novelIdx, titleIdx.row())
+ assert signal.args[0].internalPointer().item.itemName == "Novel"
+ assert signal.args[1] == 0
+ assert signal.args[2] == 0
+ assert child is not None
+ assert child.item.itemName == "Title Page"
+ assert [n.item.itemName for n in model.root.allChildren()] == [
+ "Novel", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations",
+ ]
+ titleIdx = model.indexFromNode(title)
+ folderIdx = model.indexFromNode(folder)
+ chapterIdx = model.indexFromNode(chapter)
+ sceneIdx = model.indexFromNode(scene)
+
+ # Insert Child
+ with qtbot.waitSignal(model.rowsAboutToBeInserted) as signal:
+ model.insertChild(child, novelIdx, 1)
+ assert signal.args[0].internalPointer().item.itemName == "Novel"
+ assert signal.args[1] == 1
+ assert signal.args[2] == 1
+ assert [n.item.itemName for n in model.root.allChildren()] == [
+ "Novel", "New Folder", "New Chapter", "New Scene", "Title Page",
+ "Plot", "Characters", "Locations",
+ ]
+ titleIdx = model.indexFromNode(title)
+ folderIdx = model.indexFromNode(folder)
+ chapterIdx = model.indexFromNode(chapter)
+ sceneIdx = model.indexFromNode(scene)
+
+ # Move it back with internal move
+ with qtbot.waitSignal(model.rowsAboutToBeMoved) as signal:
+ model.internalMove(titleIdx, -1)
+ assert signal.args[0].internalPointer().item.itemName == "Novel"
+ assert signal.args[1] == 1
+ assert signal.args[2] == 1
+ assert signal.args[3].internalPointer().item.itemName == "Novel"
+ assert signal.args[4] == 0
+ assert [n.item.itemName for n in model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations",
+ ]
+ titleIdx = model.indexFromNode(title)
+ folderIdx = model.indexFromNode(folder)
+ chapterIdx = model.indexFromNode(chapter)
+ sceneIdx = model.indexFromNode(scene)
+
+ # Move Multiple, with parent
+ # Chapter and scene selection is flipped, but they should be deselected
+ # because folder is also selected, so their order should not change
+ model.multiMove([folderIdx, sceneIdx, chapterIdx, invalidIdx], novelIdx, 0)
+ assert [n.item.itemName for n in model.root.allChildren()] == [
+ "Novel", "New Folder", "New Chapter", "New Scene", "Title Page",
+ "Plot", "Characters", "Locations",
+ ]
+ assert folder.parent() is novel
+ assert chapter.parent() is folder
+ assert scene.parent() is folder
+
+ titleIdx = model.indexFromNode(title)
+ folderIdx = model.indexFromNode(folder)
+ chapterIdx = model.indexFromNode(chapter)
+ sceneIdx = model.indexFromNode(scene)
+
+ # Move Multiple, siblings, altered order
+ # Chapter and scene selection is flipped, and they should now be reordered,
+ # and no longer in the folder
+ model.multiMove([sceneIdx, chapterIdx, invalidIdx], novelIdx, 0)
+ assert [n.item.itemName for n in model.root.allChildren()] == [
+ "Novel", "New Scene", "New Chapter", "New Folder", "Title Page",
+ "Plot", "Characters", "Locations",
+ ]
+ assert folder.parent() is novel
+ assert chapter.parent() is novel
+ assert scene.parent() is novel
+
+
+@pytest.mark.core
+def testCoreItemModel_ProjectModel_Other(qtbot, mockGUI, mockRnd, fncPath):
+ """Test other methods of the project model."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+ model = project.tree.model
+
+ # Nodes
+ root = model.root
+ novel = root.child(0)
+ assert novel is not None
+ title = novel.child(0)
+ assert title is not None
+ folder = novel.child(1)
+ assert folder is not None
+ chapter = folder.child(0)
+ assert chapter is not None
+ scene = folder.child(1)
+ assert scene is not None
+ trash = project.tree.trash
+ assert trash is not None
+
+ assert novel.item.itemName == "Novel"
+ assert title.item.itemName == "Title Page"
+ assert folder.item.itemName == "New Folder"
+ assert chapter.item.itemName == "New Chapter"
+ assert scene.item.itemName == "New Scene"
+
+ assert [n.item.itemName for n in model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
+
+ # Indices
+ chapterIdx = model.indexFromNode(chapter)
+ sceneIdx = model.indexFromNode(scene)
+ trashIdx = model.indexFromNode(trash)
+
+ # Expanded
+ assert model.allExpanded() == []
+ novel.item.setExpanded(True)
+ folder.item.setExpanded(True)
+ assert [model.node(i) for i in model.allExpanded()] == [novel, folder]
+
+ # Check Trash
+ assert model.trashSelection([chapterIdx, sceneIdx]) is False
+ model.multiMove([chapterIdx, sceneIdx], trashIdx)
+ assert [n.item.itemName for n in model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder",
+ "Plot", "Characters", "Locations", "Trash", "New Chapter", "New Scene",
+ ]
+
+ chapterIdx = model.indexFromNode(chapter)
+ sceneIdx = model.indexFromNode(scene)
+ assert model.trashSelection([chapterIdx, sceneIdx]) is True
+
+ # Clear
+ model.clear()
+ assert [n.item.itemName for n in model.root.allChildren()] == []
diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py
index 5ca249ae..b9bdb26f 100644
--- a/tests/test_gui/test_gui_doceditor.py
+++ b/tests/test_gui/test_gui_doceditor.py
@@ -82,7 +82,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded
assert docEditor._typConf.typPadChar == nwUnicode.U_NBSP
assert docEditor.docHeader.itemTitle.text() == (
- "Novel \u203a New Chapter \u203a New Scene"
+ "Novel \u203a New Folder \u203a New Scene"
)
assert docEditor.docHeader._docOutline == {0: "### New Scene"}
diff --git a/tests/tools.py b/tests/tools.py
index c48e3216..aaa11ef6 100644
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -188,7 +188,7 @@ def buildTestProject(obj: object, projPath: Path) -> None:
project.newRoot(nwItemClass.WORLD)
tdHandle = project.newFile("Title Page", nrHandle)
- cfHandle = project.newFolder("New Chapter", nrHandle) or ""
+ cfHandle = project.newFolder("New Folder", nrHandle) or ""
cdHandle = project.newFile("New Chapter", cfHandle)
sdHandle = project.newFile("New Scene", cfHandle)
From 9ec9c55c3fad37d8f6eb2ed6902f943dfd0368e4 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 24 Nov 2024 02:48:17 +0100
Subject: [PATCH 35/39] Add test coverage of the project tree
---
novelwriter/core/itemmodel.py | 26 ++
novelwriter/core/tree.py | 107 ++---
novelwriter/gui/doceditor.py | 2 +-
novelwriter/gui/docviewer.py | 2 +-
tests/test_core/test_core_tree.py | 672 ++++++++++++------------------
5 files changed, 346 insertions(+), 463 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index 25a58796..c83e7907 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -66,6 +66,25 @@ if TYPE_CHECKING: # pragma: no cover
class ProjectNode:
+ """Core: Project Model Node Class
+
+ The project tree structure is saved as nodes in a tree, starting
+ from a root node. This class makes up these nodes.
+
+ Each node is a wrapper around an NWItem object. The NWItem is the
+ object representing a single item in the project, and it only
+ contains a reference to its parent as well as it top level root, but
+ is itself not structured in a hierarchy in memory.
+
+ This class provides the necessary hierarchical structure, as well as
+ the data entries needed for populating the GUI project tree. It also
+ handles pushing and pulling information from its NWItem when
+ necessary.
+
+ The data to be displayed could in principle be pulled from the
+ NWItem whenever it is needed, but for performance reason it is
+ cached, as the GUI will pull this information often.
+ """
C_NAME = 0
C_COUNT = 1
@@ -254,6 +273,13 @@ class ProjectNode:
class ProjectModel(QAbstractItemModel):
+ """Core: Project Model Class
+
+ This class provides the interface for the tree widget used on the
+ GUI. It implements the QModelIndex based interface required, adds
+ support for drag and drop, and a few other novelWriter-specific
+ methods needed primarily by the project tree GUI component.
+ """
__slots__ = ("_tree", "_root")
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index c2afe769..55c23ebd 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -45,7 +45,7 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__)
-MAX_DEPTH = 1000 # Cap of tree traversing for loops (recursion limit)
+MAX_DEPTH = 999 # Cap of tree traversing for loops (recursion limit)
class NWTree:
@@ -55,12 +55,6 @@ class NWTree:
This class holds all the project items of the project as instances
of NWItem.
- For historical reasons, the order of the items is saved in a
- separate list from the items themselves, which are stored in a
- dictionary. This is somewhat redundant with the newer versions of
- Python, but is still practical as it's easier to update the item
- order as a list.
-
Each item has a handle, which is a random hex string of length 13.
The handle is the name of the item everywhere in novelWriter, and is
also used for file names.
@@ -77,10 +71,37 @@ class NWTree:
logger.debug("Ready: NWTree")
return
- def __del__(self) -> None:
+ def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: NWTree")
return
+ def __len__(self) -> int:
+ """The number of items in the project."""
+ return len(self._items)
+
+ def __bool__(self) -> bool:
+ """True if there are any items in the project."""
+ return bool(self._items)
+
+ def __getitem__(self, tHandle: str | None) -> NWItem | None:
+ """Return a project item based on its handle. Returns None if
+ the handle doesn't exist in the project.
+ """
+ if tHandle and tHandle in self._items:
+ return self._items[tHandle]
+ logger.error("No tree item with handle '%s'", str(tHandle))
+ return None
+
+ def __contains__(self, tHandle: str) -> bool:
+ """Checks if a handle exists in the tree."""
+ return tHandle in self._items
+
+ def __iter__(self) -> Iterator[NWItem]:
+ """Iterate through project items."""
+ for node in self._model.root.allChildren():
+ yield node.item
+ return
+
##
# Properties
##
@@ -148,7 +169,8 @@ class NWTree:
self._itemChange(node.item, nwChange.DELETE)
del self._nodes[tHandle]
del self._items[tHandle]
- return True
+ return True
+ return False
@overload # pragma: no cover
def create(
@@ -219,7 +241,7 @@ class NWTree:
later = items
self._model.beginInsertRows(self._model.index(0, 0), 0, 0)
- for _ in range(999):
+ for _ in range(MAX_DEPTH):
later = self._addItems(later)
if len(later) == 0:
break
@@ -251,6 +273,8 @@ class NWTree:
for node in reversed(self._model.root.allChildren()):
node.refresh()
node.updateCount(propagate=False)
+ self._model.root.refresh()
+ self._model.root.updateCount(propagate=False)
self._model.layoutChanged.emit()
return
@@ -364,35 +388,27 @@ class NWTree:
def checkType(self, tHandle: str, itemType: nwItemType) -> bool:
"""Check if item exists and is of the specified item type."""
- tItem = self.__getitem__(tHandle)
- if not tItem:
- return False
- return tItem.itemType == itemType
+ if tItem := self.__getitem__(tHandle):
+ return tItem.itemType == itemType
+ return False
- def getItemPath(self, tHandle: str, asName: bool = False) -> list[str]:
+ def itemPath(self, tHandle: str, asName: bool = False) -> list[str]:
"""Iterate upwards in the tree until we find the item with
parent None, the root item, and return the list of handles, or
alternatively item names. We do this with a for loop with a
maximum depth to make infinite loops impossible.
"""
- tTree = []
- tItem = self.__getitem__(tHandle)
- if tItem is not None:
- tTree.append(tItem.itemName if asName else tHandle)
+ path = []
+ if node := self._nodes.get(tHandle):
for _ in range(MAX_DEPTH):
- if tItem.itemParent is None:
- return tTree
+ if parent := node.parent():
+ path.append(node.item.itemName if asName else tHandle)
+ node = parent
else:
- tHandle = tItem.itemParent
- tItem = self.__getitem__(tHandle)
- if tItem is None:
- return tTree
- else:
- tTree.append(tItem.itemName if asName else tHandle)
+ return path
else:
- raise RecursionError("Critical internal error")
-
- return tTree
+ logger.error("Max project tree depth reached")
+ return path
def subTree(self, tHandle: str) -> list[str]:
"""Get the subtree from a given handle."""
@@ -426,37 +442,6 @@ class NWTree:
return node.item.itemHandle
return None
- ##
- # Special Methods
- ##
-
- def __len__(self) -> int:
- """The number of items in the project."""
- return len(self._items)
-
- def __bool__(self) -> bool:
- """True if there are any items in the project."""
- return bool(self._items)
-
- def __getitem__(self, tHandle: str | None) -> NWItem | None:
- """Return a project item based on its handle. Returns None if
- the handle doesn't exist in the project.
- """
- if tHandle and tHandle in self._items:
- return self._items[tHandle]
- logger.error("No tree item with handle '%s'", str(tHandle))
- return None
-
- def __contains__(self, tHandle: str) -> bool:
- """Checks if a handle exists in the tree."""
- return tHandle in self._items
-
- def __iter__(self) -> Iterator[NWItem]:
- """Iterate through project items."""
- for node in self._model.root.allChildren():
- yield node.item
- return
-
##
# Internal Functions
##
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index 5a5a6ba3..a77869ca 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -3013,7 +3013,7 @@ class GuiDocEditHeader(QWidget):
if CONFIG.showFullPath:
self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed(
- [name for name in SHARED.project.tree.getItemPath(tHandle, asName=True)]
+ [name for name in SHARED.project.tree.itemPath(tHandle, asName=True)]
)))
else:
self.itemTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "")
diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py
index 735c15e1..9e1b6f5f 100644
--- a/novelwriter/gui/docviewer.py
+++ b/novelwriter/gui/docviewer.py
@@ -806,7 +806,7 @@ class GuiDocViewHeader(QWidget):
if CONFIG.showFullPath:
self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed(
- [name for name in SHARED.project.tree.getItemPath(tHandle, asName=True)]
+ [name for name in SHARED.project.tree.itemPath(tHandle, asName=True)]
)))
else:
self.itemTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "")
diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py
index b76d20a1..baf6d3e4 100644
--- a/tests/test_core/test_core_tree.py
+++ b/tests/test_core/test_core_tree.py
@@ -22,282 +22,311 @@ from __future__ import annotations
import random
+from copy import deepcopy
from pathlib import Path
import pytest
-from novelwriter.common import isHandle
from novelwriter.constants import nwFiles
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
from novelwriter.core.tree import NWTree
-from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
+from novelwriter.enum import nwItemClass, nwItemType
from tests.mocked import causeOSError
from tests.tools import C, buildTestProject
@pytest.fixture(scope="function")
-def mockItems(mockGUI, mockRnd):
+def mockItems(mockGUI, mockRnd, fncPath):
"""Create a list of mock items."""
project = NWProject()
-
- itemA = NWItem(project, "a000000000001")
- itemA._name = "Novel"
- itemA._parent = None
- itemA._type = nwItemType.ROOT
- itemA._class = nwItemClass.NOVEL
- itemA._expanded = True
-
- itemB = NWItem(project, "b000000000001")
- itemB._name = "Act One"
- itemB._parent = "a000000000001"
- itemB._type = nwItemType.FOLDER
- itemB._class = nwItemClass.NOVEL
- itemB._expanded = True
-
- itemC = NWItem(project, "c000000000001")
- itemC._name = "Chapter One"
- itemC._parent = "b000000000001"
- itemC._type = nwItemType.FILE
- itemC._class = nwItemClass.NOVEL
- itemC._layout = nwItemLayout.DOCUMENT
- itemC._charCount = 300
- itemC._wordCount = 50
- itemC._paraCount = 2
-
- itemD = NWItem(project, "c000000000002")
- itemD._name = "Scene One"
- itemD._parent = "b000000000001"
- itemD._type = nwItemType.FILE
- itemD._class = nwItemClass.NOVEL
- itemD._layout = nwItemLayout.DOCUMENT
- itemD._charCount = 3000
- itemD._wordCount = 500
- itemD._paraCount = 20
-
- itemE = NWItem(project, "a000000000002")
- itemE._name = "Outtakes"
- itemE._parent = None
- itemE._type = nwItemType.ROOT
- itemE._class = nwItemClass.ARCHIVE
- itemE._expanded = False
-
- itemF = NWItem(project, "a000000000003")
- itemF._name = "Trash"
- itemF._parent = None
- itemF._type = nwItemType.ROOT
- itemF._class = nwItemClass.TRASH
- itemF._expanded = False
-
- itemG = NWItem(project, "a000000000004")
- itemG._name = "Characters"
- itemG._parent = None
- itemG._type = nwItemType.ROOT
- itemG._class = nwItemClass.CHARACTER
- itemG._expanded = True
-
- itemH = NWItem(project, "b000000000002")
- itemH._name = "Jane Doe"
- itemH._parent = "a000000000004"
- itemH._type = nwItemType.FILE
- itemH._class = nwItemClass.CHARACTER
- itemH._layout = nwItemLayout.NOTE
- itemH._charCount = 2000
- itemH._wordCount = 400
- itemH._paraCount = 16
-
- return [itemA, itemB, itemC, itemD, itemE, itemF, itemG, itemH]
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+ return project.tree.pack()
@pytest.mark.core
-@pytest.mark.skip
-def testCoreTree_BuildTree(mockGUI, mockItems):
- """Test building a project tree from a list of items."""
+def testCoreTree_Populate(monkeypatch, mockGUI, mockItems):
+ """Test populating the project tree."""
project = NWProject()
tree = NWTree(project)
- # Check that tree is empty (calls NWTree.__bool__)
+ # Check that tree is empty
assert bool(tree) is False
+ assert len(tree) == 0
- # Check for archive and trash folders
- assert tree.trashRoot is None
-
- aHandles = []
- for nwItem in mockItems:
- aHandles.append(nwItem.itemHandle)
- assert tree.append(nwItem) is True
- assert tree.updateItemData(nwItem.itemHandle) is True
-
- assert tree._changed is True
-
- # Check that tree is not empty (calls __bool__)
+ # Trash should be added on request
+ assert tree.trash is not None
+ assert tree.nodes[tree.trash.item.itemHandle].item.itemName == "Trash"
assert bool(tree) is True
+ assert len(tree) == 1
+ tree.clear()
- # Check the number of elements (calls __len__)
- assert len(tree) == len(mockItems)
+ # Load Items
+ tree.unpack(mockItems)
+ assert len(tree) == 9
+ assert tree.trash is not None
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
- # Check that we have the correct handles
- assert tree.handles() == aHandles
+ # Pack the data again
+ assert [n["name"] for n in tree.pack()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
- # Check by iterator (calls __iter__, __next__ and __getitem__)
- for item, handle in zip(tree, aHandles):
- assert item.itemHandle == handle
+ # Inject a node in the map, but not in the tree (inconsistent tree)
+ # This should be ignored
+ tree._nodes["123456789abc"] = tree.model.root
+ assert [n["name"] for n in tree.pack()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
- # Trash Folder
- # ============
+ # Clear, and populate in reverse order
+ tree.clear()
+ assert bool(tree) is False
+ assert len(tree) == 0
+ tree.unpack(list(reversed(mockItems)))
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Locations", "Characters", "Plot", "Novel", "New Folder",
+ "New Scene", "New Chapter", "Title Page", "Trash",
+ ]
- # Check that we have the correct archive and trash folders
- assert tree.trashRoot == "a000000000003"
- assert tree.findRoot(nwItemClass.ARCHIVE) == "a000000000002"
- assert tree.isTrash("a000000000003") is True
+ # Clear, and populate with one item being its own parent
+ tree.clear()
+ assert bool(tree) is False
+ assert len(tree) == 0
+ modItems = deepcopy(mockItems)
+ assert modItems[1]["name"] == "Title Page"
+ modItems[1]["itemAttr"]["parent"] = modItems[1]["itemAttr"]["handle"]
+ tree.unpack(mockItems)
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
- # Check that we have the root classes
- assert tree.rootClasses() == {
- nwItemClass.NOVEL, nwItemClass.CHARACTER, nwItemClass.ARCHIVE, nwItemClass.TRASH
- }
-
- # Check the isTrash function
- assert tree.isTrash("0000000000000") is True # Doesn't exist
- assert tree.isTrash("a000000000003") is True # This the trash folder
-
- tree["a000000000003"].setClass(nwItemClass.NO_CLASS) # type: ignore
- assert tree.isTrash("a000000000003") is True # This is still trash
- tree["a000000000003"].setClass(nwItemClass.TRASH) # type: ignore
-
- assert tree.isTrash("b000000000002") is False # This is not trash
-
- value = tree["b000000000002"].itemParent # type: ignore
- tree["b000000000002"].setParent("a000000000003") # type: ignore
- assert tree.isTrash("b000000000002") is True # This is in trash
- tree["b000000000002"].setParent(value) # type: ignore
-
- value = tree["b000000000002"].itemRoot # type: ignore
- tree["b000000000002"].setRoot("a000000000003") # type: ignore
- assert tree.isTrash("b000000000002") is True # This is in trash
- tree["b000000000002"].setRoot(value) # type: ignore
-
- # Try to add another trash folder
- itemT = NWItem(project, "1111111111111")
- itemT._name = "Trash"
- itemT._type = nwItemType.ROOT
- itemT._class = nwItemClass.TRASH
- itemT._expanded = False
-
- assert tree.append(itemT) is False
- assert len(tree) == len(mockItems)
-
- # Create or Add Items
- # ===================
-
- # Create a new item, but with invalid parent
- assert tree.create("New File", "blabla", nwItemType.FILE, nwItemClass.NO_CLASS) is None
-
- # Create a new, valid item
- nHandle = tree.create("New File", "b000000000001", nwItemType.FILE, nwItemClass.NO_CLASS)
- assert isHandle(nHandle)
- assert nHandle == "0000000000000"
-
- # The new item should be the last item in the tree
- handles = tree.handles()
- assert handles[-1] == nHandle
-
- # Retrieve the item
- itemT = tree[nHandle]
- assert isinstance(itemT, NWItem)
- assert len(tree) == len(mockItems) + 1
-
- # We should not be allowed to add the item again
- assert tree.append(itemT) is False
- assert len(tree) == len(mockItems) + 1
-
- # Create an invalid item to add, which will be rejected
- itemU = NWItem.duplicate(itemT, "blabla")
- assert tree.append(itemU) is False
- assert len(tree) == len(mockItems) + 1
-
- # Create a new root, but with a parent set anyway (the parent should be ignored)
- zHandle = tree.create("Custom", "a000000000001", nwItemType.ROOT, nwItemClass.CUSTOM)
- assert isinstance(zHandle, str)
- itemZ = tree[zHandle]
- assert isinstance(itemZ, NWItem)
- assert itemZ.itemParent is None
- del tree[zHandle]
-
- # Duplicate Items
- # ===============
-
- # Duplicate a non-existing item
- assert tree.duplicate("blabla") is None
-
- # Duplicate the new item
- itemV = tree.duplicate(nHandle)
- assert isinstance(itemV, NWItem)
- assert len(tree) == len(mockItems) + 2
-
- dHandle = itemV.itemHandle
- assert dHandle == "0000000000002"
-
- # Delete Items
- # ============
-
- # Delete a non-existing item
- del tree["stuff"]
- assert len(tree) == len(mockItems) + 2
-
- # Delete the last items
- del tree[nHandle]
- del tree[dHandle]
- assert len(tree) == len(mockItems)
- assert nHandle not in tree
-
- # Delete the Novel, Archive and Trash folders
- del tree["a000000000001"]
- assert len(tree) == len(mockItems) - 1
- assert "a000000000001" not in tree
-
- del tree["a000000000002"]
- assert len(tree) == len(mockItems) - 2
- assert "a000000000002" not in tree
-
- del tree["a000000000003"]
- assert len(tree) == len(mockItems) - 3
- assert "a000000000003" not in tree
- assert tree.trashRoot is None
+ # Clear and populate reversed with max depth limit very low
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.core.tree.MAX_DEPTH", 1)
+ tree.clear()
+ assert bool(tree) is False
+ assert len(tree) == 0
+ tree.unpack(list(reversed(mockItems)))
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Locations", "Characters", "Plot", "Novel", "Trash",
+ ]
@pytest.mark.core
-@pytest.mark.skip
-def testCoreTree_PackUnpack(mockGUI, mockItems):
- """Test packing and unpacking data."""
+def testCoreTree_ManipulateTree(mockGUI, mockItems):
+ """Check create, add, remove and duplicate items."""
project = NWProject()
tree = NWTree(project)
+ tree.unpack(mockItems)
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
- aHandles = []
- for nwItem in mockItems:
- aHandles.append(nwItem.itemHandle)
- tree.append(nwItem)
- tree.updateItemData(nwItem.itemHandle)
+ # Create Root
+ oHandle = tree.create("Objects", None, nwItemType.ROOT, nwItemClass.OBJECT, pos=4)
+ assert oHandle is not None
+ assert oHandle in tree
- assert len(tree) == len(mockItems)
+ # Create Folder
+ fHandle = tree.create("Foo", oHandle, nwItemType.FOLDER, pos=0)
+ assert fHandle is not None
+ assert fHandle in tree
- # Pack
- packed = tree.pack()
- for i, nwItem in enumerate(mockItems):
- assert packed[i]["itemAttr"]["handle"] == nwItem.itemHandle
+ # Create File
+ bHandle = tree.create("Bar", fHandle, nwItemType.FILE, pos=0)
+ assert bHandle is not None
+ assert bHandle in tree
- # Unpack
- tree.clear()
- assert len(tree) == 0
- assert tree.handles() == []
- tree.unpack(packed)
- assert tree.handles() == aHandles
+ # Check Tree
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash",
+ ]
+
+ # Cannot add a folder or file with no parent
+ assert tree.create("Foo", None, nwItemType.FOLDER, pos=0) is None
+ assert tree.create("Bar", None, nwItemType.FILE, pos=0) is None
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash",
+ ]
+
+ # Duplicate Bar -> Baz
+ baz = tree.duplicate(bHandle, fHandle, True)
+ assert baz is not None
+ baz.setName("Baz")
+ zHandle = baz.itemHandle
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Baz", "Trash",
+ ]
+
+ # Duplicate non-existent
+ assert tree.duplicate("bob", fHandle, True) is None
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Baz", "Trash",
+ ]
+
+ # Remove Baz
+ assert tree.remove(zHandle) is True
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash",
+ ]
+ assert len(tree._items) == len(tree.model.root.allChildren())
+ assert len(tree._items) == len(tree._nodes)
+
+ # Remove non-existing
+ assert tree.remove("bob") is False
+
+ # Add item with non-existing parent
+ item = NWItem(project, tree._makeHandle())
+ item.setParent(tree._makeHandle())
+ assert tree.add(item) is False
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash",
+ ]
+
+ # Add item with non parent, that isn't a root
+ item = NWItem(project, tree._makeHandle())
+ item.setType(nwItemType.FOLDER)
+ assert tree.add(item) is False
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash",
+ ]
@pytest.mark.core
-@pytest.mark.skip
-def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fncPath, mockRnd):
- """Check the project consistency."""
+def testCoreTree_ItemMethods(monkeypatch, mockGUI, mockItems):
+ """Check the item methods of the tree."""
+ project = NWProject()
+ tree = NWTree(project)
+ tree.unpack(mockItems)
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
+
+ # Check Type
+ assert tree.checkType(C.hNovelRoot, nwItemType.ROOT) is True
+ assert tree.checkType(C.hNovelRoot, nwItemType.FOLDER) is False
+ assert tree.checkType(C.hNovelRoot, nwItemType.FILE) is False
+
+ assert tree.checkType(C.hChapterDir, nwItemType.ROOT) is False
+ assert tree.checkType(C.hChapterDir, nwItemType.FOLDER) is True
+ assert tree.checkType(C.hChapterDir, nwItemType.FILE) is False
+
+ assert tree.checkType(C.hChapterDoc, nwItemType.ROOT) is False
+ assert tree.checkType(C.hChapterDoc, nwItemType.FOLDER) is False
+ assert tree.checkType(C.hChapterDoc, nwItemType.FILE) is True
+
+ assert tree.checkType(C.hInvalid, nwItemType.ROOT) is False
+ assert tree.checkType(C.hInvalid, nwItemType.FOLDER) is False
+ assert tree.checkType(C.hInvalid, nwItemType.FILE) is False
+
+ # Item Path
+ assert tree.itemPath(C.hSceneDoc, asName=True) == ["New Scene", "New Folder", "Novel"]
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.core.tree.MAX_DEPTH", 1)
+ assert tree.itemPath(C.hSceneDoc, asName=True) == ["New Scene"]
+
+ # Sub Tree
+ assert tree.subTree(C.hInvalid) == []
+ assert tree.subTree(C.hNovelRoot) == [
+ C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
+ ]
+
+ # Root Classes
+ classes = tree.rootClasses()
+ assert len(classes) == 5
+ assert nwItemClass.NOVEL in classes
+ assert nwItemClass.PLOT in classes
+ assert nwItemClass.CHARACTER in classes
+ assert nwItemClass.WORLD in classes
+ assert nwItemClass.TIMELINE not in classes
+ assert nwItemClass.OBJECT not in classes
+ assert nwItemClass.ENTITY not in classes
+ assert nwItemClass.CUSTOM not in classes
+ assert nwItemClass.ARCHIVE not in classes
+ assert nwItemClass.TEMPLATE not in classes
+ assert nwItemClass.TRASH in classes
+
+ # Iter Roots
+ assert list(tree.iterRoots(nwItemClass.NOVEL)) == [(C.hNovelRoot, tree[C.hNovelRoot])]
+ assert list(tree.iterRoots(nwItemClass.PLOT)) == [(C.hPlotRoot, tree[C.hPlotRoot])]
+ assert list(tree.iterRoots(nwItemClass.CHARACTER)) == [(C.hCharRoot, tree[C.hCharRoot])]
+ assert list(tree.iterRoots(nwItemClass.WORLD)) == [(C.hWorldRoot, tree[C.hWorldRoot])]
+ assert list(tree.iterRoots(nwItemClass.OBJECT)) == []
+
+ # Find Root
+ assert tree.findRoot(nwItemClass.NOVEL) == C.hNovelRoot
+ assert tree.findRoot(nwItemClass.PLOT) == C.hPlotRoot
+ assert tree.findRoot(nwItemClass.CHARACTER) == C.hCharRoot
+ assert tree.findRoot(nwItemClass.WORLD) == C.hWorldRoot
+ assert tree.findRoot(nwItemClass.OBJECT) is None
+
+
+@pytest.mark.core
+def testCoreTree_OtherMethods(qtbot, monkeypatch, mockGUI, fncPath, mockRnd):
+ """Check other methods in the tree."""
+ project = NWProject()
+ buildTestProject(project, fncPath)
+ tree = project.tree
+ trash = tree.trash
+
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
+
+ # Refresh All
+ assert tree.sumWords() == (9, 0)
+ assert tree.model.root.count == 9
+
+ for node in tree.nodes.values():
+ if node.item.isFileType():
+ node.item.setWordCount(5)
+
+ with qtbot.waitSignal(tree._model.layoutChanged):
+ tree.refreshAllItems()
+ assert tree.model.root.count == 15
+
+ project.index.rebuild()
+ tree.refreshAllItems()
+ assert tree.model.root.count == 9
+
+ # Trash can't be created
+ assert trash is not None
+ assert tree._getTrashNode() is trash
+ tree._trash = None
+ assert tree._getTrashNode() is trash
+ tree.remove(trash.item.itemHandle)
+
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.core.tree.NWTree.create", lambda *a, **k: None)
+ assert tree._getTrashNode() is None
+
+
+@pytest.mark.core
+def testCoreTree_CheckConsistency(caplog, mockGUI, fncPath, mockRnd):
+ """Check the project tree's consistency."""
project = NWProject()
buildTestProject(project, fncPath)
@@ -306,17 +335,6 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc
assert project.tree.checkConsistency("Recovered") == (0, 0)
assert all(m.endswith("OK") for m in caplog.messages)
- # Give the scene file an unknown parent
- caplog.clear()
- project.tree[C.hSceneDoc].setParent(C.hInvalid) # type: ignore
- assert project.tree.checkConsistency("Recovered") == (1, 1)
- assert f"'{C.hSceneDoc}' ... ERROR" in caplog.text
-
- # The scene file should have been added back to its home
- itemS = project.tree[C.hSceneDoc]
- assert isinstance(itemS, NWItem)
- assert itemS.itemParent == C.hChapterDir
-
# Create a new file with no meta data, and let the function handle it as orphaned
xHandle = "0123456789abc"
contentPath = project.storage.contentPath
@@ -342,7 +360,7 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc
project.storage.getDocument(xHandle).writeDocument("### Stuff") # This adds meta data
# Remove the item in the project, and re-run the consistency check
- del project.tree[xHandle]
+ project.tree.remove(xHandle)
assert project.tree.checkConsistency("Recovered") == (1, 1)
assert xHandle in project.tree
itemX = project.tree[xHandle]
@@ -363,87 +381,6 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc
@pytest.mark.core
-@pytest.mark.skip
-def testCoreTree_Methods(monkeypatch, mockGUI, mockItems):
- """Test various class methods."""
- project = NWProject()
- tree = NWTree(project)
-
- for nwItem in mockItems:
- tree.append(nwItem)
- tree.updateItemData(nwItem.itemHandle)
-
- assert len(tree) == len(mockItems)
-
- # Update item data, nonsense handle
- assert tree.updateItemData("stuff") is False
-
- # Update item data, invalid item parent
- corrParent = tree["b000000000001"].itemParent # type: ignore
- tree["b000000000001"].setParent("0000000000000") # type: ignore
- assert tree.updateItemData("b000000000001") is False
-
- # Update item data, valid item parent
- tree["b000000000001"].setParent(corrParent) # type: ignore
- assert tree.updateItemData("b000000000001") is True
-
- # Update item data, root is unreachable
- with monkeypatch.context() as mp:
- mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0)
- with pytest.raises(RecursionError):
- tree.updateItemData("b000000000001")
-
- # Check type
- assert tree.checkType("blabla", nwItemType.FILE) is False
- assert tree.checkType("b000000000001", nwItemType.FILE) is False
- assert tree.checkType("c000000000001", nwItemType.FILE) is True
-
- # Root item lookup
- assert tree.findRoot(nwItemClass.WORLD) is None
- assert tree.findRoot(nwItemClass.NOVEL) == "a000000000001"
- assert tree.findRoot(nwItemClass.CHARACTER) == "a000000000004"
-
- # Iter roots
- roots = list(tree.iterRoots(None))
- assert roots[0][0] == "a000000000001"
- assert roots[1][0] == "a000000000002"
- assert roots[2][0] == "a000000000003"
- assert roots[3][0] == "a000000000004"
-
- # Add a fake item to root and check that it can handle it
- tree._roots["0000000000000"] = NWItem(project, "0000000000000")
- assert tree.findRoot(nwItemClass.WORLD) is None
- del tree._roots["0000000000000"]
-
- # Get item path
- assert tree.getItemPath("stuff") == []
- assert tree.getItemPath("c000000000001") == [
- "c000000000001", "b000000000001", "a000000000001"
- ]
- assert tree.getItemPath("c000000000001", asName=True) == [
- "Chapter One", "Act One", "Novel"
- ]
-
- # Cause recursion error
- with monkeypatch.context() as mp:
- mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0)
- with pytest.raises(RecursionError):
- tree.getItemPath("c000000000001")
-
- # Break the folder parent handle
- tree["b000000000001"]._parent = "stuff" # type: ignore
- assert tree.getItemPath("c000000000001") == [
- "c000000000001", "b000000000001"
- ]
-
- tree["b000000000001"]._parent = "a000000000001" # type: ignore
- assert tree.getItemPath("c000000000001") == [
- "c000000000001", "b000000000001", "a000000000001"
- ]
-
-
-@pytest.mark.core
-@pytest.mark.skip
def testCoreTree_MakeHandles(mockGUI):
"""Test generating item handles."""
random.seed(42)
@@ -455,15 +392,15 @@ def testCoreTree_MakeHandles(mockGUI):
random.seed(42)
tHandle = tree._makeHandle()
assert tHandle == handles[0]
- tree._tree[handles[0]] = None # type: ignore
+ tree._items[handles[0]] = None # type: ignore
# Add the next in line to the project to force duplicate
- tree._tree[handles[1]] = None # type: ignore
+ tree._items[handles[1]] = None # type: ignore
tHandle = tree._makeHandle()
assert tHandle == handles[2]
- tree._tree[handles[2]] = None # type: ignore
+ tree._items[handles[2]] = None # type: ignore
- # Reset the seed to force collissions, which should still end up
+ # Reset the seed to force collisions, which should still end up
# returning the next handle in the sequence
random.seed(42)
tHandle = tree._makeHandle()
@@ -471,71 +408,11 @@ def testCoreTree_MakeHandles(mockGUI):
@pytest.mark.core
-@pytest.mark.skip
-def testCoreTree_Stats(mockGUI, mockItems):
- """Test project stats methods."""
- project = NWProject()
- tree = NWTree(project)
-
- for nwItem in mockItems:
- tree.append(nwItem)
-
- assert len(tree) == len(mockItems)
- tree._order.append("stuff")
-
- # Count Words
- novelWords, noteWords = tree.sumWords()
- assert novelWords == 550
- assert noteWords == 400
-
-
-@pytest.mark.core
-@pytest.mark.skip
-def testCoreTree_Reorder(caplog, mockGUI, mockItems):
- """Test changing tree order."""
- project = NWProject()
- tree = NWTree(project)
-
- aHandle = []
- for nwItem in mockItems:
- aHandle.append(nwItem.itemHandle)
- tree.append(nwItem)
-
- assert len(tree) == len(mockItems)
-
- bHandle = aHandle.copy()
- bHandle[2], bHandle[3] = bHandle[3], bHandle[2]
- assert aHandle != bHandle
-
- assert tree.handles() == aHandle
- tree.setOrder(bHandle)
- assert tree.handles() == bHandle
-
- caplog.clear()
- tree.setOrder(bHandle + ["stuff"])
- assert tree.handles() == bHandle
- assert "Handle 'stuff' in new tree order is not in old order" in caplog.text
-
- caplog.clear()
- tree._order.append("stuff")
- tree.setOrder(bHandle)
- assert tree.handles() == bHandle
- assert "Handle 'stuff' in old tree order is not in new order" in caplog.text
-
-
-@pytest.mark.core
-@pytest.mark.skip
def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
"""Test writing the ToC.txt file."""
project = NWProject()
tree = NWTree(project)
-
- for nwItem in mockItems:
- tree.append(nwItem)
- tree.updateItemData(nwItem.itemHandle)
-
- assert len(tree) == len(mockItems)
- tree._order.append("stuff")
+ tree.unpack(mockItems)
def mockIsFile(fileName):
"""Return True for items that are files in novelWriter and
@@ -547,7 +424,7 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
monkeypatch.setattr("pathlib.Path.is_file", mockIsFile)
project._storage._runtimePath = fncPath
- (fncPath / "content").mkdir()
+ # (fncPath / "content").mkdir()
# Block extraction of the path
with monkeypatch.context() as mp:
@@ -561,11 +438,6 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
# Allow writing
assert tree.writeToCFile() is True
-
- pathA = str(Path("content") / "c000000000001.nwd")
- pathB = str(Path("content") / "c000000000002.nwd")
- pathC = str(Path("content") / "b000000000002.nwd")
-
assert (fncPath / nwFiles.TOC_TXT).read_text() == (
"\n"
"Table of Contents\n"
@@ -573,7 +445,7 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
"\n"
"File Name Class Layout Document Label\n"
"--------------------------------------------------------------\n"
- f"{pathA} NOVEL DOCUMENT Chapter One\n"
- f"{pathB} NOVEL DOCUMENT Scene One\n"
- f"{pathC} CHARACTER NOTE Jane Doe\n"
+ "content/000000000000c.nwd NOVEL DOCUMENT Title Page\n"
+ "content/000000000000e.nwd NOVEL DOCUMENT New Chapter\n"
+ "content/000000000000f.nwd NOVEL DOCUMENT New Scene\n"
)
From 32d899dbfb24f339940a88698f37dec8fca6c2b6 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 24 Nov 2024 02:53:54 +0100
Subject: [PATCH 36/39] Fix windows path
---
tests/test_core/test_core_tree.py | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py
index baf6d3e4..9eab11d8 100644
--- a/tests/test_core/test_core_tree.py
+++ b/tests/test_core/test_core_tree.py
@@ -436,6 +436,10 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
mp.setattr("builtins.open", causeOSError)
assert tree.writeToCFile() is False
+ pathA = str(Path("content") / "000000000000c.nwd")
+ pathB = str(Path("content") / "000000000000e.nwd")
+ pathC = str(Path("content") / "000000000000f.nwd")
+
# Allow writing
assert tree.writeToCFile() is True
assert (fncPath / nwFiles.TOC_TXT).read_text() == (
@@ -445,7 +449,7 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
"\n"
"File Name Class Layout Document Label\n"
"--------------------------------------------------------------\n"
- "content/000000000000c.nwd NOVEL DOCUMENT Title Page\n"
- "content/000000000000e.nwd NOVEL DOCUMENT New Chapter\n"
- "content/000000000000f.nwd NOVEL DOCUMENT New Scene\n"
+ f"{pathA} NOVEL DOCUMENT Title Page\n"
+ f"{pathB} NOVEL DOCUMENT New Chapter\n"
+ f"{pathC} NOVEL DOCUMENT New Scene\n"
)
From 0449cf824d8df17f7b33e20b77a6dc9003842809 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 24 Nov 2024 18:39:23 +0100
Subject: [PATCH 37/39] Add test coverage of the gui project tree
---
novelwriter/core/itemmodel.py | 8 +
novelwriter/gui/projtree.py | 50 +-
novelwriter/tools/manussettings.py | 3 +-
.../guiEditor_Main_Final_nwProject.nwx | 20 +-
tests/test_core/test_core_itemmodel.py | 10 +
tests/test_gui/test_gui_projtree.py | 1784 ++++++++---------
6 files changed, 910 insertions(+), 965 deletions(-)
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index c83e7907..df4d33c1 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -240,6 +240,14 @@ class ProjectNode:
self._refreshChildrenPos()
return
+ def setExpanded(self, state: bool) -> None:
+ """Set the node's expanded state."""
+ if state and self._children:
+ self._item.setExpanded(True)
+ else:
+ self._item.setExpanded(False)
+ return
+
##
# Internal Functions
##
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index aa3ea021..d9ca56a6 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -101,22 +101,22 @@ class GuiProjectView(QWidget):
self.keyGoPrev = QShortcut(self.projTree)
self.keyGoPrev.setKey("Alt+Up")
self.keyGoPrev.setContext(Qt.ShortcutContext.WidgetShortcut)
- self.keyGoPrev.activated.connect(self.projTree.moveSiblingUp)
+ self.keyGoPrev.activated.connect(self.projTree.goToSiblingUp)
self.keyGoNext = QShortcut(self.projTree)
self.keyGoNext.setKey("Alt+Down")
self.keyGoNext.setContext(Qt.ShortcutContext.WidgetShortcut)
- self.keyGoNext.activated.connect(self.projTree.moveSiblingDown)
+ self.keyGoNext.activated.connect(self.projTree.goToSiblingDown)
self.keyGoUp = QShortcut(self.projTree)
self.keyGoUp.setKey("Alt+Left")
self.keyGoUp.setContext(Qt.ShortcutContext.WidgetShortcut)
- self.keyGoUp.activated.connect(self.projTree.moveToParent)
+ self.keyGoUp.activated.connect(self.projTree.goToParent)
self.keyGoDown = QShortcut(self.projTree)
self.keyGoDown.setKey("Alt+Right")
self.keyGoDown.setContext(Qt.ShortcutContext.WidgetShortcut)
- self.keyGoDown.activated.connect(self.projTree.moveToFirstChild)
+ self.keyGoDown.activated.connect(self.projTree.goToFirstChild)
self.keyContext = QShortcut(self.projTree)
self.keyContext.setKey("Ctrl+.")
@@ -415,8 +415,6 @@ class GuiProjectToolBar(QWidget):
self.mTemplates.addUpdate(tHandle, item.itemName, item.getMainIcon())
elif tHandle in self.mTemplates:
self.mTemplates.remove(tHandle)
- elif change == nwChange.DELETE and tHandle in self.mTemplates:
- self.mTemplates.remove(tHandle)
return
##
@@ -680,7 +678,8 @@ class GuiProjectTree(QTreeView):
tHandle = SHARED.project.newFolder(newLabel, sHandle, pos)
# Select the new item automatically
- self.setSelectedHandle(tHandle)
+ if tHandle:
+ self.setSelectedHandle(tHandle)
return
@@ -793,6 +792,7 @@ class GuiProjectTree(QTreeView):
dHandles = docDup.duplicate(itemTree)
if len(dHandles) != len(itemTree):
SHARED.warn(self.tr("Could not duplicate all items."))
+ self.restoreExpandedState()
return
##
@@ -807,7 +807,7 @@ class GuiProjectTree(QTreeView):
super().mousePressEvent(event)
if event.button() == QtMouseLeft:
if not self.indexAt(event.pos()).isValid():
- self.selectionModel().clearCurrentIndex()
+ self._clearSelection()
elif event.button() == QtMouseMiddle:
if (node := self._getNode(self.indexAt(event.pos()))) and node.item.isFileType():
self.projView.openDocumentRequest.emit(
@@ -841,7 +841,7 @@ class GuiProjectTree(QTreeView):
return
@pyqtSlot()
- def moveSiblingUp(self) -> None:
+ def goToSiblingUp(self) -> None:
"""Skip to the previous sibling."""
if (node := self._getNode(self.currentIndex())) and (parent := node.parent()):
if (move := parent.child(node.row() - 1)) and (model := self._getModel()):
@@ -849,7 +849,7 @@ class GuiProjectTree(QTreeView):
return
@pyqtSlot()
- def moveSiblingDown(self) -> None:
+ def goToSiblingDown(self) -> None:
"""Skip to the next sibling."""
if (node := self._getNode(self.currentIndex())) and (parent := node.parent()):
if (move := parent.child(node.row() + 1)) and (model := self._getModel()):
@@ -857,7 +857,7 @@ class GuiProjectTree(QTreeView):
return
@pyqtSlot()
- def moveToParent(self) -> None:
+ def goToParent(self) -> None:
"""Move to parent item."""
if (
(model := self._getModel())
@@ -868,7 +868,7 @@ class GuiProjectTree(QTreeView):
return
@pyqtSlot()
- def moveToFirstChild(self) -> None:
+ def goToFirstChild(self) -> None:
"""Move to first child item."""
if (
(model := self._getModel())
@@ -893,7 +893,9 @@ class GuiProjectTree(QTreeView):
return
@pyqtSlot()
- def processDeleteRequest(self, handles: list[str] = [], askFirst: bool = True) -> None:
+ def processDeleteRequest(
+ self, handles: list[str] | None = None, askFirst: bool = True
+ ) -> None:
"""Move selected items to Trash."""
if handles and (model := self._getModel()):
indices = [model.indexFromHandle(handle) for handle in handles]
@@ -1002,20 +1004,26 @@ class GuiProjectTree(QTreeView):
def _onNodeCollapsed(self, index: QModelIndex) -> None:
"""Capture a node collapse, and pass it to the model."""
if node := self._getNode(index):
- node.item.setExpanded(False)
+ node.setExpanded(False)
return
@pyqtSlot(QModelIndex)
def _onNodeExpanded(self, index: QModelIndex) -> None:
"""Capture a node expand, and pass it to the model."""
if node := self._getNode(index):
- node.item.setExpanded(True)
+ node.setExpanded(True)
return
##
# Internal Functions
##
+ def _clearSelection(self) -> None:
+ """Clear the currently selected items."""
+ self.clearSelection()
+ self.selectionModel().clearCurrentIndex()
+ return
+
def _selectedRows(self) -> list[QModelIndex]:
"""Return all column 0 indexes."""
return [i for i in self.selectedIndexes() if i.column() == 0]
@@ -1162,9 +1170,8 @@ class _TreeContextMenu(QMenu):
# Process Item
if self._children:
self._expandCollapse()
- if isFile:
- action = self.addAction(self.tr("Duplicate"))
- action.triggered.connect(qtLambda(self._tree.duplicateFromHandle, self._handle))
+ action = self.addAction(self.tr("Duplicate"))
+ action.triggered.connect(qtLambda(self._tree.duplicateFromHandle, self._handle))
self._deleteOrTrash()
return
@@ -1321,9 +1328,10 @@ class _TreeContextMenu(QMenu):
def _deleteOrTrash(self) -> None:
"""Add move to Trash action."""
- if self._model.trashSelection(self._indices):
- text = self.tr("Delete Permanently")
- elif len(self._indices) == 1 and self._item.isRootType():
+ if (
+ self._model.trashSelection(self._indices)
+ or len(self._indices) == 1 and self._item.isRootType()
+ ):
text = self.tr("Delete Permanently")
else:
text = self.tr("Move to Trash")
diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py
index bca7cbaa..e5221cd2 100644
--- a/novelwriter/tools/manussettings.py
+++ b/novelwriter/tools/manussettings.py
@@ -419,8 +419,7 @@ class _FilterTab(NFixedPage):
logger.debug("Building project tree")
self._treeMap = {}
self.optTree.clear()
- for node in SHARED.project.tree.model.root.allChildren():
- nwItem = node.item
+ for nwItem in SHARED.project.tree:
tHandle = nwItem.itemHandle
pHandle = nwItem.itemParent
rHandle = nwItem.itemRoot
diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx
index 3b05335b..d3051769 100644
--- a/tests/reference/guiEditor_Main_Final_nwProject.nwx
+++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx
@@ -34,7 +34,7 @@
Novel
-
-
+
Title Page
-
@@ -42,39 +42,39 @@
New Folder
-
-
+
New Chapter
-
-
+
New Scene
-
-
+
Plot
-
-
+
New Note
-
-
+
Characters
-
-
+
New Note
-
-
+
Locations
-
-
+
New Note
-
-
+
Trash
diff --git a/tests/test_core/test_core_itemmodel.py b/tests/test_core/test_core_itemmodel.py
index 5d26d2ea..f63afa35 100644
--- a/tests/test_core/test_core_itemmodel.py
+++ b/tests/test_core/test_core_itemmodel.py
@@ -116,6 +116,16 @@ def testCoreItemModel_ProjectNode_Children(mockGUI, mockRnd, fncPath):
assert child010.parent() is child01
assert child011.parent() is child01
+ # Expand
+ child0.setExpanded(True)
+ child1.setExpanded(True)
+ child2.setExpanded(True)
+ child3.setExpanded(True)
+ assert child0.item.isExpanded is True # Only one with children
+ assert child1.item.isExpanded is False
+ assert child2.item.isExpanded is False
+ assert child3.item.isExpanded is False
+
@pytest.mark.core
def testCoreItemModel_ProjectNode_Modify(mockGUI, mockRnd, fncPath):
diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py
index 2c572383..3d5ede31 100644
--- a/tests/test_gui/test_gui_projtree.py
+++ b/tests/test_gui/test_gui_projtree.py
@@ -20,24 +20,20 @@ along with this program. If not, see .
"""
from __future__ import annotations
-from pathlib import Path
from unittest.mock import MagicMock
import pytest
-from PyQt5.QtCore import QEvent, QMimeData, QPoint, Qt, QTimer
-from PyQt5.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QMouseEvent
-from PyQt5.QtWidgets import QMenu, QMessageBox, QTreeWidget, QTreeWidgetItem
+from PyQt5.QtCore import QEvent, QItemSelectionModel, QModelIndex, QPoint
+from PyQt5.QtGui import QMouseEvent
+from PyQt5.QtWidgets import QMenu, QMessageBox
from novelwriter import CONFIG, SHARED
-from novelwriter.core.item import NWItem
-from novelwriter.core.project import NWProject
from novelwriter.dialogs.docmerge import GuiDocMerge
from novelwriter.dialogs.docsplit import GuiDocSplit
from novelwriter.dialogs.editlabel import GuiEditLabel
-from novelwriter.enum import nwFocus, nwItemClass, nwItemLayout, nwItemType
-from novelwriter.gui.projtree import GuiProjectTree, GuiProjectView, _TreeContextMenu
-from novelwriter.guimain import GuiMain
+from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType
+from novelwriter.gui.projtree import _TreeContextMenu
from novelwriter.types import (
QtAccepted, QtModNone, QtMouseLeft, QtMouseMiddle, QtRejected,
QtScrollAlwaysOff, QtScrollAsNeeded
@@ -48,122 +44,264 @@ from tests.tools import C, buildTestProject
@pytest.mark.gui
-@pytest.mark.skip
-def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
- """Test adding and removing items from the project tree."""
+def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
+ """Test adding items to the project tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
projView = nwGUI.projView
- projTree = nwGUI.projView.projTree
+ projTree = projView.projTree
project = SHARED.project
+ tree = project.tree
# Try to add item with no project
- assert projTree.newTreeItem(nwItemType.FILE) is False
+ projTree.newTreeItem(nwItemType.FILE)
+ assert len(tree) == 0
# Create a project
buildTestProject(nwGUI, projPath)
-
- # No itemType set
- projTree.clearSelection()
- assert projTree.newTreeItem(None) is False
+ trash = tree.trash
+ assert trash is not None
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
# Root Items
# ==========
# No class set
- assert projTree.newTreeItem(nwItemType.ROOT) is False
+ projTree.newTreeItem(nwItemType.ROOT)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
- # Create root item
- assert projTree.newTreeItem(nwItemType.ROOT, nwItemClass.WORLD) is True
- assert "0000000000010" in project.tree
+ # Create Objects root item after Locations
+ hObjectRoot = "0000000000011"
+ projView.setSelectedHandle(C.hWorldRoot)
+ projTree.newTreeItem(nwItemType.ROOT, nwItemClass.OBJECT)
+ assert hObjectRoot in tree
+ item = tree[hObjectRoot]
+ assert item is not None
+ assert item.itemName == "Objects"
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Objects", "Trash",
+ ]
# File/Folder Items
# =================
# No location selected for new item
- projTree.clearSelection()
+ projTree._clearSelection()
+
caplog.clear()
- assert projTree.newTreeItem(nwItemType.FILE) is False
- assert projTree.newTreeItem(nwItemType.FOLDER) is False
+ projTree.newTreeItem(nwItemType.FILE)
assert "Did not find anywhere" in caplog.text
+ caplog.clear()
+ projTree.newTreeItem(nwItemType.FOLDER)
+ assert "Did not find anywhere" in caplog.text
+
+ # Try to add them to Trash
+ projView.setSelectedHandle(trash.item.itemHandle)
+
+ caplog.clear()
+ projTree.newTreeItem(nwItemType.FILE)
+ assert "Cannot add new files or folders" in caplog.text
+
+ caplog.clear()
+ projTree.newTreeItem(nwItemType.FOLDER)
+ assert "Cannot add new files or folders" in caplog.text
+
# Create new folder as child of Novel folder
- projView.setSelectedHandle(C.hNovelRoot)
- assert projTree.newTreeItem(nwItemType.FOLDER) is True
- assert project.tree["0000000000011"].itemParent == C.hNovelRoot # type: ignore
- assert project.tree["0000000000011"].itemRoot == C.hNovelRoot # type: ignore
- assert project.tree["0000000000011"].itemClass == nwItemClass.NOVEL # type: ignore
+ hNewFolder = "0000000000012"
+ projView.setSelectedHandle(C.hNovelRoot, doScroll=True)
+ projTree.newTreeItem(nwItemType.FOLDER)
+ assert hNewFolder in tree
+ item = tree[hNewFolder]
+ assert item is not None
+ assert item.itemName == "New Folder"
+ assert item.itemParent == C.hNovelRoot
+ assert item.itemRoot == C.hNovelRoot
+ assert item.itemClass == nwItemClass.NOVEL
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Folder", "Plot", "Characters", "Locations", "Objects", "Trash",
+ ]
# Add a new file in the new folder
- projView.setSelectedHandle("0000000000011")
- assert projTree.newTreeItem(nwItemType.FILE) is True
- assert project.tree["0000000000012"].itemParent == "0000000000011" # type: ignore
- assert project.tree["0000000000012"].itemRoot == C.hNovelRoot # type: ignore
- assert project.tree["0000000000012"].itemClass == nwItemClass.NOVEL # type: ignore
+ hNewFile = "0000000000013"
+ projView.setSelectedHandle(hNewFolder, doScroll=True)
+ projTree.newTreeItem(nwItemType.FILE)
+ assert hNewFile in tree
+ item = tree[hNewFile]
+ assert item is not None
+ assert item.itemName == "New Document"
+ assert item.itemParent == hNewFolder
+ assert item.itemRoot == C.hNovelRoot
+ assert item.itemClass == nwItemClass.NOVEL
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Folder", "New Document", "Plot", "Characters", "Locations",
+ "Objects", "Trash",
+ ]
# Add a new chapter next to the other new file
- projView.setSelectedHandle("0000000000012")
- assert projTree.newTreeItem(nwItemType.FILE, hLevel=2) is True
- assert project.tree["0000000000013"].itemParent == "0000000000011" # type: ignore
- assert project.tree["0000000000013"].itemRoot == C.hNovelRoot # type: ignore
- assert project.tree["0000000000013"].itemClass == nwItemClass.NOVEL # type: ignore
- assert nwGUI.openDocument("0000000000013")
+ hNewChapter = "0000000000014"
+ projView.setSelectedHandle(hNewFile, doScroll=True)
+ projTree.newTreeItem(nwItemType.FILE, hLevel=2)
+ assert hNewChapter in tree
+ item = tree[hNewChapter]
+ assert item is not None
+ assert item.itemName == "New Chapter"
+ assert item.itemParent == hNewFolder
+ assert item.itemRoot == C.hNovelRoot
+ assert item.itemClass == nwItemClass.NOVEL
+ assert nwGUI.openDocument(hNewChapter)
assert nwGUI.docEditor.getText() == "## New Chapter\n\n"
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Folder", "New Document", "New Chapter", "Plot", "Characters",
+ "Locations", "Objects", "Trash",
+ ]
# Add a new scene next to the other new file
- projView.setSelectedHandle("0000000000012")
- assert projTree.newTreeItem(nwItemType.FILE, hLevel=3) is True
- assert project.tree["0000000000014"].itemParent == "0000000000011" # type: ignore
- assert project.tree["0000000000014"].itemRoot == C.hNovelRoot # type: ignore
- assert project.tree["0000000000014"].itemClass == nwItemClass.NOVEL # type: ignore
- assert nwGUI.openDocument("0000000000014")
+ hNewScene = "0000000000015"
+ projView.setSelectedHandle(hNewChapter, doScroll=True)
+ projTree.newTreeItem(nwItemType.FILE, hLevel=3)
+ assert hNewScene in tree
+ item = tree[hNewScene]
+ assert item is not None
+ assert item.itemName == "New Scene"
+ assert item.itemParent == hNewFolder
+ assert item.itemRoot == C.hNovelRoot
+ assert item.itemClass == nwItemClass.NOVEL
+ assert nwGUI.openDocument(hNewScene)
assert nwGUI.docEditor.getText() == "### New Scene\n\n"
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Folder", "New Document", "New Chapter", "New Scene", "Plot",
+ "Characters", "Locations", "Objects", "Trash",
+ ]
# Add a new scene with the content copied from the previous
- assert nwGUI.openDocument("0000000000014")
+ nwGUI.openDocument(hNewScene)
nwGUI.docEditor.setPlainText("### New Scene\n\nWith Stuff\n\n")
nwGUI.saveDocument()
- projView.setSelectedHandle("0000000000014")
- assert projTree.newTreeItem(nwItemType.FILE, copyDoc="0000000000014") is True
- assert project.tree["0000000000015"].itemParent == "0000000000011" # type: ignore
- assert project.tree["0000000000015"].itemRoot == C.hNovelRoot # type: ignore
- assert project.tree["0000000000015"].itemClass == nwItemClass.NOVEL # type: ignore
- assert nwGUI.openDocument("0000000000015")
+
+ hNewSceneCopy = "0000000000016"
+ projView.setSelectedHandle(hNewScene, doScroll=True)
+ projTree.newTreeItem(nwItemType.FILE, copyDoc=hNewScene)
+ assert hNewSceneCopy in tree
+ item = tree[hNewSceneCopy]
+ assert item is not None
+ assert item.itemName == "New Scene"
+ assert item.itemParent == hNewFolder
+ assert item.itemRoot == C.hNovelRoot
+ assert item.itemClass == nwItemClass.NOVEL
+ assert nwGUI.openDocument(hNewSceneCopy)
assert nwGUI.docEditor.getText() == "### New Scene\n\nWith Stuff\n\n"
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Folder", "New Document", "New Chapter", "New Scene", "New Scene",
+ "Plot", "Characters", "Locations", "Objects", "Trash",
+ ]
# Add a new file to the characters folder
- projView.setSelectedHandle(C.hCharRoot)
- assert projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) is True
- assert project.tree["0000000000016"].itemParent == C.hCharRoot # type: ignore
- assert project.tree["0000000000016"].itemRoot == C.hCharRoot # type: ignore
- assert project.tree["0000000000016"].itemClass == nwItemClass.CHARACTER # type: ignore
- assert nwGUI.openDocument("0000000000016")
+ hNewCharacter = "0000000000017"
+ projView.setSelectedHandle(C.hCharRoot, doScroll=True)
+ projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True)
+ assert hNewCharacter in tree
+ item = tree[hNewCharacter]
+ assert item is not None
+ assert item.itemName == "New Note"
+ assert item.itemParent == C.hCharRoot
+ assert item.itemRoot == C.hCharRoot
+ assert item.itemClass == nwItemClass.CHARACTER
+ assert nwGUI.openDocument(hNewCharacter)
assert nwGUI.docEditor.getText() == "# New Note\n\n"
-
- # Make sure the sibling folder bug trap works
- projView.setSelectedHandle("0000000000013")
- project.tree["0000000000013"].setParent(None) # This should not happen # type: ignore
- caplog.clear()
- assert projTree.newTreeItem(nwItemType.FILE) is False
- assert "Internal error" in caplog.text
- project.tree["0000000000013"].setParent("0000000000011") # type: ignore
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Folder", "New Document", "New Chapter", "New Scene", "New Scene",
+ "Plot", "Characters", "New Note", "Locations", "Objects", "Trash",
+ ]
# Cancel during creation
with monkeypatch.context() as mp:
mp.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("", False))
- projView.setSelectedHandle("0000000000013")
- assert projTree.newTreeItem(nwItemType.FILE) is False
+ projView.setSelectedHandle(hNewFile, doScroll=True)
+ projTree.newTreeItem(nwItemType.FILE)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Folder", "New Document", "New Chapter", "New Scene", "New Scene",
+ "Plot", "Characters", "New Note", "Locations", "Objects", "Trash",
+ ]
- # Get the trash folder
- with monkeypatch.context() as mp:
- mp.setattr(NWProject, "trashFolder", lambda *a: None)
- assert projTree._addTrashRoot() is None
+ # From Template
+ # =============
- assert isinstance(projTree._addTrashRoot(), QTreeWidgetItem)
- trashHandle = project.trashFolder()
- projView.setSelectedHandle(trashHandle)
- assert projTree.newTreeItem(nwItemType.FILE) is False
- assert "Cannot add new files or folders to the Trash folder" in caplog.text
+ # Create template folder
+ hTemplateRoot = "0000000000018"
+ projView.setSelectedHandle(hObjectRoot)
+ projTree.newTreeItem(nwItemType.ROOT, nwItemClass.TEMPLATE)
+ assert hTemplateRoot in tree
+ item = tree[hTemplateRoot]
+ assert item is not None
+ assert item.itemName == "Templates"
+ assert item.itemParent is None
+ assert item.itemRoot == hTemplateRoot
+ assert item.itemClass == nwItemClass.TEMPLATE
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Folder", "New Document", "New Chapter", "New Scene", "New Scene",
+ "Plot", "Characters", "New Note", "Locations", "Objects", "Templates",
+ "Trash",
+ ]
+
+ # Create scene template
+ hSceneTemplate = "0000000000019"
+ projView.setSelectedHandle(hTemplateRoot, doScroll=True)
+ projTree.newTreeItem(nwItemType.FILE, hLevel=3)
+ assert hSceneTemplate in tree
+ item = tree[hSceneTemplate]
+ assert item is not None
+ assert item.itemName == "New Scene"
+ assert item.itemParent == hTemplateRoot
+ assert item.itemRoot == hTemplateRoot
+ assert item.itemClass == nwItemClass.TEMPLATE
+
+ item.setName("New Scene Template")
+ nwGUI.openDocument(hSceneTemplate)
+ nwGUI.docEditor.setPlainText("### New Scene Template\n\nWith Stuff\n\n")
+ nwGUI.saveDocument()
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Folder", "New Document", "New Chapter", "New Scene", "New Scene",
+ "Plot", "Characters", "New Note", "Locations", "Objects", "Templates",
+ "New Scene Template", "Trash",
+ ]
+
+ # Create from template
+ hNewFromTemplate = "000000000001a"
+ projView.setSelectedHandle(hNewSceneCopy, doScroll=True)
+ projView.createFileFromTemplate(hSceneTemplate)
+ assert hNewFromTemplate in tree
+ item = tree[hNewFromTemplate]
+ assert item is not None
+ assert item.itemName == "New Scene Template"
+ assert item.itemParent == hNewFolder
+ assert item.itemRoot == C.hNovelRoot
+ assert item.itemClass == nwItemClass.NOVEL
+ assert nwGUI.openDocument(hNewFromTemplate)
+ assert nwGUI.docEditor.getText() == "### New Scene Template\n\nWith Stuff\n\n"
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Folder", "New Document", "New Chapter", "New Scene", "New Scene",
+ "New Scene Template", "Plot", "Characters", "New Note", "Locations",
+ "Objects", "Templates", "New Scene Template", "Trash",
+ ]
# Rename Item
# ===========
@@ -171,25 +309,11 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRn
# Rename plot folder
with monkeypatch.context() as mp:
mp.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("Stuff", True))
- projTree.renameTreeItem(C.hPlotRoot)
- assert project.tree[C.hPlotRoot].itemName == "Stuff" # type: ignore
-
- # Other Checks
- # ============
-
- # Also check error handling in reveal function
- assert projTree.revealNewTreeItem("abc") is False
-
- # Add an item that cannot be displayed in the tree
- nHandle = project.newFile("Test", None) # type: ignore
- assert projTree.revealNewTreeItem(nHandle) is False
-
- # Adding an invalid item directly to the tree should also fail
- assert projTree._addTreeItem(None) is None
-
- # Setting values for a non-existing tree item should be handled
- projTree.setTreeItemValues(None)
- projTree.setTreeItemValues(C.hInvalid) # The function used to take handles
+ projView.setSelectedHandle(C.hPlotRoot, doScroll=True)
+ projView.renameTreeItem()
+ item = tree[C.hPlotRoot]
+ assert item is not None
+ assert item.itemName == "Stuff"
# Clean up
# qtbot.stop()
@@ -197,343 +321,373 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRn
@pytest.mark.gui
-@pytest.mark.skip
-def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
- """Test adding and removing items from the project tree."""
+def testGuiProjTree_SimpleOperations(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
+ """Test simple operations in the project tree like internal move,
+ change selection and expand/collapse nodes.
+ """
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
projView = nwGUI.projView
- projTree = nwGUI.projView.projTree
+ projTree = projView.projTree
+ project = SHARED.project
+ tree = project.tree
+ model = tree.model
- # Try to move item with no project
- assert projTree.moveTreeItem(1) is False
+ # The default model is empty
+ assert projView.getSelectedHandle() is None
+ assert projTree._getModel() is None
+ assert projTree._selectedRows() == []
+ assert projTree._getNode(QModelIndex()) is None
# Create a project
buildTestProject(nwGUI, projPath)
-
- # Move Documents
- # ==============
-
- # Add some files
- projView.setSelectedHandle(C.hChapterDir)
- assert projTree.newTreeItem(nwItemType.FILE) is True
- assert projTree.newTreeItem(nwItemType.FILE) is True
- assert projTree.newTreeItem(nwItemType.FILE) is True
- assert projTree.getTreeFromHandle(C.hChapterDir) == [
- C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
- "0000000000010", "0000000000011", "0000000000012",
+ trash = tree.trash
+ assert trash is not None
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
]
- # Move with no selections
- projTree.clearSelection()
- assert projTree.moveTreeItem(1) is False
+ # Add some scenes
+ hFolder = "0000000000011"
+ hScenes = ["0000000000012", "0000000000013", "0000000000014", "0000000000015"]
+ projView.setSelectedHandle(C.hNovelRoot, doScroll=True)
+ projTree.newTreeItem(nwItemType.FOLDER)
+ assert hFolder in tree
+ item = tree[hFolder]
+ assert item is not None
+ item.setName("Chapter Folder")
- # Move second item up twice (should give same result)
- projView.setSelectedHandle(C.hSceneDoc)
- assert projTree.moveTreeItem(-1) is True
- assert projTree.getTreeFromHandle(C.hChapterDir) == [
- C.hChapterDir, C.hSceneDoc, C.hChapterDoc,
- "0000000000010", "0000000000011", "0000000000012",
- ]
- assert projTree.moveTreeItem(-1) is False
- assert projTree.getTreeFromHandle(C.hChapterDir) == [
- C.hChapterDir, C.hSceneDoc, C.hChapterDoc,
- "0000000000010", "0000000000011", "0000000000012",
+ projView.setSelectedHandle(hFolder, doScroll=True)
+ for n, hScene in enumerate(hScenes):
+ projTree.newTreeItem(nwItemType.FILE, hLevel=3)
+ assert hScene in tree
+ item = tree[hScene]
+ assert item is not None
+ item.setName(f"Scene {n+1}")
+
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter Folder", "Scene 1", "Scene 2", "Scene 3", "Scene 4",
+ "Plot", "Characters", "Locations", "Trash",
]
- # Restore
- assert projTree.moveTreeItem(1) is True
- assert projTree.getTreeFromHandle(C.hChapterDir) == [
- C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
- "0000000000010", "0000000000011", "0000000000012",
+ # Expansion
+ # =========
+
+ # Default State
+ assert [n.item.itemHandle for n in [model.node(i) for i in model.allExpanded()] if n] == [
+ C.hNovelRoot, hFolder
]
- # Move fifth item down twice (should give same result)
- projView.setSelectedHandle("0000000000011")
- assert projTree.moveTreeItem(1) is True
- assert projTree.getTreeFromHandle(C.hChapterDir) == [
- C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
- "0000000000010", "0000000000012", "0000000000011",
- ]
- assert projTree.moveTreeItem(1) is False
- assert projTree.getTreeFromHandle(C.hChapterDir) == [
- C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
- "0000000000010", "0000000000012", "0000000000011",
+ # Expand Novel
+ projTree.expandFromIndex(model.indexFromHandle(C.hNovelRoot))
+ assert [n.item.itemHandle for n in [model.node(i) for i in model.allExpanded()] if n] == [
+ C.hNovelRoot, C.hChapterDir, hFolder
]
- # Restore
- assert projTree.moveTreeItem(-1) is True
- assert projTree.getTreeFromHandle(C.hChapterDir) == [
- C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
- "0000000000010", "0000000000011", "0000000000012",
+ # Collapse Novel
+ projTree.collapseFromIndex(model.indexFromHandle(C.hNovelRoot))
+ assert [n.item.itemHandle for n in [model.node(i) for i in model.allExpanded()] if n] == [
+ C.hNovelRoot
]
- # Move down again
- projView.setSelectedHandle("0000000000011")
- assert projTree.moveTreeItem(1) is True
- assert projTree.getTreeFromHandle(C.hChapterDir) == [
- C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
- "0000000000010", "0000000000012", "0000000000011",
+ # Move Up
+ # =======
+
+ # Already first
+ projView.setSelectedHandle(hScenes[0], doScroll=True)
+ projTree.moveItemUp()
+ assert [n.item.itemName for n in tree.model.root.allChildren()][5:10] == [
+ "Chapter Folder", "Scene 1", "Scene 2", "Scene 3", "Scene 4",
]
- # Root Folder
- # ===========
+ # Proper move
+ projView.setSelectedHandle(hScenes[1], doScroll=True)
+ projTree.moveItemUp()
+ assert [n.item.itemName for n in tree.model.root.allChildren()][5:10] == [
+ "Chapter Folder", "Scene 2", "Scene 1", "Scene 3", "Scene 4",
+ ]
- projView.setSelectedHandle(C.hNovelRoot)
- assert SHARED.project.tree._order.index(C.hNovelRoot) == 0
+ # Move Down
+ # =========
- # Move novel folder up
- assert projTree.moveTreeItem(-1) is False
- assert SHARED.project.tree._order.index(C.hNovelRoot) == 0
+ # Already last
+ projView.setSelectedHandle(hScenes[3], doScroll=True)
+ projTree.moveItemDown()
+ assert [n.item.itemName for n in tree.model.root.allChildren()][5:10] == [
+ "Chapter Folder", "Scene 2", "Scene 1", "Scene 3", "Scene 4",
+ ]
- # Move novel folder down
- assert projTree.moveTreeItem(1) is True
- assert SHARED.project.tree._order.index(C.hNovelRoot) == 1
+ # Proper move
+ projView.setSelectedHandle(hScenes[1], doScroll=True)
+ projTree.moveItemDown()
+ assert [n.item.itemName for n in tree.model.root.allChildren()][5:10] == [
+ "Chapter Folder", "Scene 1", "Scene 2", "Scene 3", "Scene 4",
+ ]
- # Move novel folder up again
- assert projTree.moveTreeItem(-1) is True
- assert SHARED.project.tree._order.index(C.hNovelRoot) == 0
+ # Select Up
+ # =========
- # Clean up
- # qtbot.stop()
- nwGUI.closeProject()
+ # Sibling
+ projView.setSelectedHandle(hFolder, doScroll=True)
+ assert projView.getSelectedHandle() == hFolder
+ projTree.goToSiblingUp()
+ assert projView.getSelectedHandle() == C.hChapterDir
+ projTree.goToSiblingUp()
+ assert projView.getSelectedHandle() == C.hTitlePage
+ projTree.goToSiblingUp()
+ assert projView.getSelectedHandle() == C.hTitlePage
+
+ # Parent
+ projTree.goToParent()
+ assert projView.getSelectedHandle() == C.hNovelRoot
+ projTree.goToParent()
+ assert projView.getSelectedHandle() == C.hNovelRoot
+
+ # Select Down
+ # =========
+
+ # Child
+ projView.setSelectedHandle(C.hNovelRoot, doScroll=True)
+ assert projView.getSelectedHandle() == C.hNovelRoot
+ projTree.goToFirstChild()
+ assert projView.getSelectedHandle() == C.hTitlePage
+
+ # Sibling
+ projTree.goToSiblingDown()
+ assert projView.getSelectedHandle() == C.hChapterDir
+ projTree.goToSiblingDown()
+ assert projView.getSelectedHandle() == hFolder
+ projTree.goToSiblingDown()
+ assert projView.getSelectedHandle() == hFolder
@pytest.mark.gui
-@pytest.mark.skip
-def testGuiProjTree_RequestDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
- """Test external requests for removing items from project tree."""
+def testGuiProjTree_MouseClicks(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
+ """Test mouse clicks in the project tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
projView = nwGUI.projView
- projTree = nwGUI.projView.projTree
+ projTree = projView.projTree
+ project = SHARED.project
+ tree = project.tree
+ model = tree.model
- # Try to run with no project
- assert projView.requestDeleteItem() is False
+ # The default model is empty
+ assert projView.getSelectedHandle() is None
+ assert projTree._getModel() is None
+ assert projTree._selectedRows() == []
+ assert projTree._getNode(QModelIndex()) is None
# Create a project
buildTestProject(nwGUI, projPath)
+ trash = tree.trash
+ assert trash is not None
+ assert len(tree) == 9
- # Try emptying the trash already now, when there is no trash folder
- assert projView.emptyTrash() is False
+ # Single click emits a signal
+ with qtbot.waitSignal(projView.selectedItemChanged) as signal:
+ projTree._onSingleClick(model.indexFromHandle(C.hNovelRoot))
+ assert signal.args[0] == C.hNovelRoot
- # Add some files
- projView.setSelectedHandle(C.hChapterDir)
- assert projTree.newTreeItem(nwItemType.FILE) is True
- assert projTree.newTreeItem(nwItemType.FILE) is True
- assert projTree.newTreeItem(nwItemType.FILE) is True
- assert projTree.getTreeFromHandle(C.hChapterDir) == [
- C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
- "0000000000010", "0000000000011", "0000000000012",
+ # Double click on folder expands/collapses it
+ assert [n.item.itemHandle for n in [model.node(i) for i in model.allExpanded()] if n] == []
+ projTree._onDoubleClick(model.indexFromHandle(C.hNovelRoot))
+ assert [n.item.itemHandle for n in [model.node(i) for i in model.allExpanded()] if n] == [
+ C.hNovelRoot
]
+ projTree._onDoubleClick(model.indexFromHandle(C.hNovelRoot))
+ assert [n.item.itemHandle for n in [model.node(i) for i in model.allExpanded()] if n] == []
- # Delete item without focus -> blocked
- monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: False)
- projView.setSelectedHandle("0000000000012")
- assert projView.requestDeleteItem() is False
- monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True)
+ # Double click on file opens it
+ with qtbot.waitSignal(projView.openDocumentRequest) as signal:
+ projTree._onDoubleClick(model.indexFromHandle(C.hChapterDoc))
+ assert signal.args[0] == C.hChapterDoc
+ assert signal.args[1] == nwDocMode.EDIT
+ assert signal.args[2] == ""
+ assert signal.args[3] is True
- # No selection made
- projTree.clearSelection()
- caplog.clear()
- assert projView.requestDeleteItem() is False
- assert "no item to delete" in caplog.text
-
- # Not a valid handle
- projTree.clearSelection()
- caplog.clear()
- assert projView.requestDeleteItem("0000000000000") is False
- assert "No tree item with handle '0000000000000'" in caplog.text
-
- # Delete Root Folders
+ # Mouse Button Clicks
# ===================
- assert projView.requestDeleteItem(C.hNovelRoot) is False # Novel Root is blocked
- assert projView.requestDeleteItem(C.hCharRoot) is True # Character Root
+ eType = QEvent.Type.MouseButtonPress
+ modifier = QtModNone
- # Delete File
- # ===========
+ # Trigger the viewer
+ pos = projTree.visualRect(model.indexFromHandle(C.hChapterDoc)).center()
+ button = QtMouseMiddle
+ event = QMouseEvent(eType, pos, button, button, modifier)
+ projTree.mousePressEvent(event)
+ assert nwGUI.docViewer.docHandle == C.hChapterDoc
- # Block adding trash folder
- funcPointer = projTree._addTrashRoot
- projTree._addTrashRoot = lambda *a: None
- assert projView.requestDeleteItem("0000000000012") is False
- projTree._addTrashRoot = funcPointer
+ # Trigger the left click clear
+ pos = QPoint(5000, 5000)
+ button = QtMouseLeft
+ event = QMouseEvent(eType, pos, button, button, modifier)
- # Delete last two documents, which also adds the trash folder
- assert projView.requestDeleteItem("0000000000012") is True
- assert projView.requestDeleteItem("0000000000011") is True
- assert projTree.getTreeFromHandle(C.hChapterDir) == [
- C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
- "0000000000010"
- ]
- trashHandle = SHARED.project.tree.trashRoot
- assert projTree.getTreeFromHandle(trashHandle) == [
- trashHandle, "0000000000012", "0000000000011"
+ projTree.setSelectedHandle(C.hChapterDoc)
+ assert projView.getSelectedHandle() == C.hChapterDoc
+
+ projTree.mousePressEvent(event)
+ assert projView.getSelectedHandle() is None
+
+
+@pytest.mark.gui
+def testGuiProjTree_DeleteRequest(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
+ """Test delete requests in the project tree."""
+ monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
+
+ projView = nwGUI.projView
+ projTree = projView.projTree
+ project = SHARED.project
+ tree = project.tree
+
+ # Create a project
+ buildTestProject(nwGUI, projPath)
+ trash = tree.trash
+ assert trash is not None
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
]
- # Try to delete the trash folder
- caplog.clear()
- assert projView.requestDeleteItem("0000000000013") is False
- assert "Cannot delete the Trash folder" in caplog.text
+ # Add some scenes
+ hFolder = "0000000000011"
+ hScenes = ["0000000000012", "0000000000013", "0000000000014", "0000000000015"]
+ projView.setSelectedHandle(C.hNovelRoot, doScroll=True)
+ projTree.newTreeItem(nwItemType.FOLDER)
+ assert hFolder in tree
+ item = tree[hFolder]
+ assert item is not None
+ item.setName("Chapter Folder")
- nwGUI.closeProject()
+ projView.setSelectedHandle(hFolder, doScroll=True)
+ for n, hScene in enumerate(hScenes):
+ projTree.newTreeItem(nwItemType.FILE, hLevel=3)
+ assert hScene in tree
+ item = tree[hScene]
+ assert item is not None
+ item.setName(f"Scene {n+1}")
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter Folder", "Scene 1", "Scene 2", "Scene 3", "Scene 4",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
-@pytest.mark.gui
-@pytest.mark.skip
-def testGuiProjTree_MoveItemToTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
- """Test moving items to Trash."""
- monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
+ # Calling it with no selection, does nothing
+ projTree._clearSelection()
+ projTree.processDeleteRequest()
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter Folder", "Scene 1", "Scene 2", "Scene 3", "Scene 4",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
- project = SHARED.project
- projTree = nwGUI.projView.projTree
+ # Deleting a used root raises an error
+ projTree.processDeleteRequest([C.hNovelRoot])
+ assert SHARED.lastAlert == "Root folders can only be deleted when they are empty."
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter Folder", "Scene 1", "Scene 2", "Scene 3", "Scene 4",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
- # Create a project
- buildTestProject(nwGUI, projPath)
+ # Deleting a unused root is fine
+ projTree.processDeleteRequest([C.hWorldRoot])
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter Folder", "Scene 1", "Scene 2", "Scene 3", "Scene 4",
+ "Plot", "Characters", "Trash",
+ ]
- # Invalid item
- caplog.clear()
- assert projTree.moveItemToTrash(C.hInvalid) is False
- assert "Could not find tree item for deletion" in caplog.text
-
- # Root folders cannot be moved to Trash
- caplog.clear()
- assert projTree.moveItemToTrash(C.hNovelRoot) is False
- assert "Root folders cannot be moved to Trash" in caplog.text
-
- # Block adding trash folder
- funcPointer = projTree._addTrashRoot
- projTree._addTrashRoot = lambda *a: None
-
- caplog.clear()
- assert projTree.moveItemToTrash(C.hTitlePage) is False
- assert project.tree.isTrash(C.hTitlePage) is False
- assert "Could not delete item" in caplog.text
-
- projTree._addTrashRoot = funcPointer
-
- # User cancels action
+ # User can cancel move to trash
with monkeypatch.context() as mp:
mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
- assert projTree.moveItemToTrash(C.hTitlePage) is False
- assert project.tree.isTrash(C.hTitlePage) is False
+ projTree.processDeleteRequest(hScenes, askFirst=True)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter Folder", "Scene 1", "Scene 2", "Scene 3", "Scene 4",
+ "Plot", "Characters", "Trash",
+ ]
- # Move a document to Trash
- assert projTree.moveItemToTrash(C.hTitlePage) is True
- assert project.tree.isTrash(C.hTitlePage) is True
+ # Items not already in trash can be moved there
+ projTree.processDeleteRequest(hScenes, askFirst=True)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter Folder", "Plot", "Characters", "Trash",
+ "Scene 1", "Scene 2", "Scene 3", "Scene 4",
+ ]
- # Cannot be moved again
- caplog.clear()
- assert projTree.moveItemToTrash(C.hTitlePage) is False
- assert "Item is already in the Trash folder" in caplog.text
-
- nwGUI.closeProject()
-
-
-@pytest.mark.gui
-@pytest.mark.skip
-def testGuiProjTree_PermanentlyDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
- """Test permanently deleting items."""
- monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
-
- project = SHARED.project
- projTree = nwGUI.projView.projTree
-
- # Create a project
- buildTestProject(nwGUI, projPath)
-
- # Invalid item
- caplog.clear()
- assert projTree.permDeleteItem(C.hInvalid) is False
- assert "Could not find tree item for deletion" in caplog.text
-
- # Not deleting root item in use
- caplog.clear()
- assert projTree.permDeleteItem(C.hNovelRoot) is False
- assert "Root folders can only be deleted when they are empty" in caplog.text
- assert C.hNovelRoot in project.tree
-
- # Deleting unused root item is allowed
- caplog.clear()
- assert projTree.permDeleteItem(C.hPlotRoot) is True
- assert C.hPlotRoot not in project.tree
-
- # User cancels action
+ # User can block permanent deletion
with monkeypatch.context() as mp:
mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
- assert projTree.permDeleteItem(C.hTitlePage) is False
- assert C.hTitlePage in project.tree
+ projTree.processDeleteRequest(hScenes[0:2], askFirst=True)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter Folder", "Plot", "Characters", "Trash",
+ "Scene 1", "Scene 2", "Scene 3", "Scene 4",
+ ]
- # Deleting file is OK, and if it is open, it should close
- assert nwGUI.openDocument(C.hTitlePage) is True
- assert nwGUI.docEditor.docHandle == C.hTitlePage
- assert projTree.permDeleteItem(C.hTitlePage) is True
- assert C.hTitlePage not in project.tree
- assert nwGUI.docEditor.docHandle is None
+ # Items in trash can be permanently deleted
+ projTree.processDeleteRequest(hScenes[0:2], askFirst=True)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter Folder", "Plot", "Characters", "Trash",
+ "Scene 3", "Scene 4",
+ ]
- # Deleting folder + files recursively is ok
- assert projTree.permDeleteItem(C.hChapterDir) is True
- assert C.hChapterDir not in project.tree
- assert C.hChapterDoc not in project.tree
- assert C.hSceneDoc not in project.tree
+ # Moving a parent item to trash, includes children
+ projTree.processDeleteRequest([C.hChapterDir], askFirst=True)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "Chapter Folder", "Plot", "Characters", "Trash",
+ "Scene 3", "Scene 4", "New Folder", "New Chapter", "New Scene",
+ ]
- nwGUI.closeProject()
+ # Permanently delete in trash is recursive
+ projTree.processDeleteRequest([C.hChapterDir], askFirst=True)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "Chapter Folder", "Plot", "Characters", "Trash",
+ "Scene 3", "Scene 4",
+ ]
-
-@pytest.mark.gui
-@pytest.mark.skip
-def testGuiProjTree_EmptyTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
- """Test emptying Trash."""
- monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
-
- project = SHARED.project
- projTree = nwGUI.projView.projTree
-
- # No project open
- caplog.clear()
- assert projTree.emptyTrash() is False
- assert "No project open" in caplog.text
-
- # Create a project
- buildTestProject(nwGUI, projPath)
-
- # No Trash folder
- assert projTree.emptyTrash() is False
-
- # Move some documents to Trash
- assert projTree.moveItemToTrash(C.hTitlePage) is True
- assert projTree.moveItemToTrash(C.hChapterDir) is True
-
- assert project.tree.isTrash(C.hTitlePage) is True
- assert project.tree.isTrash(C.hChapterDir) is True
- assert project.tree.isTrash(C.hChapterDoc) is True
- assert project.tree.isTrash(C.hSceneDoc) is True
-
- # User cancels
+ # Trash can be completely emptied, but user can block it
with monkeypatch.context() as mp:
mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
- assert projTree.emptyTrash() is False
- assert C.hTitlePage in project.tree
- assert C.hChapterDir in project.tree
- assert C.hChapterDoc in project.tree
- assert C.hSceneDoc in project.tree
+ projTree.emptyTrash()
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "Chapter Folder", "Plot", "Characters", "Trash",
+ "Scene 3", "Scene 4",
+ ]
- # Run again to empty all items
- assert projTree.emptyTrash() is True
- assert C.hTitlePage not in project.tree
- assert C.hChapterDir not in project.tree
- assert C.hChapterDoc not in project.tree
- assert C.hSceneDoc not in project.tree
+ # Trash can be completely emptied
+ projTree.emptyTrash()
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "Chapter Folder", "Plot", "Characters", "Trash",
+ ]
- # Running Empty Trash again is cancelled due to empty folder
- assert projTree.emptyTrash() is False
+ # Emptying empty trash pops an alert
+ projTree.emptyTrash()
+ assert SHARED.lastAlert == "The Trash folder is already empty."
- nwGUI.closeProject()
+ # Trash can be deleted if empty
+ projTree.processDeleteRequest([trash.item.itemHandle])
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "Chapter Folder", "Plot", "Characters"
+ ]
+
+ # Emptying trash when it doesn't exist, recreates it
+ projTree.emptyTrash()
+ assert SHARED.lastAlert == "The Trash folder is already empty."
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "Chapter Folder", "Plot", "Characters", "Trash",
+ ]
@pytest.mark.gui
-@pytest.mark.skip
def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText):
"""Test the merge document function."""
mergeData = {}
@@ -544,18 +698,33 @@ def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
monkeypatch.setattr(GuiDocMerge, "result", lambda *a: QtAccepted)
monkeypatch.setattr(GuiDocMerge, "data", lambda *a: mergeData)
- buildTestProject(nwGUI, projPath)
-
+ projView = nwGUI.projView
+ projTree = projView.projTree
project = SHARED.project
- projTree = nwGUI.projView.projTree
+ tree = project.tree
- mergedDoc1 = "0000000000014"
+ # Create a project
+ buildTestProject(nwGUI, projPath)
+ trash = tree.trash
+ assert trash is not None
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
# Create File to Merge
hChapter1 = project.newFile("Chapter 1", C.hNovelRoot)
- hSceneOne11 = project.newFile("Scene 1.1", hChapter1) # type: ignore
- hSceneOne12 = project.newFile("Scene 1.2", hChapter1) # type: ignore
- hSceneOne13 = project.newFile("Scene 1.3", hChapter1) # type: ignore
+ assert hChapter1 == "0000000000011"
+
+ hSceneOne11 = project.newFile("Scene 1.1", hChapter1)
+ hSceneOne12 = project.newFile("Scene 1.2", hChapter1)
+ hSceneOne13 = project.newFile("Scene 1.3", hChapter1)
+ assert hSceneOne11 == "0000000000012"
+ assert hSceneOne12 == "0000000000013"
+ assert hSceneOne13 == "0000000000014"
+
+ mergedDoc1 = "0000000000015"
docText1 = "\n\n".join(ipsumText[0:2]) + "\n\n"
docText2 = "\n\n".join(ipsumText[1:3]) + "\n\n"
@@ -568,25 +737,26 @@ def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
lenText4 = len(docText4)
lenAll = lenText1 + lenText2 + lenText3 + lenText4
- project.writeNewFile(hChapter1, 2, True, docText1) # type: ignore
- project.writeNewFile(hSceneOne11, 3, True, docText2) # type: ignore
- project.writeNewFile(hSceneOne12, 3, True, docText3) # type: ignore
- project.writeNewFile(hSceneOne13, 3, True, docText4) # type: ignore
+ project.writeNewFile(hChapter1, 2, True, docText1)
+ project.writeNewFile(hSceneOne11, 3, True, docText2)
+ project.writeNewFile(hSceneOne12, 3, True, docText3)
+ project.writeNewFile(hSceneOne13, 3, True, docText4)
- projTree.revealNewTreeItem(hChapter1)
- projTree.revealNewTreeItem(hSceneOne11)
- projTree.revealNewTreeItem(hSceneOne12)
- projTree.revealNewTreeItem(hSceneOne13)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter 1", "Scene 1.1", "Scene 1.2", "Scene 1.3",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
# Invalid file handle
- assert projTree._mergeDocuments(C.hInvalid, False) is False
+ assert projTree.mergeDocuments(C.hInvalid, False) is False
# Cannot merge root item
- assert projTree._mergeDocuments(C.hNovelRoot, False) is False
+ assert projTree.mergeDocuments(C.hNovelRoot, False) is False
# Merge to new file, but there is now merge data
mergeData.clear()
- assert projTree._mergeDocuments(hChapter1, True) is False
+ assert projTree.mergeDocuments(hChapter1, True) is False
# Merge to New Doc
# ================
@@ -598,11 +768,18 @@ def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
# User cancels merge
with monkeypatch.context() as mp:
mp.setattr(GuiDocMerge, "result", lambda *a: QtRejected)
- assert projTree._mergeDocuments(hChapter1, True) is False
+ assert projTree.mergeDocuments(hChapter1, True) is False
# The merge goes through
- assert projTree._mergeDocuments(hChapter1, True) is True
- assert len(project.storage.getDocument(mergedDoc1).readDocument()) > lenAll # type: ignore
+ assert projTree.mergeDocuments(hChapter1, True) is True
+ mergedText = project.storage.getDocument(mergedDoc1).readDocument()
+ assert mergedText is not None
+ assert len(mergedText) > lenAll
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter 1", "Scene 1.1", "Scene 1.2", "Scene 1.3", "[Merged] Chapter 1",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
# Merge to Existing Doc
# =====================
@@ -612,28 +789,34 @@ def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
mergeData["moveToTrash"] = False
# Merging to a folder is not allowed
- assert projTree._mergeDocuments(C.hChapterDir, False) is False
+ assert projTree.mergeDocuments(C.hChapterDir, False) is False
# Block writing and check error handling
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
- assert projTree._mergeDocuments(hChapter1, False) is False
+ assert projTree.mergeDocuments(hChapter1, False) is False
# Successful merge, and move to trash
mergeData["moveToTrash"] = True
- assert len(project.storage.getDocument(hChapter1).readDocument()) < lenAll # type: ignore
- assert projTree._mergeDocuments(hChapter1, False) is True
- assert len(project.storage.getDocument(hChapter1).readDocument()) > lenAll # type: ignore
+ mergedText = project.storage.getDocument(hChapter1).readDocument()
+ assert mergedText is not None
+ assert len(mergedText) < lenAll
+ assert projTree.mergeDocuments(hChapter1, False) is True
+ mergedText = project.storage.getDocument(hChapter1).readDocument()
+ assert mergedText is not None
+ assert len(mergedText) > lenAll
- assert project.tree.isTrash(hSceneOne11) # type: ignore
- assert project.tree.isTrash(hSceneOne12) # type: ignore
- assert project.tree.isTrash(hSceneOne13) # type: ignore
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Chapter 1", "[Merged] Chapter 1",
+ "Plot", "Characters", "Locations", "Trash",
+ "Scene 1.1", "Scene 1.2", "Scene 1.3",
+ ]
# qtbot.stop()
@pytest.mark.gui
-@pytest.mark.skip
def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText):
"""Test the split document function."""
splitData = {}
@@ -645,11 +828,20 @@ def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
monkeypatch.setattr(GuiDocSplit, "result", lambda *a: QtAccepted)
monkeypatch.setattr(GuiDocSplit, "data", lambda *a: (splitData, splitText))
+ projView = nwGUI.projView
+ projTree = projView.projTree
+ project = SHARED.project
+ tree = project.tree
+
# Create a project
buildTestProject(nwGUI, projPath)
-
- project = SHARED.project
- projTree = nwGUI.projView.projTree
+ trash = tree.trash
+ assert trash is not None
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
docText = (
"Text\n\n"
@@ -667,8 +859,8 @@ def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
)
hSplitDoc = project.newFile("Split Doc", C.hNovelRoot)
- project.writeNewFile(hSplitDoc, 1, True, docText) # type: ignore
- projTree.revealNewTreeItem(hSplitDoc, nHandle=C.hNovelRoot, wordCount=True)
+ assert hSplitDoc is not None
+ project.writeNewFile(hSplitDoc, 1, True, docText)
docText = f"# Split Doc\n\n{docText}"
splitData["headerList"] = [
@@ -687,24 +879,24 @@ def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
]
fstSet = [
- "0000000000011", "0000000000012", "0000000000013", "0000000000014",
- "0000000000015", "0000000000016", "0000000000017", "0000000000018",
- "0000000000019", "000000000001a", "000000000001b", "000000000001c",
+ "0000000000012", "0000000000013", "0000000000014", "0000000000015",
+ "0000000000016", "0000000000017", "0000000000018", "0000000000019",
+ "000000000001a", "000000000001b", "000000000001c", "000000000001d",
]
sndSet = [
- "000000000001d", "000000000001e", "000000000001f", "0000000000020",
- "0000000000021", "0000000000022", "0000000000023", "0000000000024",
- "0000000000025", "0000000000026", "0000000000027", "0000000000028",
+ "000000000001e", "000000000001f", "0000000000020", "0000000000021",
+ "0000000000022", "0000000000023", "0000000000024", "0000000000025",
+ "0000000000026", "0000000000027", "0000000000028", "0000000000029",
]
trdSet = [
- "000000000002a", "000000000002b", "000000000002c", "000000000002d",
- "000000000002e", "000000000002f", "0000000000030", "0000000000031",
- "0000000000032", "0000000000033", "0000000000034", "0000000000035",
+ "000000000002b", "000000000002c", "000000000002d", "000000000002e",
+ "000000000002f", "0000000000030", "0000000000031", "0000000000032",
+ "0000000000033", "0000000000034", "0000000000035", "0000000000036",
]
# Try to split an invalid document and a non-document
- assert projTree._splitDocument(C.hInvalid) is False
- assert projTree._splitDocument(C.hNovelRoot) is False
+ assert projTree.splitDocument(C.hInvalid) is False
+ assert projTree.splitDocument(C.hNovelRoot) is False
# Split into same root folder
splitData["intoFolder"] = False
@@ -712,13 +904,13 @@ def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
# Writing fails
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
- assert projTree._splitDocument(hSplitDoc) is True
+ assert projTree.splitDocument(hSplitDoc) is True
for tHandle in fstSet:
assert tHandle in project.tree
assert not (projPath / "content" / f"{tHandle}.nwd").is_file()
# Writing succeeds
- assert projTree._splitDocument(hSplitDoc) is True
+ assert projTree.splitDocument(hSplitDoc) is True
for tHandle in sndSet:
assert tHandle in project.tree
assert (projPath / "content" / f"{tHandle}.nwd").is_file()
@@ -726,240 +918,95 @@ def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
# Add to a folder and move source to trash
splitData["intoFolder"] = True
splitData["moveToTrash"] = True
- assert projTree._splitDocument(hSplitDoc) is True
+ assert projTree.splitDocument(hSplitDoc) is True
assert "0000000000029" in project.tree # The folder
for tHandle in trdSet:
assert tHandle in project.tree
assert (projPath / "content" / f"{tHandle}.nwd").is_file()
- assert project.tree.isTrash(hSplitDoc) is True # type: ignore
+ assert trash.allChildren() == [tree.nodes[hSplitDoc]]
# Cancelled by user
with monkeypatch.context() as mp:
mp.setattr(GuiDocSplit, "result", lambda *a: QtRejected)
- assert projTree._splitDocument(hSplitDoc) is False
+ assert projTree.splitDocument(hSplitDoc) is False
# qtbot.stop()
@pytest.mark.gui
-@pytest.mark.skip
-def testGuiProjTree_Duplicate(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
+def testGuiProjTree_Duplicate(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test the duplicate items function."""
# Create a project
- buildTestProject(nwGUI, projPath)
- assert len(SHARED.project.tree) == 8
+ projView = nwGUI.projView
+ projTree = projView.projTree
+ project = SHARED.project
+ tree = project.tree
+
+ # Create a project
+ buildTestProject(nwGUI, projPath)
+ trash = tree.trash
+ assert trash is not None
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
- projTree = nwGUI.projView.projTree
projTree.expandAll()
# Nothing to do
- assert projTree.duplicateFromHandle(C.hInvalid) is False
- assert len(SHARED.project.tree) == 8
+ projTree.duplicateFromHandle(C.hInvalid)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
# Duplicate title page, but select no
with monkeypatch.context() as mp:
- mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
- assert projTree.duplicateFromHandle(C.hTitlePage) is False
- assert len(SHARED.project.tree) == 8
-
- # Duplicate title page
- assert projTree.duplicateFromHandle(C.hTitlePage) is True
- assert len(SHARED.project.tree) == 9
-
- # Duplicate folder
- assert projTree.duplicateFromHandle(C.hChapterDir) is True
- assert len(SHARED.project.tree) == 12
-
- # Duplicate novel root
- assert projTree.duplicateFromHandle(C.hNovelRoot) is True
- assert len(SHARED.project.tree) == 21
-
- # Check tree order that all items are next to each other
- assert SHARED.project.tree._order == [
- C.hNovelRoot, C.hTitlePage, "0000000000010", C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
- "0000000000011", "0000000000012", "0000000000013", "0000000000014", "0000000000015",
- "0000000000016", "0000000000017", "0000000000018", "0000000000019", "000000000001a",
- "000000000001b", "000000000001c", C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
+ mp.setattr(QMessageBox, "result", lambda *a: QtRejected)
+ projTree.duplicateFromHandle(C.hTitlePage)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
]
- # Make the duplicator stop early
- content = SHARED.project.storage.contentPath
- assert isinstance(content, Path)
- (content / "000000000001e.nwd").touch()
- assert (content / "000000000001e.nwd").exists()
+ # Duplicate title page
+ projTree.duplicateFromHandle(C.hTitlePage)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "Title Page", "New Folder", "New Chapter",
+ "New Scene", "Plot", "Characters", "Locations", "Trash",
+ ]
- # Should only create the folder, and skip the two files because the
- # next handle is already a file
- assert projTree.duplicateFromHandle(C.hChapterDir) is True
- assert len(SHARED.project.tree) == 22
+ # Duplicate folder
+ projTree.duplicateFromHandle(C.hChapterDir)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "Title Page", "New Folder", "New Chapter",
+ "New Scene", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
+
+ # Duplicate novel root
+ projTree.duplicateFromHandle(C.hNovelRoot)
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "Title Page", "New Folder", "New Chapter",
+ "New Scene", "New Folder", "New Chapter", "New Scene",
+ "Novel", "Title Page", "Title Page", "New Folder", "New Chapter",
+ "New Scene", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
# qtbot.stop()
@pytest.mark.gui
-@pytest.mark.skip
-def testGuiProjTree_AutoScroll(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
- """Test the auto scroll feature."""
- buildTestProject(nwGUI, projPath)
- projTree: GuiProjectTree = nwGUI.projView.projTree
-
- monkeypatch.setattr(GuiEditLabel, "getLabel", lambda parent, text: (text, True))
- monkeypatch.setattr(QTreeWidget, "dragMoveEvent", lambda *a, **k: None)
- monkeypatch.setattr(QTimer, "isActive", lambda *a: False)
- monkeypatch.setattr(QTimer, "start", lambda *a: None)
-
- projTree.setSelectedHandle(C.hChapterDir, True)
- projTree.expandAll()
- for i in range(100):
- projTree.newTreeItem(nwItemType.FILE, None, 3, False)
-
- projTree.setSelectedHandle("0000000000015", True)
- nwGUI.resize(500, 500)
-
- action = Qt.DropAction.MoveAction
- mime = QMimeData()
- mouse = QtMouseLeft
- modifier = QtModNone
-
- # Scroll Down
- h = projTree.height()
- projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, h-1), action, mime, mouse, modifier))
- assert projTree._scrollDirection == 1
- projTree._doAutoScroll()
- assert projTree._scrollDirection == 0
- projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, h-2), action, mime, mouse, modifier))
- assert projTree._scrollDirection == 1
- projTree._doAutoScroll()
- assert projTree._scrollDirection == 0
- projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, h-3), action, mime, mouse, modifier))
- assert projTree._scrollDirection == 1
- projTree._doAutoScroll()
- assert projTree._scrollDirection == 0
- projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, h-4), action, mime, mouse, modifier))
- assert projTree._scrollDirection == 1
- projTree._doAutoScroll()
- assert projTree._scrollDirection == 0
-
- # Scroll Up
- projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, 1), action, mime, mouse, modifier))
- assert projTree._scrollDirection == -1
- projTree._doAutoScroll()
- assert projTree._scrollDirection == 0
- projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, 2), action, mime, mouse, modifier))
- assert projTree._scrollDirection == -1
- projTree._doAutoScroll()
- assert projTree._scrollDirection == 0
- projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, 3), action, mime, mouse, modifier))
- assert projTree._scrollDirection == -1
- projTree._doAutoScroll()
- assert projTree._scrollDirection == 0
- projTree.dragMoveEvent(QDragMoveEvent(QPoint(1, 4), action, mime, mouse, modifier))
- assert projTree._scrollDirection == -1
- projTree._doAutoScroll()
- assert projTree._scrollDirection == 0
-
- # qtbot.stop()
-
-
-@pytest.mark.gui
-@pytest.mark.skip
-def testGuiProjTree_DragAndDrop(qtbot, monkeypatch, caplog, nwGUI: GuiMain, projPath, mockRnd):
- """Test the auto scroll feature."""
- buildTestProject(nwGUI, projPath)
- projTree: GuiProjectTree = nwGUI.projView.projTree
-
- monkeypatch.setattr(QTreeWidget, "dropEvent", lambda *a, **k: None)
-
- projTree.setSelectedHandle(C.hSceneDoc, True)
- projTree._getTreeItem(C.hChapterDir).setExpanded(True) # type: ignore
-
- nPos = projTree.visualItemRect(projTree._getTreeItem(C.hNovelRoot)).bottomLeft()
- action = Qt.DropAction.MoveAction
- mime = QMimeData()
- mouse = QtMouseLeft
- modifier = QtModNone
-
- projTree.saveTreeOrder()
- treeOrder = SHARED.project.tree._order
-
- # Move an item, but no selection
- event = QDropEvent(nPos, action, mime, mouse, modifier)
- projTree.dropEvent(event)
- projTree.saveTreeOrder()
- assert SHARED.project.tree._order == treeOrder
-
- # Invalid location
- caplog.clear()
- event = QDropEvent(QPoint(1000, 1000), action, mime, mouse, modifier)
- projTree.dropEvent(event)
- assert event.isAccepted() is False
- assert "Invalid drop location" in caplog.text
- projTree.saveTreeOrder()
- assert SHARED.project.tree._order == treeOrder
-
- # Root item selected
- caplog.clear()
- event = QDropEvent(nPos, action, mime, mouse, modifier)
- projTree.clearSelection()
- projTree._getTreeItem(C.hTitlePage).setSelected(True) # type: ignore
- projTree._getTreeItem(C.hNovelRoot).setSelected(True) # type: ignore
- projTree.dropEvent(event)
- assert event.isAccepted() is False
- projTree.saveTreeOrder()
- assert SHARED.project.tree._order == treeOrder
-
- # Make sure illegal drag events are cancelled
- with monkeypatch.context() as mp:
- mp.setattr(QTreeWidget, "dragEnterEvent", lambda *a: None)
- mime = QMimeData()
- mime.setText("foobar")
- event = QDragEnterEvent(nPos, action, mime, mouse, modifier)
- projTree.clearSelection()
- projTree._getTreeItem(C.hNovelRoot).setSelected(True) # type: ignore
- projTree._getTreeItem(C.hTitlePage).setSelected(True) # type: ignore
- projTree._getTreeItem(C.hChapterDoc).setSelected(True) # type: ignore
- assert projTree.selectedItems() == [ # Novel Root selection is cancelled automatically
- projTree._getTreeItem(C.hTitlePage), projTree._getTreeItem(C.hChapterDoc)
- ]
- projTree.dragEnterEvent(event)
- assert mime.text() == ""
- assert projTree._popAlert is not None
-
- # Pop the alert
- with monkeypatch.context() as mp:
- mp.setattr(QTreeWidget, "startDrag", lambda *a: None)
- projTree.startDrag(None) # type: ignore
- assert projTree._popAlert is None
-
- # Valid drag events are processed
- with monkeypatch.context() as mp:
- mp.setattr(QTreeWidget, "dragEnterEvent", lambda *a: None)
- mime = QMimeData()
- mime.setText("foobar")
- event = QDragEnterEvent(nPos, action, mime, mouse, modifier)
- projTree.clearSelection()
- projTree._getTreeItem(C.hChapterDoc).setSelected(True) # type: ignore
- projTree._getTreeItem(C.hSceneDoc).setSelected(True) # type: ignore
- assert projTree.selectedItems() == [ # Novel Root selection is cancelled automatically
- projTree._getTreeItem(C.hChapterDoc), projTree._getTreeItem(C.hSceneDoc)
- ]
- projTree.dragEnterEvent(event)
- assert mime.text() == "foobar"
- assert projTree._popAlert is None
-
- # qtbot.stop()
-
-
-@pytest.mark.gui
-@pytest.mark.skip
-def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
+def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test various parts of the project tree class not covered by
other tests.
"""
buildTestProject(nwGUI, projPath)
- projView: GuiProjectView = nwGUI.projView
- projTree: GuiProjectTree = nwGUI.projView.projTree
+ projView = nwGUI.projView
+ projTree = projView.projTree
# Method: initSettings
# ====================
@@ -977,224 +1024,100 @@ def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd)
assert projTree.verticalScrollBarPolicy() == QtScrollAsNeeded
assert projTree.horizontalScrollBarPolicy() == QtScrollAsNeeded
- # Method: revealNewTreeItem
- # =========================
-
- # Send invalid handle
- assert projTree.revealNewTreeItem(C.hInvalid) is False
-
- # Try to add an orphaned file to the tree
- nHandle = SHARED.project.newFile("Test", C.hNovelRoot)
- SHARED.project.tree[nHandle].setParent(None) # type: ignore
- assert projTree.revealNewTreeItem(nHandle) is False
-
- # Try to add an item with unknown parent to the tree
- nHandle = SHARED.project.newFile("Test", C.hNovelRoot)
- SHARED.project.tree[nHandle].setParent(C.hInvalid) # type: ignore
- assert projTree.revealNewTreeItem(nHandle) is False
-
- # Slot: _treeDoubleClick
- # ======================
-
- # Try to open a file with nothings selected
- projTree.clearSelection()
- projTree._onDoubleClick(QTreeWidgetItem(), 0)
- assert nwGUI.docEditor.docHandle is None
-
- # When the item cannot be found
- projTree._getTreeItem(C.hTitlePage).setSelected(True) # type: ignore
- with monkeypatch.context() as mp:
- mp.setattr("novelwriter.core.tree.NWTree.__getitem__", lambda *a: None)
- projTree._onDoubleClick(QTreeWidgetItem(), 0)
- assert nwGUI.docEditor.docHandle is None
-
- # Successfully open a file
- projTree._onDoubleClick(projTree._getTreeItem(C.hTitlePage), 0)
- assert nwGUI.docEditor.docHandle == C.hTitlePage
- projTree._getTreeItem(C.hTitlePage).setSelected(False) # type: ignore
-
- # A non-file item should be expanded instead
- projTree._getTreeItem(C.hNovelRoot).setExpanded(False) # type: ignore
- projTree._getTreeItem(C.hNovelRoot).setSelected(True) # type: ignore
- projTree._onDoubleClick(projTree._getTreeItem(C.hNovelRoot), 1)
- assert nwGUI.docEditor.docHandle == C.hTitlePage
- assert projTree._getTreeItem(C.hNovelRoot).isExpanded() is True # type: ignore
-
- # Navigate the Tree
- # =================
-
- # Expand handles
- projTree.setExpandedFromHandle(C.hNovelRoot, True)
- projTree.setSelectedHandle(C.hSceneDoc)
- assert projTree.getSelectedHandle() == C.hSceneDoc
-
- # Move between documents in that folder
- projTree.moveToNextItem(-1)
- assert projTree.getSelectedHandle() == C.hChapterDoc
- projTree.moveToNextItem(-1) # Can't move further up
- assert projTree.getSelectedHandle() == C.hChapterDoc
- projTree.moveToNextItem(1)
- assert projTree.getSelectedHandle() == C.hSceneDoc
- projTree.moveToNextItem(1) # Can't move further down
- assert projTree.getSelectedHandle() == C.hSceneDoc
-
- # Move up/down the parent/child hierarchy
- projTree.moveToLevel(-1)
- assert projTree.getSelectedHandle() == C.hChapterDir
- projTree.moveToLevel(-1)
- assert projTree.getSelectedHandle() == C.hNovelRoot
- projTree.moveToLevel(-1) # Can't move further up
- assert projTree.getSelectedHandle() == C.hNovelRoot
- projTree.moveToLevel(1)
- assert projTree.getSelectedHandle() == C.hTitlePage
- projTree.moveToLevel(1) # Can't move further down
- assert projTree.getSelectedHandle() == C.hTitlePage
-
- # Move between roots
- projTree.setSelectedHandle(C.hNovelRoot)
- projTree.moveToNextItem(1)
- assert projTree.getSelectedHandle() == C.hPlotRoot
- projTree.moveToNextItem(1)
- assert projTree.getSelectedHandle() == C.hCharRoot
- projTree.moveToNextItem(1)
- assert projTree.getSelectedHandle() == C.hWorldRoot
- projTree.moveToNextItem(1) # Can't move further down
- assert projTree.getSelectedHandle() == C.hWorldRoot
-
- # When nothing is selected, nothing happens
- projTree.clearSelection()
- assert projTree.getSelectedHandle() is None
- projTree.moveToNextItem(-1)
- assert projTree.getSelectedHandle() is None
- projTree.moveToNextItem(1)
- assert projTree.getSelectedHandle() is None
- projTree.moveToLevel(-1)
- assert projTree.getSelectedHandle() is None
- projTree.moveToLevel(1)
- assert projTree.getSelectedHandle() is None
-
- # Mouse Button Clicks
- # ===================
-
- eType = QEvent.Type.MouseButtonPress
- pos = projTree.visualItemRect(projTree._getTreeItem(C.hChapterDoc)).center()
- button = QtMouseMiddle
- modifier = QtModNone
-
- # Trigger the viewer
- event = QMouseEvent(eType, pos, button, button, modifier)
- projTree.mousePressEvent(event)
- assert nwGUI.docViewer.docHandle == C.hChapterDoc
-
- # Trigger the left click clear
- pos = QPoint(5000, 5000)
- button = QtMouseLeft
- event = QMouseEvent(eType, pos, button, button, modifier)
- projTree.setSelectedHandle(C.hChapterDoc)
- projTree.mousePressEvent(event)
- assert projTree.selectedItems() == []
-
- # Rename Item
- # ===========
-
- with monkeypatch.context() as mp:
- mp.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("FooBar", True))
- projTree.clearSelection()
- assert SHARED.project.tree[C.hChapterDoc].itemName == "New Chapter" # type: ignore
- projView.renameTreeItem(C.hChapterDoc)
- assert SHARED.project.tree[C.hChapterDoc].itemName == "FooBar" # type: ignore
-
- projTree.setSelectedHandle(C.hSceneDoc)
- assert SHARED.project.tree[C.hSceneDoc].itemName == "New Scene" # type: ignore
- projView.renameTreeItem()
- assert SHARED.project.tree[C.hSceneDoc].itemName == "FooBar" # type: ignore
-
- # Check Crash Resistance
- # ======================
- projTree._postItemMove("dfghj") # This should exit cleanly
-
# qtbot.stop()
@pytest.mark.gui
-@pytest.mark.skip
def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test the building of the project tree context menu. All this does
is test that the menu builds. It doesn't open the actual menu.
"""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
+ QtSelect = QItemSelectionModel.SelectionFlag.Select
+
+ # Create a project
+ projView = nwGUI.projView
+ projTree = projView.projTree
+ project = SHARED.project
+ tree = project.tree
+ model = tree.model
# Create a project
buildTestProject(nwGUI, projPath)
- nwGUI.openProject(projPath)
- nwGUI._switchFocus(nwFocus.TREE)
+ trash = tree.trash
+ assert trash is not None
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
# Handles for new objects
hCharNote = "0000000000011"
hNovelNote = "0000000000012"
- hSubNote = "0000000000013"
- hNewFolderOne = "0000000000014"
- hNewFolderTwo = "0000000000016"
-
- projView = nwGUI.projView
- projTree = nwGUI.projView.projTree
- projTree.setExpandedFromHandle(None, True)
-
- projTree._addTrashRoot()
- hTrashRoot = SHARED.project.tree.trashRoot
+ hTrashDoc = "0000000000013"
+ hSubNote = "0000000000014"
+ hNewFolderOne = "0000000000015"
+ hNewFolderTwo = "0000000000017"
+ hTrashRoot = trash.item.itemHandle
+ projTree.expandAll()
projTree.setSelectedHandle(C.hCharRoot)
- projTree.newTreeItem(nwItemType.FILE)
+ projTree.newTreeItem(nwItemType.FILE, isNote=True)
projTree.setSelectedHandle(C.hNovelRoot)
projTree.newTreeItem(nwItemType.FILE, isNote=True)
+ projTree.setSelectedHandle(C.hNovelRoot)
+ projTree.newTreeItem(nwItemType.FILE)
+ projTree.setSelectedHandle(hTrashDoc)
+ projTree.processDeleteRequest()
- SHARED.project.newFile("SubNote", hNovelNote)
- projTree.revealNewTreeItem(hSubNote)
- assert SHARED.project.tree[hSubNote].itemParent == hNovelNote # type: ignore
+ project.newFile("SubNote", hNovelNote)
+ item = tree[hSubNote]
+ assert item is not None
+ assert item.itemName == "SubNote"
+ assert item.itemParent == hNovelNote
- def itemPos(tHandle):
- return projTree.visualItemRect(projTree._getTreeItem(tHandle)).center()
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "New Note", "SubNote", "Plot", "Characters", "New Note", "Locations",
+ "Trash", "New Document",
+ ]
# Pop the menu in various positions and check for success
with monkeypatch.context() as mp:
mockMenu = MagicMock()
mp.setattr(QMenu, "exec", mockMenu)
- projTree.clearSelection()
+ projTree._clearSelection()
# No item under menu
projTree.openContextMenu(projTree.viewport().rect().bottomRight())
assert mockMenu.call_count == 0
# Open Trash Menu
- projTree.openContextMenu(itemPos(hTrashRoot))
+ projView.setSelectedHandle(hTrashRoot)
+ projTree.openContextMenu()
assert mockMenu.call_count == 1
# Open Single Select Menu
- projTree.openContextMenu(itemPos(C.hNovelRoot))
+ projView.setSelectedHandle(C.hNovelRoot)
+ projTree.openContextMenu()
assert mockMenu.call_count == 2
# Open Multi-Select Menu
- projTree._getTreeItem(hNovelNote).setSelected(True)
- projTree._getTreeItem(hSubNote).setSelected(True)
- projTree.openContextMenu(itemPos(hCharNote))
+ projTree.selectionModel().select(model.indexFromHandle(hNovelNote), QtSelect)
+ projTree.selectionModel().select(model.indexFromHandle(hSubNote), QtSelect)
+ projTree.openContextMenu()
assert mockMenu.call_count == 3
-
- # Check the keyboard shortcut handler as well
- projTree.setSelectedHandle(C.hNovelRoot)
- projTree.openContextMenu(None)
- assert mockMenu.call_count == 4
projTree.clearSelection()
- projTree.openContextMenu(None)
- assert mockMenu.call_count == 4
# Menu Builders
# =============
# Context Menu on Root Item
- nwItem = SHARED.project.tree[C.hNovelRoot]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildSingleSelectMenu(True)
+ node = tree.nodes[C.hNovelRoot]
+ indices = [model.indexFromHandle(C.hNovelRoot)]
+ ctxMenu = _TreeContextMenu(projTree, model, node, indices)
+ ctxMenu.buildSingleSelectMenu()
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Create New ...", "Rename", "Set Status to ...", "Expand All",
@@ -1202,10 +1125,10 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
]
# Context Menu on Folder Item
- nwItem = SHARED.project.tree[C.hChapterDir]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildSingleSelectMenu(True)
+ node = tree.nodes[C.hChapterDir]
+ indices = [model.indexFromHandle(C.hChapterDir)]
+ ctxMenu = _TreeContextMenu(projTree, model, node, indices)
+ ctxMenu.buildSingleSelectMenu()
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Create New ...", "Rename", "Set Status to ...", "Transform ...", "Expand All",
@@ -1219,26 +1142,24 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
return []
# Context Menu on Document File Item
- nwItem = SHARED.project.tree[C.hChapterDoc]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildSingleSelectMenu(True)
+ node = tree.nodes[C.hChapterDoc]
+ indices = [model.indexFromHandle(C.hChapterDoc)]
+ ctxMenu = _TreeContextMenu(projTree, model, node, indices)
+ ctxMenu.buildSingleSelectMenu()
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Open Document", "View Document", "Create New ...", "Rename", "Rename to Heading",
- "Toggle Active", "Set Status to ...", "Transform ...", "Expand All", "Collapse All",
- "Duplicate", "Move to Trash",
+ "Toggle Active", "Set Status to ...", "Transform ...", "Duplicate", "Move to Trash",
]
assert getTransformSubMenu(ctxMenu) == [
- "Convert to Project Note", "Merge Child Items into Self",
- "Merge Child Items into New", "Split Document by Headings"
+ "Convert to Project Note", "Split Document by Headings"
]
# Context Menu on Note File Item in Character Folder
- nwItem = SHARED.project.tree[hCharNote]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildSingleSelectMenu(False)
+ node = tree.nodes[hCharNote]
+ indices = [model.indexFromHandle(hCharNote)]
+ ctxMenu = _TreeContextMenu(projTree, model, node, indices)
+ ctxMenu.buildSingleSelectMenu()
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Open Document", "View Document", "Create New ...", "Rename", "Rename to Heading",
@@ -1249,34 +1170,44 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
]
# Context Menu on Note File Item in Novel Tree
- nwItem = SHARED.project.tree[hNovelNote]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildSingleSelectMenu(False)
+ node = tree.nodes[hNovelNote]
+ indices = [model.indexFromHandle(hNovelNote)]
+ ctxMenu = _TreeContextMenu(projTree, model, node, indices)
+ ctxMenu.buildSingleSelectMenu()
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Open Document", "View Document", "Create New ...", "Rename", "Rename to Heading",
- "Toggle Active", "Set Status to ...", "Transform ...", "Duplicate", "Move to Trash",
+ "Toggle Active", "Set Status to ...", "Transform ...", "Expand All", "Collapse All",
+ "Duplicate", "Move to Trash",
]
assert getTransformSubMenu(ctxMenu) == [
- "Convert to Novel Document", "Split Document by Headings",
+ "Convert to Novel Document", "Merge Child Items into Self",
+ "Merge Child Items into New", "Split Document by Headings",
]
# Context Menu on Multiple Items, Clicked on Document
- nwItem = SHARED.project.tree[hNovelNote]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote])
+ node = tree.nodes[hNovelNote]
+ indices = [
+ model.indexFromHandle(hCharNote),
+ model.indexFromHandle(hNovelNote),
+ model.indexFromHandle(hSubNote),
+ ]
+ ctxMenu = _TreeContextMenu(projTree, model, node, indices)
+ ctxMenu.buildMultiSelectMenu()
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Set Active to ...", "Set Status to ...", "Move to Trash",
]
# Context Menu on Multiple Items, Clicked on Note
- nwItem = SHARED.project.tree[hCharNote]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote])
+ node = tree.nodes[hCharNote]
+ indices = [
+ model.indexFromHandle(hCharNote),
+ model.indexFromHandle(hNovelNote),
+ model.indexFromHandle(hSubNote),
+ ]
+ ctxMenu = _TreeContextMenu(projTree, model, node, indices)
+ ctxMenu.buildMultiSelectMenu()
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Set Active to ...", "Set Importance to ...", "Move to Trash",
@@ -1285,189 +1216,180 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Direct Edit Functions, Single
# =============================
- nwItem = SHARED.project.tree[hNovelNote]
- assert isinstance(nwItem, NWItem)
+ node = tree.nodes[hNovelNote]
+ indices = [model.indexFromHandle(hNovelNote)]
- # # Toggle active flag
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildSingleSelectMenu(False)
- assert nwItem.isActive is True
+ # Toggle active flag
+ ctxMenu = _TreeContextMenu(projTree, model, node, indices)
+ ctxMenu.buildSingleSelectMenu()
+ assert node.item.isActive is True
ctxMenu._toggleItemActive()
- assert nwItem.isActive is False
+ assert node.item.isActive is False
# Change item status
- assert nwItem.itemStatus == "s000000"
+ assert node.item.itemStatus == "s000000"
ctxMenu._changeItemStatus("s000001")
- assert nwItem.itemStatus == "s000001"
+ assert node.item.itemStatus == "s000001"
# Change item importance
- assert nwItem.itemImport == "i000004"
+ assert node.item.itemImport == "i000004"
ctxMenu._changeItemImport("i000005")
- assert nwItem.itemImport == "i000005"
+ assert node.item.itemImport == "i000005"
# Change item layout
- assert nwItem.itemLayout == nwItemLayout.NOTE
+ assert node.item.itemLayout == nwItemLayout.NOTE
ctxMenu._changeItemLayout(nwItemLayout.DOCUMENT)
- assert nwItem.itemLayout == nwItemLayout.DOCUMENT
+ assert node.item.itemLayout == nwItemLayout.DOCUMENT
ctxMenu._changeItemLayout(nwItemLayout.NOTE)
- assert nwItem.itemLayout == nwItemLayout.NOTE
+ assert node.item.itemLayout == nwItemLayout.NOTE
# Convert Folders to Documents
# ============================
projView.setSelectedHandle(hNovelNote)
- assert projTree.newTreeItem(nwItemType.FOLDER) is True
+ projTree.newTreeItem(nwItemType.FOLDER)
projView.setSelectedHandle(hNewFolderOne)
- assert projTree.newTreeItem(nwItemType.FILE) is True
+ projTree.newTreeItem(nwItemType.FILE)
projView.setSelectedHandle(hNovelNote)
- assert projTree.newTreeItem(nwItemType.FOLDER) is True
+ projTree.newTreeItem(nwItemType.FOLDER)
projView.setSelectedHandle(hNewFolderTwo)
- assert projTree.newTreeItem(nwItemType.FILE, isNote=True) is True
+ projTree.newTreeItem(nwItemType.FILE, isNote=True)
- nwItem = SHARED.project.tree[hNewFolderOne]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildSingleSelectMenu(False)
+ assert hNewFolderOne in tree
+ assert hNewFolderTwo in tree
+
+ nodeOne = tree.nodes[hNewFolderOne]
+ nodeTwo = tree.nodes[hNewFolderTwo]
+
+ # Select Folder One
+ indices = [model.indexFromHandle(hNewFolderOne)]
+ ctxMenu = _TreeContextMenu(projTree, model, nodeOne, indices)
+ ctxMenu.buildSingleSelectMenu()
# Click no on the dialog
with monkeypatch.context() as mp:
- mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
+ mp.setattr(QMessageBox, "result", lambda *a: QtRejected)
ctxMenu._convertFolderToFile(nwItemLayout.DOCUMENT)
- assert SHARED.project.tree[hNewFolderOne].isFolderType() # type: ignore
+ assert nodeOne.item.isFolderType()
# Convert the first folder to a document
+ assert nodeOne.item.isFolderType()
ctxMenu._convertFolderToFile(nwItemLayout.DOCUMENT)
- assert SHARED.project.tree[hNewFolderOne].isFileType() # type: ignore
- assert SHARED.project.tree[hNewFolderOne].isDocumentLayout() # type: ignore
+ assert nodeOne.item.isFileType()
+ assert nodeOne.item.isDocumentLayout()
- nwItem = SHARED.project.tree[hNewFolderTwo]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildSingleSelectMenu(False)
+ # Select Folder Two
+ indices = [model.indexFromHandle(hNewFolderTwo)]
+ ctxMenu = _TreeContextMenu(projTree, model, nodeTwo, indices)
+ ctxMenu.buildSingleSelectMenu()
# Convert the second folder to a note
ctxMenu._convertFolderToFile(nwItemLayout.NOTE)
- assert SHARED.project.tree[hNewFolderTwo].isFileType() # type: ignore
- assert SHARED.project.tree[hNewFolderTwo].isNoteLayout() # type: ignore
+ assert nodeTwo.item.isFileType()
+ assert nodeTwo.item.isNoteLayout()
# Direct Edit Functions, Multi
# ============================
- nwItem = SHARED.project.tree[hCharNote]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote])
+ node = tree.nodes[hCharNote]
+ indices = [
+ model.indexFromHandle(hCharNote),
+ model.indexFromHandle(hNovelNote),
+ model.indexFromHandle(hSubNote),
+ ]
+ ctxMenu = _TreeContextMenu(projTree, model, node, indices)
+ ctxMenu.buildMultiSelectMenu()
- projTree.clearSelection()
- projTree._getTreeItem(hCharNote).setSelected(True)
- projTree._getTreeItem(hNovelNote).setSelected(True)
- projTree._getTreeItem(hSubNote).setSelected(True)
+ # projTree.clearSelection()
+ # projTree._getTreeItem(hCharNote).setSelected(True)
+ # projTree._getTreeItem(hNovelNote).setSelected(True)
+ # projTree._getTreeItem(hSubNote).setSelected(True)
+
+ nodeCNote = tree.nodes[hCharNote]
+ nodeNNote = tree.nodes[hNovelNote]
+ nodeSNote = tree.nodes[hSubNote]
# Item Active
- assert SHARED.project.tree[hCharNote].isActive is True # type: ignore
- assert SHARED.project.tree[hNovelNote].isActive is False # type: ignore
- assert SHARED.project.tree[hSubNote].isActive is True # type: ignore
+ assert nodeCNote.item.isActive is True
+ assert nodeNNote.item.isActive is False
+ assert nodeSNote.item.isActive is True
ctxMenu._iterItemActive(False)
- assert SHARED.project.tree[hCharNote].isActive is False # type: ignore
- assert SHARED.project.tree[hNovelNote].isActive is False # type: ignore
- assert SHARED.project.tree[hSubNote].isActive is False # type: ignore
+ assert nodeCNote.item.isActive is False
+ assert nodeNNote.item.isActive is False
+ assert nodeSNote.item.isActive is False
ctxMenu._iterItemActive(True)
- assert SHARED.project.tree[hCharNote].isActive is True # type: ignore
- assert SHARED.project.tree[hNovelNote].isActive is True # type: ignore
- assert SHARED.project.tree[hSubNote].isActive is True # type: ignore
+ assert nodeCNote.item.isActive is True
+ assert nodeNNote.item.isActive is True
+ assert nodeSNote.item.isActive is True
# Item Status
- assert SHARED.project.tree[hCharNote].itemStatus == "s000000" # type: ignore
- assert SHARED.project.tree[hNovelNote].itemStatus == "s000001" # type: ignore
- assert SHARED.project.tree[hSubNote].itemStatus == "s000000" # type: ignore
+ assert nodeCNote.item.itemStatus == "s000000"
+ assert nodeNNote.item.itemStatus == "s000001"
+ assert nodeSNote.item.itemStatus == "s000000"
ctxMenu._iterSetItemStatus("s000003")
- assert SHARED.project.tree[hCharNote].itemStatus == "s000000" # type: ignore
- assert SHARED.project.tree[hNovelNote].itemStatus == "s000003" # type: ignore
- assert SHARED.project.tree[hSubNote].itemStatus == "s000003" # type: ignore
+ assert nodeCNote.item.itemStatus == "s000000"
+ assert nodeNNote.item.itemStatus == "s000003"
+ assert nodeSNote.item.itemStatus == "s000003"
# Item Importance
- assert SHARED.project.tree[hCharNote].itemImport == "i000004" # type: ignore
- assert SHARED.project.tree[hNovelNote].itemImport == "i000005" # type: ignore
- assert SHARED.project.tree[hSubNote].itemImport == "i000004" # type: ignore
+ assert nodeCNote.item.itemImport == "i000004"
+ assert nodeNNote.item.itemImport == "i000005"
+ assert nodeSNote.item.itemImport == "i000004"
ctxMenu._iterSetItemImport("i000007")
- assert SHARED.project.tree[hCharNote].itemImport == "i000007" # type: ignore
- assert SHARED.project.tree[hNovelNote].itemImport == "i000005" # type: ignore
- assert SHARED.project.tree[hSubNote].itemImport == "i000004" # type: ignore
-
- # Move to Trash
- assert SHARED.project.tree[hCharNote].itemRoot == C.hCharRoot # type: ignore
- assert SHARED.project.tree[hNovelNote].itemRoot == C.hNovelRoot # type: ignore
- assert SHARED.project.tree[hSubNote].itemRoot == C.hNovelRoot # type: ignore
- ctxMenu._iterMoveToTrash()
- assert SHARED.project.tree[hCharNote].itemRoot == hTrashRoot # type: ignore
- assert SHARED.project.tree[hNovelNote].itemRoot == hTrashRoot # type: ignore
- assert SHARED.project.tree[hSubNote].itemRoot == hTrashRoot # type: ignore
-
- # Permanently Delete Menu
- nwItem = SHARED.project.tree[hCharNote]
- assert isinstance(nwItem, NWItem)
- ctxMenu = _TreeContextMenu(projTree, nwItem)
- ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote])
- actions = [x.text() for x in ctxMenu.actions() if x.text()]
- assert actions == [
- "Set Active to ...", "Set Importance to ...", "Delete Permanently",
- ]
-
- # Permanently Delete
- ctxMenu._iterPermDelete()
- assert SHARED.project.tree[hCharNote] is None
- assert SHARED.project.tree[hNovelNote] is None
- assert SHARED.project.tree[hSubNote] is None
+ assert nodeCNote.item.itemImport == "i000007"
+ assert nodeNNote.item.itemImport == "i000005"
+ assert nodeSNote.item.itemImport == "i000004"
# qtbot.stop()
@pytest.mark.gui
-@pytest.mark.skip
def testGuiProjTree_Templates(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test the templates feature of the project tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
# Create a project
- buildTestProject(nwGUI, projPath)
- nwGUI.openProject(projPath)
- nwGUI._switchFocus(nwFocus.TREE)
- nwGUI.show()
-
- project = SHARED.project
-
projView = nwGUI.projView
projTree = projView.projTree
projBar = projView.projBar
+ project = SHARED.project
+ tree = project.tree
+
+ # Create a project
+ buildTestProject(nwGUI, projPath)
+ trash = tree.trash
+ assert trash is not None
+ assert len(tree) == 9
+ assert [n.item.itemName for n in tree.model.root.allChildren()] == [
+ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
+ "Plot", "Characters", "Locations", "Trash",
+ ]
# Handles for new objects
- hTemplatesRoot = "0000000000010"
- hSceneTemplate = "0000000000011"
- hNoteTemplate = "0000000000012"
- hNewScene = "0000000000013"
- hNewCharacter = "0000000000014"
+ hTemplatesRoot = "0000000000011"
+ hSceneTemplate = "0000000000012"
+ hNoteTemplate = "0000000000013"
+ hNewScene = "0000000000014"
+ hNewCharacter = "0000000000015"
# Add template folder
projTree.newTreeItem(nwItemType.ROOT, nwItemClass.TEMPLATE)
- nwTemplateRoot = project.tree[hTemplatesRoot]
+ nwTemplateRoot = tree[hTemplatesRoot]
assert nwTemplateRoot is not None
assert nwTemplateRoot.itemName == "Templates"
# Add a scene template
projTree.setSelectedHandle(hTemplatesRoot)
projTree.newTreeItem(nwItemType.FILE, hLevel=3, isNote=False)
- nwSceneTemplate = project.tree[hSceneTemplate]
+ nwSceneTemplate = tree[hSceneTemplate]
assert nwSceneTemplate is not None
assert nwSceneTemplate.itemName == "New Scene"
assert projBar.mTemplates.actions()[0].text() == "New Scene"
# Rename the scene template
- with qtbot.waitSignal(projTree.itemRefreshed, timeout=1000) as signal:
- projTree.renameTreeItem(hSceneTemplate, name="Scene")
- assert signal.args[0] == hSceneTemplate
- assert signal.args[1].itemName == "Scene"
- assert projBar.mTemplates.actions()[0].text() == "Scene"
+ projView.renameTreeItem(hSceneTemplate, name="Scene")
+ assert projBar.mTemplates.actions()[0].text() == "Scene"
# Add a note template
projTree.setSelectedHandle(hTemplatesRoot)
@@ -1478,11 +1400,8 @@ def testGuiProjTree_Templates(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
assert projBar.mTemplates.actions()[1].text() == "New Note"
# Rename the note template
- with qtbot.waitSignal(projTree.itemRefreshed, timeout=1000) as signal:
- projTree.renameTreeItem(hNoteTemplate, name="Note")
- assert signal.args[0] == hNoteTemplate
- assert signal.args[1].itemName == "Note"
- assert projBar.mTemplates.actions()[1].text() == "Note"
+ projView.renameTreeItem(hNoteTemplate, name="Note")
+ assert projBar.mTemplates.actions()[1].text() == "Note"
# Add new content to template files
(projPath / "content" / f"{hSceneTemplate}.nwd").write_text("### Scene\n\n@pov: Jane\n\n")
@@ -1505,13 +1424,14 @@ def testGuiProjTree_Templates(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
assert project.storage.getDocument(hNewCharacter).readDocument() == "# Jane\n\n@tag: Jane\n\n"
# Remove the templates
- with qtbot.waitSignal(projTree.itemRefreshed, timeout=1000) as signal:
- assert projBar.mTemplates.menuAction().isVisible() is True
- assert len(projBar.mTemplates.actions()) == 2
- projTree.moveItemToTrash(hNoteTemplate)
- assert len(projBar.mTemplates.actions()) == 1
- projTree.moveItemToTrash(hSceneTemplate)
- assert len(projBar.mTemplates.actions()) == 0
- assert projBar.mTemplates.menuAction().isVisible() is False
+ assert projBar.mTemplates.menuAction().isVisible() is True
+ assert len(projBar.mTemplates.actions()) == 2
+ assert trash.childCount() == 0
+ projTree.processDeleteRequest([hNoteTemplate])
+ assert trash.childCount() == 1
+ assert len(projBar.mTemplates.actions()) == 1
+ projTree.processDeleteRequest([hSceneTemplate])
+ assert len(projBar.mTemplates.actions()) == 0
+ assert projBar.mTemplates.menuAction().isVisible() is False
# qtbot.stop()
From e30d2cbbf1951ac1b93611ec12f7b3afcb9f6214 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 24 Nov 2024 19:25:46 +0100
Subject: [PATCH 38/39] Always use forward slash for ToC file
---
novelwriter/core/tree.py | 2 +-
tests/test_core/test_core_tree.py | 10 +++-------
2 files changed, 4 insertions(+), 8 deletions(-)
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 55c23ebd..c845f88c 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -343,7 +343,7 @@ class NWTree:
file = f"{item.itemHandle}.nwd"
if (contentPath / file).is_file():
tocLine = "{0:<25s} {1:<9s} {2:<8s} {3:s}".format(
- str(Path("content") / file),
+ f"content/{file}",
item.itemClass.name,
item.itemLayout.name,
item.itemName,
diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py
index 9eab11d8..baf6d3e4 100644
--- a/tests/test_core/test_core_tree.py
+++ b/tests/test_core/test_core_tree.py
@@ -436,10 +436,6 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
mp.setattr("builtins.open", causeOSError)
assert tree.writeToCFile() is False
- pathA = str(Path("content") / "000000000000c.nwd")
- pathB = str(Path("content") / "000000000000e.nwd")
- pathC = str(Path("content") / "000000000000f.nwd")
-
# Allow writing
assert tree.writeToCFile() is True
assert (fncPath / nwFiles.TOC_TXT).read_text() == (
@@ -449,7 +445,7 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
"\n"
"File Name Class Layout Document Label\n"
"--------------------------------------------------------------\n"
- f"{pathA} NOVEL DOCUMENT Title Page\n"
- f"{pathB} NOVEL DOCUMENT New Chapter\n"
- f"{pathC} NOVEL DOCUMENT New Scene\n"
+ "content/000000000000c.nwd NOVEL DOCUMENT Title Page\n"
+ "content/000000000000e.nwd NOVEL DOCUMENT New Chapter\n"
+ "content/000000000000f.nwd NOVEL DOCUMENT New Scene\n"
)
From 4cadd1597e853fed9c3f31a04cb9533d2239c866 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 25 Nov 2024 00:07:32 +0100
Subject: [PATCH 39/39] Add coverage to remaining missed lines
---
novelwriter/extensions/configlayout.py | 4 +-
novelwriter/gui/itemdetails.py | 18 +-----
novelwriter/gui/projtree.py | 17 +++---
tests/test_gui/test_gui_doceditor.py | 81 ++++++++++++++++++++++++--
tests/test_gui/test_gui_docviewer.py | 80 ++++++++++++++++++++++++-
tests/test_gui/test_gui_guimain.py | 11 ++++
tests/test_gui/test_gui_projtree.py | 18 +++++-
tests/test_gui/test_gui_statusbar.py | 14 +++--
8 files changed, 199 insertions(+), 44 deletions(-)
diff --git a/novelwriter/extensions/configlayout.py b/novelwriter/extensions/configlayout.py
index 2bd48032..b6ef1951 100644
--- a/novelwriter/extensions/configlayout.py
+++ b/novelwriter/extensions/configlayout.py
@@ -183,7 +183,7 @@ class NScrollableForm(QScrollArea):
def addRow(
self,
label: str | None,
- widget: QWidget | list[QWidget | QPixmap | str | int],
+ widget: QWidget | list[QWidget | QPixmap | int],
helpText: str = "",
unit: str | None = None,
button: QWidget | None = None,
@@ -204,8 +204,6 @@ class NScrollableForm(QScrollArea):
icon = QLabel(self)
icon.setPixmap(item)
wBox.addWidget(icon)
- elif isinstance(item, str):
- wBox.addWidget(QLabel(item, self))
elif isinstance(item, int):
wBox.addSpacing(CONFIG.pxInt(item))
qWidget = QWidget(self)
diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py
index 2a90c7b5..77df5acf 100644
--- a/novelwriter/gui/itemdetails.py
+++ b/novelwriter/gui/itemdetails.py
@@ -225,12 +225,7 @@ class GuiItemDetails(QWidget):
def updateViewBox(self, tHandle: str | None) -> None:
"""Populate the details box from a given handle."""
- if tHandle is None:
- self.clearDetails()
- return
-
- nwItem = SHARED.project.tree[tHandle]
- if nwItem is None:
+ if not (tHandle and (nwItem := SHARED.project.tree[tHandle])):
self.clearDetails()
return
@@ -297,14 +292,3 @@ class GuiItemDetails(QWidget):
elif change == nwChange.DELETE:
self.updateViewBox(None)
return
-
- @pyqtSlot(str, int, int, int)
- def updateCounts(self, tHandle: str, cC: int, wC: int, pC: int) -> None:
- """Update the counts if the handle is the same as the one we're
- already showing. Otherwise, do nothing.
- """
- if tHandle == self._handle:
- self.cCountData.setText(f"{cC:n}")
- self.wCountData.setText(f"{wC:n}")
- self.pCountData.setText(f"{pC:n}")
- return
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index d9ca56a6..2ca5eaf9 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -213,7 +213,7 @@ class GuiProjectView(QWidget):
@pyqtSlot(str, Enum)
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Refresh other content when project item changed."""
- self.projBar.processTemplateDocuments(tHandle, change)
+ self.projBar.processTemplateDocuments(tHandle)
return
@pyqtSlot(str)
@@ -404,17 +404,16 @@ class GuiProjectToolBar(QWidget):
"""Build the templates menu."""
for tHandle, _ in SHARED.project.tree.iterRoots(nwItemClass.TEMPLATE):
for dHandle in SHARED.project.tree.subTree(tHandle):
- self.processTemplateDocuments(dHandle, nwChange.CREATE)
+ self.processTemplateDocuments(dHandle)
return
- def processTemplateDocuments(self, tHandle: str, change: nwChange) -> None:
+ def processTemplateDocuments(self, tHandle: str) -> None:
"""Process change in tree items to update menu content."""
- if change in (nwChange.CREATE, nwChange.UPDATE):
- if item := SHARED.project.tree[tHandle]:
- if item.isTemplateFile() and item.isActive:
- self.mTemplates.addUpdate(tHandle, item.itemName, item.getMainIcon())
- elif tHandle in self.mTemplates:
- self.mTemplates.remove(tHandle)
+ if item := SHARED.project.tree[tHandle]:
+ if item.isTemplateFile() and item.isActive:
+ self.mTemplates.addUpdate(tHandle, item.itemName, item.getMainIcon())
+ elif tHandle in self.mTemplates:
+ self.mTemplates.remove(tHandle)
return
##
diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py
index b9bdb26f..fd39e5f8 100644
--- a/tests/test_gui/test_gui_doceditor.py
+++ b/tests/test_gui/test_gui_doceditor.py
@@ -24,22 +24,23 @@ from unittest.mock import MagicMock
import pytest
-from PyQt5.QtCore import QEvent, Qt, QThreadPool, QUrl
+from PyQt5.QtCore import QEvent, QMimeData, Qt, QThreadPool, QUrl
from PyQt5.QtGui import (
- QClipboard, QDesktopServices, QFont, QMouseEvent, QTextBlock, QTextCursor,
- QTextOption
+ QClipboard, QDesktopServices, QDragEnterEvent, QDragMoveEvent, QDropEvent,
+ QFont, QMouseEvent, QTextBlock, QTextCursor, QTextOption
)
-from PyQt5.QtWidgets import QAction, QApplication, QMenu
+from PyQt5.QtWidgets import QAction, QApplication, QMenu, QPlainTextEdit
from novelwriter import CONFIG, SHARED
+from novelwriter.common import decodeMimeHandles
from novelwriter.constants import nwKeyWords, nwUnicode
from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.enum import nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwTrinary
from novelwriter.gui.doceditor import GuiDocEditor
from novelwriter.text.counting import standardCounter
from novelwriter.types import (
- QtAlignJustify, QtAlignLeft, QtKeepAnchor, QtModCtrl, QtMouseLeft,
- QtMoveAnchor, QtMoveRight, QtScrollAlwaysOff, QtScrollAsNeeded
+ QtAlignJustify, QtAlignLeft, QtKeepAnchor, QtModCtrl, QtModNone,
+ QtMouseLeft, QtMoveAnchor, QtMoveRight, QtScrollAlwaysOff, QtScrollAsNeeded
)
from tests.mocked import causeOSError
@@ -209,6 +210,74 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, projPath, ipsumTex
# qtbot.stop()
+@pytest.mark.gui
+def testGuiEditor_DragAndDrop(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
+ """Test drag and drop in the editor."""
+ docEditor = nwGUI.docEditor
+
+ buildTestProject(nwGUI, projPath)
+ assert nwGUI.openDocument(C.hTitlePage) is True
+ assert docEditor.docHandle == C.hTitlePage
+
+ middle = docEditor.viewport().rect().center()
+ action = Qt.DropAction.MoveAction
+ mouse = Qt.MouseButton.NoButton
+
+ model = SHARED.project.tree.model
+ docMime = model.mimeData([model.indexFromHandle(C.hSceneDoc)])
+ noneMime = QMimeData()
+ noneMime.setData("plain/text", b"")
+ assert decodeMimeHandles(docMime) == [C.hSceneDoc]
+
+ # Drag Enter
+ mockEnter = MagicMock()
+ docEvent = QDragEnterEvent(middle, action, docMime, mouse, QtModNone)
+ noneEvent = QDragEnterEvent(middle, action, noneMime, mouse, QtModNone)
+ with monkeypatch.context() as mp:
+ mp.setattr(QPlainTextEdit, "dragEnterEvent", mockEnter)
+
+ # Document Enter
+ docEditor.dragEnterEvent(docEvent)
+ assert docEvent.isAccepted() is True
+ assert mockEnter.call_count == 0
+
+ # Regular Enter
+ docEditor.dragEnterEvent(noneEvent)
+ assert mockEnter.call_count == 1
+
+ # Drag Move
+ mockMove = MagicMock()
+ docEvent = QDragMoveEvent(middle, action, docMime, mouse, QtModNone)
+ noneEvent = QDragMoveEvent(middle, action, noneMime, mouse, QtModNone)
+ with monkeypatch.context() as mp:
+ mp.setattr(QPlainTextEdit, "dragMoveEvent", mockMove)
+
+ # Document Move
+ docEditor.dragMoveEvent(docEvent)
+ assert docEvent.isAccepted() is True
+ assert mockMove.call_count == 0
+
+ # Regular Move
+ docEditor.dragMoveEvent(noneEvent)
+ assert mockMove.call_count == 1
+
+ # Drop
+ mockDrop = MagicMock()
+ docEvent = QDropEvent(middle, action, docMime, mouse, QtModNone)
+ noneEvent = QDropEvent(middle, action, noneMime, mouse, QtModNone)
+ with monkeypatch.context() as mp:
+ mp.setattr(QPlainTextEdit, "dropEvent", mockDrop)
+
+ # Document Drop
+ docEditor.dropEvent(docEvent)
+ assert mockDrop.call_count == 0
+ assert docEditor.docHandle == C.hSceneDoc
+
+ # Regular Move
+ docEditor.dropEvent(noneEvent)
+ assert mockDrop.call_count == 1
+
+
@pytest.mark.gui
def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd):
"""Test extracting various meta data and other values."""
diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py
index c312f5ac..41847348 100644
--- a/tests/test_gui/test_gui_docviewer.py
+++ b/tests/test_gui/test_gui_docviewer.py
@@ -24,16 +24,21 @@ from unittest.mock import MagicMock
import pytest
-from PyQt5.QtCore import QEvent, QPoint, Qt, QUrl
-from PyQt5.QtGui import QDesktopServices, QMouseEvent, QTextCursor
-from PyQt5.QtWidgets import QAction, QApplication, QMenu
+from PyQt5.QtCore import QEvent, QMimeData, QPoint, Qt, QUrl
+from PyQt5.QtGui import (
+ QDesktopServices, QDragEnterEvent, QDragMoveEvent, QDropEvent, QMouseEvent,
+ QTextCursor
+)
+from PyQt5.QtWidgets import QAction, QApplication, QMenu, QTextBrowser
from novelwriter import CONFIG, SHARED
+from novelwriter.common import decodeMimeHandles
from novelwriter.enum import nwChange, nwDocAction
from novelwriter.formats.toqdoc import ToQTextDocument
from novelwriter.types import QtModNone, QtMouseLeft
from tests.mocked import causeException
+from tests.tools import C, buildTestProject
@pytest.mark.gui
@@ -238,3 +243,72 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
docViewer.updateTheme()
# qtbot.stop()
+
+
+@pytest.mark.gui
+def testGuiViewer_DragAndDrop(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
+ """Test drag and drop in the viewer."""
+ docViewer = nwGUI.docViewer
+
+ buildTestProject(nwGUI, projPath)
+ assert nwGUI.openDocument(C.hTitlePage) is True
+ assert nwGUI.viewDocument(C.hTitlePage) is True
+ assert docViewer.docHandle == C.hTitlePage
+
+ middle = docViewer.viewport().rect().center()
+ action = Qt.DropAction.MoveAction
+ mouse = Qt.MouseButton.NoButton
+
+ model = SHARED.project.tree.model
+ docMime = model.mimeData([model.indexFromHandle(C.hSceneDoc)])
+ noneMime = QMimeData()
+ noneMime.setData("plain/text", b"")
+ assert decodeMimeHandles(docMime) == [C.hSceneDoc]
+
+ # Drag Enter
+ mockEnter = MagicMock()
+ docEvent = QDragEnterEvent(middle, action, docMime, mouse, QtModNone)
+ noneEvent = QDragEnterEvent(middle, action, noneMime, mouse, QtModNone)
+ with monkeypatch.context() as mp:
+ mp.setattr(QTextBrowser, "dragEnterEvent", mockEnter)
+
+ # Document Enter
+ docViewer.dragEnterEvent(docEvent)
+ assert docEvent.isAccepted() is True
+ assert mockEnter.call_count == 0
+
+ # Regular Enter
+ docViewer.dragEnterEvent(noneEvent)
+ assert mockEnter.call_count == 1
+
+ # Drag Move
+ mockMove = MagicMock()
+ docEvent = QDragMoveEvent(middle, action, docMime, mouse, QtModNone)
+ noneEvent = QDragMoveEvent(middle, action, noneMime, mouse, QtModNone)
+ with monkeypatch.context() as mp:
+ mp.setattr(QTextBrowser, "dragMoveEvent", mockMove)
+
+ # Document Move
+ docViewer.dragMoveEvent(docEvent)
+ assert docEvent.isAccepted() is True
+ assert mockMove.call_count == 0
+
+ # Regular Move
+ docViewer.dragMoveEvent(noneEvent)
+ assert mockMove.call_count == 1
+
+ # Drop
+ mockDrop = MagicMock()
+ docEvent = QDropEvent(middle, action, docMime, mouse, QtModNone)
+ noneEvent = QDropEvent(middle, action, noneMime, mouse, QtModNone)
+ with monkeypatch.context() as mp:
+ mp.setattr(QTextBrowser, "dropEvent", mockDrop)
+
+ # Document Drop
+ docViewer.dropEvent(docEvent)
+ assert mockDrop.call_count == 0
+ assert docViewer.docHandle == C.hSceneDoc
+
+ # Regular Move
+ docViewer.dropEvent(noneEvent)
+ assert mockDrop.call_count == 1
diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py
index 7ca32b8b..9668185f 100644
--- a/tests/test_gui/test_gui_guimain.py
+++ b/tests/test_gui/test_gui_guimain.py
@@ -702,6 +702,17 @@ def testGuiMain_Viewing(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
nwGUI.viewDocument(C.hSceneDoc)
assert nwGUI.docViewer.toPlainText() == "New Scene\nWith some stuff in it!"
+ # Open with keypress
+ nwGUI.closeDocViewer()
+ assert nwGUI.docViewer.docHandle is None
+ nwGUI.projView.setSelectedHandle(C.hSceneDoc)
+ with monkeypatch.context() as mp:
+ mp.setattr(nwGUI.projView.projTree, "hasFocus", lambda *a: True)
+ qtbot.keyClick(
+ nwGUI.projView.projTree, Qt.Key.Key_Return, modifier=QtModShift, delay=KEY_DELAY
+ )
+ assert nwGUI.docViewer.docHandle == C.hSceneDoc
+
# qtbot.stop()
diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py
index 3d5ede31..0a073aae 100644
--- a/tests/test_gui/test_gui_projtree.py
+++ b/tests/test_gui/test_gui_projtree.py
@@ -663,12 +663,22 @@ def testGuiProjTree_DeleteRequest(qtbot, caplog, monkeypatch, nwGUI, projPath, m
"Scene 3", "Scene 4",
]
+ # Opening documents before delete, will close them
+ nwGUI.openDocument(hScenes[2])
+ nwGUI.viewDocument(hScenes[3])
+
+ assert nwGUI.docEditor.docHandle == hScenes[2]
+ assert nwGUI.docViewer.docHandle == hScenes[3]
+
# Trash can be completely emptied
projTree.emptyTrash()
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "Chapter Folder", "Plot", "Characters", "Trash",
]
+ assert nwGUI.docEditor.docHandle is None
+ assert nwGUI.docViewer.docHandle is None
+
# Emptying empty trash pops an alert
projTree.emptyTrash()
assert SHARED.lastAlert == "The Trash folder is already empty."
@@ -1423,7 +1433,13 @@ def testGuiProjTree_Templates(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
assert nwNewCharacter.itemName == "Note"
assert project.storage.getDocument(hNewCharacter).readDocument() == "# Jane\n\n@tag: Jane\n\n"
- # Remove the templates
+ # Clearing the menu and rebuilding it should work
+ projBar.mTemplates.clearMenu()
+ assert len(projBar.mTemplates.actions()) == 0
+ projBar.buildTemplatesMenu()
+ assert len(projBar.mTemplates.actions()) == 2
+
+ # Remove the note template
assert projBar.mTemplates.menuAction().isVisible() is True
assert len(projBar.mTemplates.actions()) == 2
assert trash.childCount() == 0
diff --git a/tests/test_gui/test_gui_statusbar.py b/tests/test_gui/test_gui_statusbar.py
index 624f1f84..71d0d0d1 100644
--- a/tests/test_gui/test_gui_statusbar.py
+++ b/tests/test_gui/test_gui_statusbar.py
@@ -31,7 +31,7 @@ from tests.tools import C, buildTestProject
@pytest.mark.gui
-def testGuiStatusBar_Main(qtbot, nwGUI, projPath, mockRnd):
+def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test the the various features of the status bar."""
buildTestProject(nwGUI, projPath)
cHandle = SHARED.project.newFile("A Note", C.hCharRoot)
@@ -89,9 +89,13 @@ def testGuiStatusBar_Main(qtbot, nwGUI, projPath, mockRnd):
nwGUI._lastTotalCount = 0
nwGUI._updateStatusWordCount()
assert nwGUI.mainStatus.statsText.text() == "Words: 9 (+9)"
- CONFIG.incNotesWCount = True
- nwGUI._lastTotalCount = 0
- nwGUI._updateStatusWordCount()
- assert nwGUI.mainStatus.statsText.text() == "Words: 11 (+11)"
+
+ # Update again, but through time tick
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.guimain.time", lambda *a: 50.0)
+ CONFIG.incNotesWCount = True
+ nwGUI._lastTotalCount = 0
+ nwGUI._timeTick()
+ assert nwGUI.mainStatus.statsText.text() == "Words: 11 (+11)"
# qtbot.stop()