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/constants.py b/novelwriter/constants.py
index dc4a44eb..be575b5b 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
@@ -271,6 +274,10 @@ 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"),
+ }
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/coretools.py b/novelwriter/core/coretools.py
index e2bdd342..66bda582 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
@@ -270,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:
@@ -523,7 +509,7 @@ class ProjectBuilder:
# Also add the archive and trash folders
project.newRoot(nwItemClass.ARCHIVE)
- project.trashFolder()
+ project.tree.trash # Triggers the creation of Trash
project.saveProject()
project.closeProject()
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 812b15e2..382fae34 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -116,24 +116,24 @@ 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()
self._indexChange = 0.0
self._rootChange = {}
- SHARED.indexSignalProxy({"event": "clearIndex"})
+ SHARED.emitIndexCleared(self._project)
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)
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)
@@ -296,10 +293,7 @@ class NWIndex:
self._indexChange = nowTime
self._rootChange[tItem.itemRoot] = nowTime
if not blockSignal:
- SHARED.indexSignalProxy({
- "event": "scanText",
- "handle": tHandle,
- })
+ tItem.notifyToRefresh()
return True
@@ -370,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/item.py b/novelwriter/core/item.py
index c89af5ee..dc008650 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -27,8 +27,9 @@ import logging
from typing import TYPE_CHECKING, Any
-from PyQt5.QtGui import QIcon
+from PyQt5.QtGui import QFont, QIcon
+from novelwriter import CONFIG, SHARED
from novelwriter.common import (
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified,
yesNo
@@ -256,6 +257,8 @@ class NWItem:
self._paraCount = 0
self._cursorPos = 0
+ self._initCount = self._wordCount
+
return True
@classmethod
@@ -281,6 +284,15 @@ class NWItem:
cls._initCount = source._initCount
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
##
@@ -309,6 +321,19 @@ 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 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.
@@ -319,6 +344,19 @@ 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.
+ """
+ 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
##
@@ -553,8 +591,3 @@ class NWItem:
else:
self._cursorPos = 0
return
-
- def saveInitialCount(self) -> None:
- """Save the initial word count."""
- self._initCount = self._wordCount
- return
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
new file mode 100644
index 00000000..df4d33c1
--- /dev/null
+++ b/novelwriter/core/itemmodel.py
@@ -0,0 +1,518 @@
+"""
+novelWriter – Project Item Model
+================================
+
+File History:
+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
+
+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
+
+from PyQt5.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt
+from PyQt5.QtGui import QFont, QIcon
+
+from novelwriter.common import decodeMimeHandles, minmax
+from novelwriter.constants import nwConst
+from novelwriter.core.item import NWItem
+from novelwriter.enum import nwItemClass
+from novelwriter.types import QtAlignRight
+
+if TYPE_CHECKING: # pragma: no cover
+ from novelwriter.core.tree import NWTree
+
+logger = logging.getLogger(__name__)
+
+INV_ROOT = "invisibleRoot"
+C_FACTOR = 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
+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
+
+NODE_FLAGS = Qt.ItemFlag.ItemIsEnabled
+NODE_FLAGS |= Qt.ItemFlag.ItemIsSelectable
+NODE_FLAGS |= Qt.ItemFlag.ItemIsDropEnabled
+
+if TYPE_CHECKING: # pragma: no cover
+ # Requires Python 3.10
+ T_NodeData = str | QIcon | QFont | Qt.AlignmentFlag | None
+
+
+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
+ C_ACTIVE = 2
+ C_STATUS = 3
+
+ __slots__ = ("_item", "_children", "_parent", "_row", "_cache", "_flags", "_count")
+
+ def __init__(self, item: NWItem) -> None:
+ self._item = item
+ self._children: list[ProjectNode] = []
+ self._parent: ProjectNode | None = None
+ self._row = 0
+ self._cache: dict[int, T_NodeData] = {}
+ self._flags = NODE_FLAGS
+ self._count = 0
+ self.refresh()
+ self.updateCount()
+ return
+
+ def __repr__(self) -> str:
+ return (
+ f""
+ )
+
+ def __bool__(self) -> bool:
+ """A node should always evaluate to True."""
+ return True
+
+ ##
+ # Properties
+ ##
+
+ @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
+ ##
+
+ def refresh(self) -> None:
+ """Refresh data values."""
+ # 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
+
+ # Active
+ 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_TIP] = sText
+ self._cache[C_STATUS_ICON] = sIcon
+
+ 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):
+ parent.updateCount()
+ return
+
+ ##
+ # Data Access
+ ##
+
+ 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:
+ """Return cached node data."""
+ return self._cache.get(C_FACTOR*column | role)
+
+ def flags(self) -> Qt.ItemFlag:
+ """Return cached node flags."""
+ 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
+
+ def allChildren(self) -> list[ProjectNode]:
+ """Return a recursive list of all children."""
+ nodes: list[ProjectNode] = []
+ self._recursiveAppendChildren(nodes)
+ return nodes
+
+ ##
+ # Data Edit
+ ##
+
+ 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)
+ else:
+ child._row = len(self._children)
+ self._children.append(child)
+ self._refreshChildrenPos()
+ 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(pos)
+ self._refreshChildrenPos()
+ self.updateCount()
+ return node
+ return None
+
+ def moveChild(self, source: int, target: int) -> None:
+ """Move a child internally."""
+ count = len(self._children)
+ if (source != target) and (0 <= source < count) and (0 <= target <= count):
+ node = self._children.pop(source)
+ self._children.insert(target, node)
+ 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
+ ##
+
+ 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
+ child.item.setOrder(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)
+ child.item.setClassDefaults(self._item.itemClass)
+ child._flags = NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
+ else:
+ child.item.setParent(None)
+ child.item.setRoot(child.item.itemHandle)
+ child.item.setClassDefaults(child.item.itemClass)
+ return
+
+
+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")
+
+ def __init__(self, tree: NWTree) -> None:
+ super().__init__()
+ self._tree = tree
+ self._root = ProjectNode(NWItem(tree._project, INV_ROOT))
+ self._root.item.setName("Invisible Root")
+ logger.debug("Ready: ProjectModel")
+ return
+
+ def __del__(self) -> None: # pragma: no cover
+ logger.debug("Delete: ProjectModel")
+ return
+
+ ##
+ # Properties
+ ##
+
+ @property
+ def root(self) -> ProjectNode:
+ """Return the model root item."""
+ return self._root
+
+ ##
+ # Model Interface
+ ##
+
+ 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() 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 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 index.isValid():
+ 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 decodeMimeHandles(data):
+ if (index := self.indexFromHandle(handle)).isValid():
+ items.append(index)
+ self.multiMove(items, parent, row)
+ return True
+ return False
+
+ ##
+ # Data Access
+ ##
+
+ def row(self, index: QModelIndex) -> int:
+ """Return the row number of the index."""
+ if index.isValid():
+ return index.internalPointer().row()
+ return -1
+
+ def node(self, index: QModelIndex) -> ProjectNode | None:
+ """Return the node for a given model index."""
+ if index.isValid():
+ 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)):
+ return self.createIndex(node.row(), 0, node)
+ return QModelIndex()
+
+ def indexFromNode(self, node: ProjectNode, column: int = 0) -> QModelIndex:
+ """Get the index representing a node in the model."""
+ return self.createIndex(node.row(), column, node)
+
+ ##
+ # Model Edit
+ ##
+
+ def insertChild(self, child: ProjectNode, parent: QModelIndex, pos: int) -> None:
+ """Insert a node into the model at a given position."""
+ 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)
+ node.addChild(child, row)
+ 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():
+ node: ProjectNode = index.internalPointer()
+ if parent := node.parent():
+ pos = index.row()
+ 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 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
+ # 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 (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()):
+ self.insertChild(temp, target, pos)
+ for child in reversed(node.allChildren()):
+ node._updateRelationships(child)
+ child.item.notifyToRefresh()
+ node.item.notifyToRefresh()
+ return
+
+ ##
+ # 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 = []
+ for node in self._root.allChildren():
+ 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 69aaadcf..013a0501 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -26,7 +26,6 @@ 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
@@ -52,7 +51,8 @@ from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
if TYPE_CHECKING: # pragma: no cover
- from novelwriter.core.item import NWItem
+ # Requires Python 3.10
+ from novelwriter.core.status import T_StatusKind, T_UpdateEntry
logger = logging.getLogger(__name__)
@@ -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
@@ -94,6 +99,12 @@ class NWProject:
logger.debug("Delete: NWProject")
return
+ def clear(self) -> None:
+ """Clear the project."""
+ self._tree.clear()
+ self._index.clear()
+ return
+
##
# Properties
##
@@ -150,24 +161,46 @@ 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
##
- def newRoot(self, itemClass: nwItemClass, label: str | None = None) -> str:
+ 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.
"""
- 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 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):
+ SHARED.closeDocument(tHandle)
+ 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
@@ -209,35 +242,21 @@ 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
- 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):
- delDoc = self._storage.getDocument(tHandle)
- if not delDoc.deleteDocument():
- SHARED.error(
- self.tr("Could not delete document file."),
- info=delDoc.getError()
- )
- return False
-
- self._index.deleteHandle(tHandle)
- del self._tree[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 trashHandle
+ 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
@@ -349,7 +368,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()
@@ -414,12 +433,11 @@ 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)
self._storage.closeSession()
- self._lockedBy = None
return
def backupProject(self, doNotify: bool) -> bool:
@@ -489,17 +507,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.
@@ -513,47 +520,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()
@@ -574,6 +540,18 @@ class NWProject:
self._data.itemImport.increment(nwItem.itemImport)
return
+ def updateStatus(self, kind: T_StatusKind, update: T_UpdateEntry) -> None:
+ """Update status or import entries."""
+ if kind == "s":
+ self._data.itemStatus.update(update)
+ SHARED.emitStatusLabelsChanged(self, kind)
+ self._tree.refreshAllItems()
+ elif kind == "i":
+ self._data.itemImport.update(update)
+ SHARED.emitStatusLabelsChanged(self, 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 de017f51..fa3a3dfc 100644
--- a/novelwriter/core/status.py
+++ b/novelwriter/core/status.py
@@ -65,6 +65,11 @@ class StatusEntry:
NO_ENTRY = StatusEntry("", QColor(0, 0, 0), nwStatusShape.SQUARE, QIcon(), 0)
+if TYPE_CHECKING: # pragma: no cover
+ # Requires Python 3.10
+ T_UpdateEntry = list[tuple[str | None, StatusEntry]]
+ T_StatusKind = Literal["s", "i"]
+
class NWStatus:
@@ -73,7 +78,7 @@ class NWStatus:
__slots__ = ("_store", "_default", "_prefix", "_height")
- def __init__(self, prefix: Literal["s", "i"]) -> None:
+ def __init__(self, prefix: T_StatusKind) -> None:
self._store: dict[str, StatusEntry] = {}
self._default = None
self._prefix = prefix[:1]
@@ -120,7 +125,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,9 +135,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
- SHARED.projectSingalProxy({"event": "statusLabels", "kind": self._prefix})
-
return
def check(self, value: str) -> str:
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 01131b93..c845f88c 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
@@ -30,10 +31,13 @@ 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 PyQt5.QtCore import QModelIndex
+
+from novelwriter import SHARED
+from novelwriter.constants import nwFiles, nwLabels, trConst
from novelwriter.core.item import NWItem
-from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
+from novelwriter.core.itemmodel import ProjectModel, ProjectNode
+from novelwriter.enum import nwChange, nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
if TYPE_CHECKING: # pragma: no cover
@@ -41,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:
@@ -51,30 +55,51 @@ 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.
"""
- __slots__ = ("_project", "_tree", "_order", "_roots", "_trash", "_changed")
+ __slots__ = ("_project", "_model", "_items", "_nodes", "_trash")
def __init__(self, project: NWProject) -> None:
-
self._project = project
+ self._model = ProjectModel(self)
+ self._items: dict[str, NWItem] = {}
+ self._nodes: dict[str, ProjectNode] = {}
+ self._trash = None
+ logger.debug("Ready: NWTree")
+ return
- 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
+ def __del__(self) -> None: # pragma: no cover
+ logger.debug("Delete: NWTree")
+ return
- self._trash = None # The handle of the trash root folder
- self._changed = False # True if tree structure has changed
+ 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
##
@@ -82,9 +107,19 @@ class NWTree:
##
@property
- def trashRoot(self) -> str | None:
- """Return the handle of the trash folder, or None."""
- return self._trash
+ def trash(self) -> ProjectNode | None:
+ """Return trash node, if it exists."""
+ if self._trash:
+ return self._trash
+ return self._getTrashNode()
+
+ @property
+ def model(self) -> ProjectModel:
+ return self._model
+
+ @property
+ def nodes(self) -> dict[str, ProjectNode]:
+ return self._nodes
##
# Class Methods
@@ -92,83 +127,91 @@ class NWTree:
def clear(self) -> None:
"""Clear the item tree entirely."""
- self._tree = {}
- self._order = []
- self._roots = {}
- self._trash = None
- self._changed = False
+ oldModel = self._model
+ oldModel.clear()
+ self._model = ProjectModel(self)
+ self._items.clear()
+ self._nodes.clear()
+ self._trash = None
+ oldModel.deleteLater()
+ del oldModel
return
- def handles(self) -> list[str]:
- """Returns a copy of the list of all the active handles."""
- return self._order.copy()
+ 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
+ self._itemChange(item, nwChange.CREATE)
+ 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
+ self._itemChange(item, nwChange.CREATE)
+ else:
+ logger.error("Invalid project item '%s'", item.itemHandle)
+ 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()):
+ self._itemChange(node.item, nwChange.DELETE)
+ del self._nodes[tHandle]
+ del self._items[tHandle]
+ return True
+ return False
@overload # pragma: no cover
- def create(self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
- itemClass: nwItemClass) -> str:
+ 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) -> str | None:
+ 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, parent, itemType, itemClass=nwItemClass.NO_CLASS):
+ 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:
+ if parent is None or parent in self._nodes:
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
+ nwItem = NWItem(self._project, tHandle)
+ nwItem.setName(label)
+ nwItem.setParent(parent)
+ nwItem.setType(itemType)
+ nwItem.setClass(itemClass)
+ if self.add(nwItem, pos):
+ 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:
+ 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):
+ 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
@@ -177,23 +220,62 @@ 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)
+ )
+ 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
project tree.
"""
self.clear()
+ items: dict[str, NWItem] = self._items.copy()
for item in data:
- nwItem = NWItem(self._project, "") # Handle is set by unpack()
+ nwItem = NWItem(self._project, "")
if nwItem.unpack(item):
- self.append(nwItem)
- nwItem.saveInitialCount()
+ items[nwItem.itemHandle] = nwItem
+
+ later = items
+ self._model.beginInsertRows(self._model.index(0, 0), 0, 0)
+ for _ in range(MAX_DEPTH):
+ later = self._addItems(later)
+ if len(later) == 0:
+ break
+ else:
+ logger.error("Not all items could be added to project tree")
+
+ self._trash = self._getTrashNode()
+ self._model.endInsertRows()
+ self._model.layoutChanged.emit()
+
+ return
+
+ 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.
+ """
+ for tHandle in items:
+ if node := self._nodes.get(tHandle):
+ node.refresh()
+ node.updateCount()
+ indexS = self._model.indexFromNode(node, 0)
+ indexE = self._model.indexFromNode(node, 3)
+ self._model.dataChanged.emit(indexS, indexE)
+ self._itemChange(node.item, nwChange.UPDATE)
+ 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.root.refresh()
+ self._model.root.updateCount(propagate=False)
+ self._model.layoutChanged.emit()
return
def checkConsistency(self, prefix: str) -> tuple[int, int]:
@@ -205,29 +287,21 @@ class NWTree:
mark recovered files.
"""
storage = self._project.storage
- files = set(storage.scanContent())
- for tHandle in self._order:
- 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()
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
@@ -248,8 +322,7 @@ class NWTree:
newItem.setType(nwItemType.FILE)
newItem.setClass(oClass)
newItem.setLayout(oLayout)
- if self.append(newItem):
- self.updateItemData(cHandle)
+ if self.add(newItem):
recovered += 1
return orphans, recovered
@@ -263,38 +336,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,
+ f"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")
@@ -307,74 +375,46 @@ 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
##
# 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.
- """
- 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
- else:
- iItem = self.__getitem__(iItem.itemParent)
- if iItem is None:
- return False
- else:
- raise RecursionError("Critical internal error")
-
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")
+ logger.error("Max project tree depth reached")
+ return path
- 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
@@ -383,139 +423,80 @@ 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:
- """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 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
- ##
- # 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
- ##
-
- def __len__(self) -> int:
- """The number of items in the project."""
- return len(self._order)
-
- def __bool__(self) -> bool:
- """True if there are any items in the project."""
- return bool(self._order)
-
- 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]
- 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
-
- self._setTreeChanged(True)
-
- return
-
- def __contains__(self, tHandle: str) -> bool:
- """Checks if a handle exists in the tree."""
- return tHandle in self._order
-
- 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
- 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.
- """
- self._changed = state
- if state:
- self._project.setProjectChanged(True)
+ 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.emitProjectItemChanged(self._project, tHandle, change)
+ if item.isRootType():
+ SHARED.emitRootFolderChanged(self._project, tHandle, 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:
+ 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.
+ """
+ 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)
+ parent.updateCount()
+ self._items[handle] = item
+ self._nodes[handle] = node
+ elif pHandle in items:
+ remains[handle] = item
+ logger.warning("Item '%s' found before its parent", handle)
+ 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
+
def _makeHandle(self) -> str:
"""Generate a unique item handle. In the event that the key
already exists, generate a new one.
"""
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()
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/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/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/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/doceditor.py b/novelwriter/gui/doceditor.py
index 3ddd7731..a77869ca 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,12 +53,12 @@ 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 (
- 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
@@ -109,13 +110,13 @@ class GuiDocEditor(QPlainTextEdit):
# Custom Signals
closeEditorRequest = pyqtSignal()
- docCountsChanged = pyqtSignal(str, int, int, int)
docTextChanged = pyqtSignal(str, float)
editedStatusChanged = pyqtSignal(bool)
itemHandleChanged = pyqtSignal(str)
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 +192,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 +999,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
@@ -1036,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()
@@ -1252,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
@@ -2985,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 6745db9d..9e1b6f5f 100644
--- a/novelwriter/gui/docviewer.py
+++ b/novelwriter/gui/docviewer.py
@@ -31,16 +31,19 @@ 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.enum import nwDocAction, nwDocMode, nwItemType
+from novelwriter.common import decodeMimeHandles, qtLambda
+from novelwriter.constants import nwConst, nwStyles, nwUnicode
+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
@@ -343,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
@@ -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
##
@@ -777,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/novelwriter/gui/docviewerpanel.py b/novelwriter/gui/docviewerpanel.py
index 443ba678..91c4eb61 100644
--- a/novelwriter/gui/docviewerpanel.py
+++ b/novelwriter/gui/docviewerpanel.py
@@ -29,18 +29,18 @@ 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
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, QtUserRole
+from novelwriter.types import QtDecoration, QtHeaderFixed, QtHeaderToContents, QtUserRole
logger = logging.getLogger(__name__)
@@ -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()
@@ -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)
@@ -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)
@@ -407,8 +403,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)
@@ -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..77df5acf 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,19 +223,9 @@ 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()
- return
-
- nwItem = SHARED.project.tree[tHandle]
- if nwItem is None:
+ if not (tHandle and (nwItem := SHARED.project.tree[tHandle])):
self.clearDetails()
return
@@ -269,10 +262,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
@@ -289,13 +279,16 @@ class GuiItemDetails(QWidget):
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.
- """
+ ##
+ # Public Slots
+ ##
+
+ @pyqtSlot(str, Enum)
+ def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
+ """Process project item change."""
if tHandle == self._handle:
- self.cCountData.setText(f"{cC:n}")
- self.wCountData.setText(f"{wC:n}")
- self.pCountData.setText(f"{pC:n}")
+ if change == nwChange.UPDATE:
+ self.updateViewBox(tHandle)
+ elif change == nwChange.DELETE:
+ self.updateViewBox(None)
return
diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py
index afecb30a..0b2d93b9 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
@@ -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()
@@ -337,12 +338,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/noveltree.py b/novelwriter/gui/noveltree.py
index 795622c0..3358738f 100644
--- a/novelwriter/gui/noveltree.py
+++ b/novelwriter/gui/noveltree.py
@@ -33,22 +33,22 @@ 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
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
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__)
@@ -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
@@ -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/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 5aaa48b4..2ca5eaf9 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -3,10 +3,12 @@ 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.6b2] GuiProjectTree
+Rewritten: 2024-11-20 [2.6b2] _TreeContextMenu
This file is a part of novelWriter
Copyright 2018–2024, Veronica Berglyd Olsen
@@ -30,28 +32,29 @@ 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, QMouseEvent, QPainter, QPalette
from PyQt5.QtWidgets import (
- QAbstractItemView, QAction, QFrame, QHBoxLayout, QHeaderView, QLabel,
- QMenu, QShortcut, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
+ QAbstractItemView, QAction, QFrame, QHBoxLayout, QLabel, QMenu, QShortcut,
+ QStyleOptionViewItem, QTreeView, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
-from novelwriter.common import minmax, qtLambda
+from novelwriter.common import qtLambda
from novelwriter.constants import nwLabels, nwStyles, nwUnicode, trConst
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
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 (
- QtAlignLeft, QtAlignRight, QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff,
- QtScrollAsNeeded, QtSizeExpanding, QtUserRole
+ QtHeaderFixed, QtHeaderStretch, QtHeaderToContents, QtMouseLeft,
+ QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded, QtSizeExpanding
)
logger = logging.getLogger(__name__)
@@ -63,17 +66,9 @@ class GuiProjectView(QWidget):
available are mapped through to the project tree class.
"""
- # Signals triggered when the meta data values of items change
- treeItemChanged = pyqtSignal(str)
- rootFolderChanged = pyqtSignal(str)
- wordCountsChanged = pyqtSignal()
-
- # 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)
@@ -96,32 +91,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.goToSiblingUp)
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.goToSiblingDown)
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.goToParent)
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.goToFirstChild)
self.keyContext = QShortcut(self.projTree)
self.keyContext.setKey("Ctrl+.")
@@ -130,12 +125,9 @@ 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
- self.emptyTrash = self.projTree.emptyTrash
- self.requestDeleteItem = self.projTree.requestDeleteItem
self.getSelectedHandle = self.projTree.getSelectedHandle
return
@@ -147,7 +139,6 @@ class GuiProjectView(QWidget):
def updateTheme(self) -> None:
"""Update theme elements."""
self.projBar.updateTheme()
- self.populateTree()
return
def initSettings(self) -> None:
@@ -164,21 +155,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.buildTree()
- return
-
def setTreeFocus(self) -> None:
"""Forward the set focus call to the tree widget."""
self.projTree.setFocus()
@@ -188,10 +170,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.processDeleteRequest)
+ 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
@@ -199,8 +191,11 @@ 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)
+ nwItem.notifyToRefresh()
return
@pyqtSlot(str, bool)
@@ -215,11 +210,10 @@ class GuiProjectView(QWidget):
self.projTree.setActiveHandle(tHandle)
return
- @pyqtSlot(str)
- def updateItemValues(self, tHandle: str) -> None:
- """Update tree item."""
- if nwItem := SHARED.project.tree[tHandle]:
- self.projTree.setTreeItemValues(nwItem)
+ @pyqtSlot(str, Enum)
+ def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
+ """Refresh other content when project item changed."""
+ self.projBar.processTemplateDocuments(tHandle)
return
@pyqtSlot(str)
@@ -229,31 +223,12 @@ 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:
+ @pyqtSlot(str, Enum)
+ def updateRootItem(self, tHandle: str, change: nwChange) -> None:
"""Process root item changes."""
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."""
- self.projTree.refreshUserLabels(kind)
- return
-
class GuiProjectToolBar(QWidget):
@@ -290,11 +265,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)
@@ -341,17 +316,13 @@ 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))
+ self.aEmptyTrash.triggered.connect(self.projTree.emptyTrash)
self.tbMore = NIconToolButton(self, iSz)
self.tbMore.setToolTip(self.tr("More Options"))
@@ -404,6 +375,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()
@@ -428,19 +400,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
@@ -486,18 +465,7 @@ class GuiProjectToolBar(QWidget):
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
-
- itemRefreshed = pyqtSignal(str, NWItem, QIcon)
+class GuiProjectTree(QTreeView):
def __init__(self, projView: GuiProjectView) -> None:
super().__init__(parent=projView)
@@ -507,25 +475,14 @@ class GuiProjectTree(QTreeWidget):
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)
+ self.trActive = trConst(nwLabels.ACTIVE_NAME["checked"])
+ self.trInactive = trConst(nwLabels.ACTIVE_NAME["unchecked"])
# Tree Settings
iPx = SHARED.theme.baseIconHeight
- cMg = CONFIG.pxInt(6)
self.setIconSize(SHARED.theme.baseIconSize)
self.setFrameStyle(QFrame.Shape.NoFrame)
@@ -535,47 +492,24 @@ class GuiProjectTree(QTreeWidget):
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)
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)
-
- # 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)
- # Connect signals
- self.itemDoubleClicked.connect(self._treeDoubleClick)
- self.itemSelectionChanged.connect(self._treeSelectionChange)
+ # Context Menu
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.customContextMenuRequested.connect(self.openContextMenu)
- # Auto Scroll
- self._scrollMargin = SHARED.theme.baseIconHeight
- self._scrollDirection = 0
- self._scrollTimer = QTimer(self)
- self._scrollTimer.timeout.connect(self._doAutoScroll)
- self._scrollTimer.setInterval(250)
+ # Connect signals
+ self.clicked.connect(self._onSingleClick)
+ self.doubleClicked.connect(self._onDoubleClick)
+ self.collapsed.connect(self._onNodeCollapsed)
+ self.expanded.connect(self._onNodeExpanded)
# Set custom settings
self.initSettings()
@@ -598,30 +532,73 @@ class GuiProjectTree(QTreeWidget):
return
##
- # Class Methods
+ # 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])):
+ return node.item.itemHandle
+ return None
+
+ ##
+ # Module Internal Methods
##
def clearTree(self) -> None:
- """Clear the GUI content and the related map."""
- self.clear()
- self._treeMap = {}
+ """Clear the tree view."""
+ self.setModel(None)
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)
+ def loadModel(self) -> None:
+ """Load and prepare a new project model."""
+ 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(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()
+
return
- def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None,
- hLevel: int = 1, isNote: bool = False, copyDoc: str | None = None) -> bool:
+ 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():
+ self.setCurrentIndex(index)
+ if doScroll:
+ self.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)
+ 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
@@ -629,643 +606,196 @@ class GuiProjectTree(QTreeWidget):
"""
if not SHARED.hasProject:
logger.error("No project open")
- return False
+ return
- 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
+ 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
+
+ tHandle = SHARED.project.newRoot(itemClass, pos)
+ self.restoreExpandedState()
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:
+ 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 False
+ 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
- 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
+ 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 pItem.isDocumentLayout()
+ 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 pItem.isDocumentLayout() and sLevel < 2
+ asChild = sIsParent and node.item.isDocumentLayout() and sLevel < 2
elif hLevel == 3:
newLabel = self.tr("New Scene")
- asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 3
+ asChild = sIsParent and node.item.isDocumentLayout() and sLevel < 3
else:
newLabel = self.tr("New Document")
- asChild = sIsParent and pItem.isDocumentLayout()
+ asChild = sIsParent and node.item.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
+ pos = -1
+ sHandle = None
+ if not (asChild or node.item.isFolderType() or node.item.isRootType()):
+ pos = node.row() + 1
+ sHandle = node.item.itemParent
- # Ask for label
+ sHandle = sHandle or node.item.itemHandle
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."""
- 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 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 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)
+ # Add the file or folder
+ if itemType == nwItemType.FILE:
+ 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)
+
+ # Select the new item automatically
+ if tHandle:
+ self.setSelectedHandle(tHandle)
+
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 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)
- 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
- permanent deletion or moving the item to Trash.
- """
- if not SHARED.hasProject:
- logger.error("No project open")
+ if not (tItem := SHARED.project.tree[tHandle]):
return False
- if not self.hasFocus():
- logger.info("Delete action blocked due to no widget focus")
+ if tItem.isRootType():
+ logger.error("Cannot merge root item")
return False
- if tHandle is None:
- tHandle = self.getSelectedHandle()
+ itemList = SHARED.project.tree.subTree(tHandle)
+ if newFile:
+ itemList.insert(0, tHandle)
- if tHandle is None:
- logger.error("There is no item to delete")
+ 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
- trashHandle = SHARED.project.tree.trashRoot
- if tHandle == trashHandle:
- logger.error("Cannot delete the Trash folder")
- return False
+ # Save the open document first, in case it's part of merge
+ SHARED.saveEditor()
- nwItem = SHARED.project.tree[tHandle]
- if nwItem is None:
- return False
+ # Create merge object, and append docs
+ docMerger = DocMerger(SHARED.project)
+ mLabel = self.tr("Merged")
- if SHARED.project.tree.isTrash(tHandle) or nwItem.isRootType():
- status = self.permDeleteItem(tHandle)
+ if newFile:
+ docMerger.newTargetDoc(tHandle, f"[{mLabel}] {tItem.itemName}")
+ elif tItem.isFileType():
+ docMerger.setTargetDoc(tHandle)
else:
- status = self.moveItemToTrash(tHandle)
-
- return status
-
- @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
+ for sHandle in items:
+ docMerger.appendText(sHandle, True, mLabel)
- logger.debug("Emptying Trash folder")
- if trashHandle is None:
- SHARED.info(self.tr("There is currently no Trash folder in this project."))
+ if not docMerger.writeTargetDoc():
+ SHARED.error(
+ self.tr("Could not write document content."),
+ info=docMerger.getError()
+ )
return False
- trashItems = self.getTreeFromHandle(trashHandle)
- if trashHandle in trashItems:
- trashItems.remove(trashHandle)
+ if data.get("moveToTrash", False):
+ self.processDeleteRequest(data.get("finalItems", []), False)
- nTrash = len(trashItems)
- if nTrash == 0:
- SHARED.info(self.tr("The Trash folder is already empty."))
+ 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 SHARED.question(self.trPermDelete.format(nTrash)):
+ 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
- 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 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.
- """
- 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)
+ 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:
- if askFirst:
- msgYes = SHARED.question(
- self.tr("Permanently delete '{0}'?").format(nwItemS.itemName)
+ 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 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)
+ if data.get("moveToTrash", False):
+ self.processDeleteRequest([tHandle], False)
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)
- 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)
+ 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:
- 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
- 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
-
- 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 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()
- 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:
- """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)
+ 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."))
+ self.restoreExpandedState()
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
- ##
-
- @pyqtSlot()
- def _treeSelectionChange(self) -> None:
- """The user changed which item is selected."""
- tHandle = self.getSelectedHandle()
- if tHandle is not None:
- self.projView.selectedItemChanged.emit(tHandle)
-
- # 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:
- """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.
- """
- 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())
-
- 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()
- return
-
- ##
- # Events
+ # Events and Overloads
##
def mousePressEvent(self, event: QMouseEvent) -> None:
@@ -1275,395 +805,239 @@ class GuiProjectTree(QTreeWidget):
"""
super().mousePressEvent(event)
if event.button() == QtMouseLeft:
- selItem = self.indexAt(event.pos())
- if not selItem.isValid():
- self.clearSelection()
+ if not self.indexAt(event.pos()).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)
+ if (node := self._getNode(self.indexAt(event.pos()))) and node.item.isFileType():
+ self.projView.openDocumentRequest.emit(
+ node.item.itemHandle, 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
+ 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
- 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)
+ ##
+ # 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 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()):
+ self.setCurrentIndex(model.indexFromNode(move))
+ return
+
+ @pyqtSlot()
+ 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()):
+ self.setCurrentIndex(model.indexFromNode(move))
+ return
+
+ @pyqtSlot()
+ def goToParent(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 goToFirstChild(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
+
+ @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, 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]
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()
+ indices = self._selectedRows()
+
+ 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(indices):
+ if not SHARED.question(self.tr("Permanently delete selected item(s)?")):
+ logger.info("Action cancelled by user")
+ return
+ 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(indices, model.indexFromNode(trashNode))
+
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.
+ @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.
"""
- 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
+ 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
- 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)
+ @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()
- super().dropEvent(event)
+ 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()
- for mHandle, (sItem, isExpanded) in mItems.items():
- self._postItemMove(mHandle)
- sItem.setExpanded(isExpanded)
- self._alertTreeChange(mHandle, flush=False)
+ ctxMenu.exec(self.viewport().mapToGlobal(point))
+ ctxMenu.deleteLater()
- self.saveTreeOrder()
+ return
+ ##
+ # Private Slots
+ ##
+
+ @pyqtSlot(QModelIndex)
+ 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 _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.
+ """
+ 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
+
+ @pyqtSlot(QModelIndex)
+ def _onNodeCollapsed(self, index: QModelIndex) -> None:
+ """Capture a node collapse, and pass it to the model."""
+ if node := self._getNode(index):
+ 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.setExpanded(True)
return
##
# 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)
-
+ def _clearSelection(self) -> None:
+ """Clear the currently selected items."""
+ self.clearSelection()
+ self.selectionModel().clearCurrentIndex()
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
+ def _selectedRows(self) -> list[QModelIndex]:
+ """Return all column 0 indexes."""
+ return [i for i in self.selectedIndexes() if i.column() == 0]
- 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 _getModel(self) -> ProjectModel | None:
+ """Return a project node corresponding to a model index."""
+ if isinstance(model := self.model(), ProjectModel):
+ return model
+ return None
- 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)
- 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
-
- 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
-
- 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
-
- 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
+ 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
class _UpdatableMenu(QMenu):
@@ -1731,18 +1105,22 @@ class _UpdatableMenu(QMenu):
class _TreeContextMenu(QMenu):
- def __init__(self, projTree: GuiProjectTree, nwItem: NWItem) -> None:
+ __slots__ = ("_tree", "_view", "_node", "_item", "_model", "_handle", "_indices", "_children")
+
+ def __init__(
+ self, projTree: GuiProjectTree, model: ProjectModel,
+ node: ProjectNode, indices: list[QModelIndex]
+ ) -> None:
super().__init__(parent=projTree)
-
- self.projTree = projTree
- self.projView = projTree.projView
-
- self._item = nwItem
- self._handle = nwItem.itemHandle
- self._items: list[NWItem] = []
-
+ 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
def __del__(self) -> None: # pragma: no cover
@@ -1756,14 +1134,15 @@ class _TreeContextMenu(QMenu):
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)
+ action.triggered.connect(self._tree.emptyTrash)
+ if self._children:
+ self._expandCollapse()
return
- def buildSingleSelectMenu(self, hasChild: bool) -> None:
+ def buildSingleSelectMenu(self) -> None:
"""Build the single-select menu."""
isFile = self._item.isFileType()
isFolder = self._item.isFolderType()
- isRoot = self._item.isRootType()
# Document Actions
if isFile:
@@ -1776,33 +1155,32 @@ 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._view.renameTreeItem, self._handle))
if isFile:
self._itemHeader()
- self._itemActive(False)
+ self._itemActive()
self._itemStatusImport(False)
# Transform Item
if isFile or isFolder:
- self._itemTransform(isFile, isFolder, hasChild)
+ self._itemTransform(isFile, isFolder)
self.addSeparator()
# Process Item
- self._itemProcess(isFile, isFolder, isRoot, hasChild)
+ if self._children:
+ self._expandCollapse()
+ action = self.addAction(self.tr("Duplicate"))
+ action.triggered.connect(qtLambda(self._tree.duplicateFromHandle, self._handle))
+ self._deleteOrTrash()
return
- def buildMultiSelectMenu(self, handles: list[str]) -> None:
+ def buildMultiSelectMenu(self) -> 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._itemActive()
self._itemStatusImport(True)
self.addSeparator()
- self._multiMoveToTrash()
+ self._deleteOrTrash()
return
##
@@ -1813,12 +1191,12 @@ class _TreeContextMenu(QMenu):
"""Add document actions."""
action = self.addAction(self.tr("Open Document"))
action.triggered.connect(qtLambda(
- self.projView.openDocumentRequest.emit,
+ self._view.openDocumentRequest.emit,
self._handle, nwDocMode.EDIT, "", True
))
action = self.addAction(self.tr("View Document"))
action.triggered.connect(qtLambda(
- self.projView.openDocumentRequest.emit,
+ self._view.openDocumentRequest.emit,
self._handle, nwDocMode.VIEW, "", False
))
return
@@ -1826,11 +1204,11 @@ class _TreeContextMenu(QMenu):
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)
+ 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
def _itemHeader(self) -> None:
@@ -1839,17 +1217,17 @@ 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._view.renameTreeItem, self._handle, hItem.title)
)
return
- def _itemActive(self, multi: bool) -> None:
+ def _itemActive(self) -> None:
"""Add Active/Inactive actions."""
- if multi:
+ if len(self._indices) > 1:
mSub = self.addMenu(self.tr("Set Active to ..."))
- aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self.projTree.trActive)
+ 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.projTree.trInactive)
+ 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"))
@@ -1873,7 +1251,7 @@ class _TreeContextMenu(QMenu):
menu.addSeparator()
action = menu.addAction(self.tr("Manage Labels ..."))
action.triggered.connect(qtLambda(
- self.projView.projectSettingsRequest.emit,
+ self._view.projectSettingsRequest.emit,
GuiProjectSettings.PAGE_STATUS
))
else:
@@ -1891,18 +1269,15 @@ class _TreeContextMenu(QMenu):
menu.addSeparator()
action = menu.addAction(self.tr("Manage Labels ..."))
action.triggered.connect(qtLambda(
- self.projView.projectSettingsRequest.emit,
+ self._view.projectSettingsRequest.emit,
GuiProjectSettings.PAGE_IMPORT
))
return
- def _itemTransform(self, isFile: bool, isFolder: bool, hasChild: bool) -> None:
+ def _itemTransform(self, isFile: bool, isFolder: bool) -> None:
"""Add actions for the Transform menu."""
menu = self.addMenu(self.tr("Transform ..."))
- tree = self.projTree
- tHandle = self._handle
-
trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT])
trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE])
loDoc = nwItemLayout.DOCUMENT
@@ -1920,151 +1295,120 @@ class _TreeContextMenu(QMenu):
if isFolder and self._item.documentAllowed():
action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
- action.triggered.connect(qtLambda(self._covertFolderToFile, loDoc))
+ action.triggered.connect(qtLambda(self._convertFolderToFile, loDoc))
if isFolder:
action = menu.addAction(self.tr("Convert to {0}").format(trNote))
- action.triggered.connect(qtLambda(self._covertFolderToFile, loNote))
+ action.triggered.connect(qtLambda(self._convertFolderToFile, loNote))
- if hasChild and isFile:
+ if self._children and isFile:
action = menu.addAction(self.tr("Merge Child Items into Self"))
- action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, False))
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, False))
action = menu.addAction(self.tr("Merge Child Items into New"))
- action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, True))
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, True))
- if hasChild and isFolder:
+ if self._children and isFolder:
action = menu.addAction(self.tr("Merge Documents in Folder"))
- action.triggered.connect(qtLambda(tree._mergeDocuments, tHandle, True))
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, True))
if isFile:
action = menu.addAction(self.tr("Split Document by Headings"))
- action.triggered.connect(qtLambda(tree._splitDocument, tHandle))
+ action.triggered.connect(qtLambda(self._tree.splitDocument, self._handle))
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))
-
- 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))
-
+ 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
- def _multiMoveToTrash(self) -> None:
+ def _deleteOrTrash(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)
+ 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")
+ action = self.addAction(text)
+ action.triggered.connect(self._tree.processDeleteRequest)
return
##
# 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 _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)
+ if self._item.isFileType():
+ self._item.setActive(not self._item.isActive)
+ self._item.notifyToRefresh()
return
##
# Internal Functions
##
- def _iterItemActive(self, isActive: bool) -> None:
+ def _iterItemActive(self, state: 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)
+ 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
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)
+ if self._item.isFileType():
+ self._item.setStatus(key)
+ self._item.notifyToRefresh()
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)
+ 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
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)
+ if self._item.isFileType():
+ self._item.setImport(key)
+ self._item.notifyToRefresh()
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)
+ 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
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)
+ self._item.notifyToRefresh()
elif itemLayout == nwItemLayout.NOTE:
self._item.setLayout(nwItemLayout.NOTE)
- self.projTree.setTreeItemValues(self._item)
- self.projTree._alertTreeChange(self._handle, flush=False)
+ self._item.notifyToRefresh()
return
- def _covertFolderToFile(self, itemLayout: nwItemLayout) -> None:
+ def _convertFolderToFile(self, itemLayout: nwItemLayout) -> None:
"""Convert a folder to a note or document."""
if self._item.isFolderType():
msgYes = SHARED.question(self.tr(
@@ -2074,13 +1418,11 @@ class _TreeContextMenu(QMenu):
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)
+ self._item.notifyToRefresh()
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)
+ self._item.notifyToRefresh()
else:
logger.info("Folder conversion cancelled")
return
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index 65f23a44..cbe66442 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()
@@ -331,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/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/novelwriter/guimain.py b/novelwriter/guimain.py
index 1bb744f6..8d83bd92 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__)
@@ -212,15 +213,19 @@ 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.indexScannedText.connect(self.projView.updateItemValues)
SHARED.mainClockTick.connect(self._timeTick)
+ 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.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)
- SHARED.statusLabelsChanged.connect(self.projView.refreshUserLabels)
self.mainMenu.requestDocAction.connect(self._passDocumentAction)
self.mainMenu.requestDocInsert.connect(self._passDocumentInsert)
@@ -233,15 +238,7 @@ 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.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)
self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
@@ -250,8 +247,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)
@@ -259,7 +254,8 @@ 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.requestNewNoteCreation.connect(self.projView.createNewNote)
+ self.docEditor.openDocumentRequest.connect(self._openDocument)
+ 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)
@@ -297,14 +293,25 @@ 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)
+ # Internal Variables
+ self._lastTotalCount = 0
+
# Initialise Main GUI
self.initMain()
self.asProjTimer.start()
@@ -502,11 +509,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
@@ -718,8 +723,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
@@ -730,9 +738,8 @@ class GuiMain(QMainWindow):
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
tStart = time()
- self.projView.saveProjectTasks()
- SHARED.project.index.rebuildIndex()
- self.projView.populateTree()
+ SHARED.project.index.rebuild()
+ SHARED.project.tree.refreshAllItems()
self.novelView.refreshTree()
tEnd = time()
@@ -1050,7 +1057,7 @@ class GuiMain(QMainWindow):
))
if tree:
- self.projView.populateTree()
+ SHARED.project.tree.refreshAllItems()
if theme:
# We are doing this manually instead of connecting to
@@ -1076,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
@@ -1213,8 +1223,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()
@@ -1242,15 +1254,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
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index a42c0ffa..3b0cae22 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,9 +38,11 @@ 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 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
@@ -55,15 +58,16 @@ class SharedData(QObject):
"_idleTime", "_idleRefTime",
)
+ focusModeChanged = pyqtSignal(bool)
+ indexAvailable = pyqtSignal()
+ indexChangedTags = pyqtSignal(list, list)
+ indexCleared = pyqtSignal()
+ mainClockTick = pyqtSignal()
+ projectItemChanged = pyqtSignal(str, Enum)
+ rootFolderChanged = pyqtSignal(str, Enum)
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:
@@ -173,10 +177,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:
@@ -302,30 +308,52 @@ 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
+ # Signal Proxies
##
- 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)
- if event == "updateTags":
- self.indexChangedTags.emit(data.get("updated", []), data.get("deleted", []))
- elif event == "scanText":
- self.indexScannedText.emit(data.get("handle", ""))
- 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 projectSingalProxy(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", ""))
+ 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
##
@@ -386,6 +414,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/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 f4c658e7..e5221cd2 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)
@@ -418,8 +419,7 @@ class _FilterTab(NFixedPage):
logger.debug("Building project tree")
self._treeMap = {}
self.optTree.clear()
- for nwItem in SHARED.project.iterProjectItems():
-
+ for nwItem in SHARED.project.tree:
tHandle = nwItem.itemHandle
pHandle = nwItem.itemParent
rHandle = nwItem.itemRoot
@@ -428,28 +428,15 @@ class _FilterTab(NFixedPage):
continue
isFile = nwItem.isFileType()
- isActive = nwItem.isActive
-
if nwItem.isInactiveClass() or not self._build.isRootAllowed(rHandle):
continue
- hLevel = nwItem.mainHeading
- itemIcon = SHARED.theme.getItemIcon(
- 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.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)
- 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():
@@ -496,11 +483,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)
)
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
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/mocked.py b/tests/mocked.py
index afa62d25..dc663331 100644
--- a/tests/mocked.py
+++ b/tests/mocked.py
@@ -20,7 +20,9 @@ along with this program. If not, see .
"""
from __future__ import annotations
-from PyQt5.QtGui import QPixmap
+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,27 +56,24 @@ 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):
self.baseIconHeight = 20
+ self.guiFont = QFont()
+ self.guiFontB = QFont()
+ self.guiFontBU = QFont()
return
def getPixmap(self, *a):
return QPixmap()
+ def getIcon(self, *a) -> QIcon:
+ return QIcon()
+
+ def getItemIcon(self, *a, **k) -> QIcon:
+ return QIcon()
+
class MockApp:
diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
index 4aba7481..a99d8333 100644
--- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx
+++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -33,35 +33,23 @@
Novel
- -
-
- Plot
-
- -
-
- Characters
-
- -
-
- World
-
-
Title Page
- -
+
-
- New Chapter
+ New Folder
-
New Chapter
- -
+
-
New Scene
- -
+
-
Stuff
@@ -69,13 +57,25 @@
Hello
+ -
+
+ Plot
+
+ -
+
+ Characters
+
-
Jane
- -
+
-
John
+ -
+
+ Locations
+
diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx
index 4bfbb568..a83eb731 100644
--- a/tests/reference/coreProject_NewRoot_nwProject.nwx
+++ b/tests/reference/coreProject_NewRoot_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -33,63 +33,63 @@
Novel
- -
-
- Plot
-
- -
-
- Characters
-
- -
-
- World
-
-
Title Page
- -
+
-
- New Chapter
+ New Folder
-
New Chapter
- -
+
-
New Scene
- -
-
- Novel
-
- -
+
-
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 5f6685a6..30c62652 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,56 +28,52 @@
Main
-
+
-
Novel
- -
-
- Plot
-
- -
-
- Characters
-
- -
-
- World
-
-
Title Page
- -
+
-
- New Chapter
+ New Folder
-
New Chapter
- -
+
-
+
+ New Chapter
+
+ -
New Scene
- -
+
-
New Scene
- -
+
-
+
+ New Scene
+
+ -
- New Chapter
+ New Folder
-
New Chapter
- -
+
-
New Scene
- -
+
-
Novel
@@ -85,21 +81,29 @@
Title Page
- -
+
-
- New Chapter
+ New Folder
-
New Chapter
- -
+
-
New Scene
- -
-
- New Chapter
+
-
+
+ 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/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/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..d3051769 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
@@ -22,13 +22,13 @@
Finished
- New
+ New
Minor
Major
Main
-
+
-
Novel
@@ -39,7 +39,7 @@
-
- New Chapter
+ New Folder
-
@@ -50,28 +50,32 @@
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..c265fa51 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
@@ -39,7 +39,7 @@
-
- New Chapter
+ New Folder
-
@@ -59,7 +59,7 @@
-
- World
+ Locations
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_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..21e42f3e 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)
@@ -89,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 == {}
@@ -112,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
@@ -152,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()
@@ -220,7 +222,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)
@@ -346,6 +348,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 +366,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 +380,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 +908,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 +920,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
@@ -1147,7 +1156,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
@@ -1281,7 +1290,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_item.py b/tests/test_core/test_core_item.py
index 3e0fd0bb..8ebfd787 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):
@@ -280,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_core/test_core_itemmodel.py b/tests/test_core/test_core_itemmodel.py
new file mode 100644
index 00000000..f63afa35
--- /dev/null
+++ b/tests/test_core/test_core_itemmodel.py
@@ -0,0 +1,633 @@
+"""
+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
+
+ # 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):
+ """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_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_core/test_core_tree.py b/tests/test_core/test_core_tree.py
index 421522e2..baf6d3e4 100644
--- a/tests/test_core/test_core_tree.py
+++ b/tests/test_core/test_core_tree.py
@@ -22,279 +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
-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
-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
-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)
@@ -303,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
@@ -339,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]
@@ -359,85 +380,6 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc
assert project.tree[nHandle].itemName == "Recovered" # type: ignore
-@pytest.mark.core
-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
def testCoreTree_MakeHandles(mockGUI):
"""Test generating item handles."""
@@ -450,84 +392,27 @@ 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()
assert tHandle == handles[3]
-@pytest.mark.core
-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
-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
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
@@ -539,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:
@@ -553,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"
@@ -565,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"
)
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_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_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..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
@@ -82,7 +83,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"}
@@ -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."""
@@ -1615,7 +1684,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 +1783,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..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.enum import nwDocAction
+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
@@ -52,8 +57,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"
@@ -206,12 +211,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
@@ -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_docviewerpanel.py b/tests/test_gui/test_gui_docviewerpanel.py
index e6c1ca7e..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._getTreeItem(C.hChapterDir).setExpanded(True)
+ nwGUI.projView.projTree.expandAll()
viewPanel = nwGUI.docViewerPanel
tabBackRefs = viewPanel.tabBackRefs
@@ -81,17 +80,17 @@ 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"
# 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
@@ -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._getTreeItem(C.hChapterDir).setExpanded(True)
+ nwGUI.projView.projTree.expandAll()
viewPanel = nwGUI.docViewerPanel
nwGUI.openDocument(C.hSceneDoc)
@@ -175,18 +173,18 @@ 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"
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
@@ -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 8bdbc45e..9668185f 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()
@@ -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()
@@ -701,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()
@@ -711,7 +723,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..bf2974c7 100644
--- a/tests/test_gui/test_gui_outline.py
+++ b/tests/test_gui/test_gui_outline.py
@@ -197,8 +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)
- nwGUI.projView.projTree.revealNewTreeItem(newHandle)
+ with qtbot.waitSignal(SHARED.rootFolderChanged):
+ newHandle = SHARED.project.newRoot(nwItemClass.NOVEL)
# Check new values in dropdown list
assert outlineBar.novelValue.itemData(0) == lipHandle
@@ -217,7 +217,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()
@@ -279,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_projtree.py b/tests/test_gui/test_gui_projtree.py
index af14b26c..0a073aae 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,121 +44,264 @@ from tests.tools import C, buildTestProject
@pytest.mark.gui
-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
# ===========
@@ -170,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()
@@ -196,334 +321,380 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRn
@pytest.mark.gui
-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
-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
- # Try to delete the trash folder
- caplog.clear()
- assert projView.requestDeleteItem("0000000000013") is False
- assert "Cannot delete the Trash folder" in caplog.text
-
- nwGUI.closeProject()
+ projTree.mousePressEvent(event)
+ assert projView.getSelectedHandle() is None
@pytest.mark.gui
-def testGuiProjTree_MoveItemToTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
- """Test moving items to Trash."""
+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
- projTree = nwGUI.projView.projTree
+ 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",
+ ]
- # Invalid item
- caplog.clear()
- assert projTree.moveItemToTrash(C.hInvalid) is False
- assert "Could not find tree item for deletion" 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")
- # 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
+ 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}")
- # Block adding trash folder
- funcPointer = projTree._addTrashRoot
- projTree._addTrashRoot = lambda *a: None
+ 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",
+ ]
- 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
+ # 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",
+ ]
- projTree._addTrashRoot = funcPointer
+ # 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",
+ ]
- # User cancels action
+ # 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",
+ ]
+
+ # 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
-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",
+ ]
+
+ # 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",
+ ]
+
+ # 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",
+ ]
+
+ # 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",
+ ]
+
+ # Trash can be completely emptied, but user can block it
+ with monkeypatch.context() as mp:
+ mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
+ 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",
+ ]
+
+ # 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",
+ ]
- # 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
+ assert nwGUI.docViewer.docHandle is None
- # 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
+ # 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"
+ ]
-
-@pytest.mark.gui
-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
- 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
-
- # 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
-
- # Running Empty Trash again is cancelled due to empty folder
- assert projTree.emptyTrash() is False
-
- nwGUI.closeProject()
+ # 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
@@ -537,18 +708,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"
@@ -561,25 +747,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
# ================
@@ -591,11 +778,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
# =====================
@@ -605,22 +799,29 @@ 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()
@@ -637,11 +838,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"
@@ -659,8 +869,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"] = [
@@ -679,24 +889,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
@@ -704,13 +914,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()
@@ -718,237 +928,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
-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
- projTree = nwGUI.projView.projTree
- projTree._getTreeItem(C.hNovelRoot).setExpanded(True) # type: ignore
- projTree._getTreeItem(C.hChapterDir).setExpanded(True) # type: ignore
+ # 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.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
-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._getTreeItem(C.hChapterDir).setExpanded(True) # type: ignore
- 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
-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
-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
# ====================
@@ -966,142 +1034,6 @@ 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._treeDoubleClick(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)
- assert nwGUI.docEditor.docHandle is None
-
- # Successfully open a file
- projTree._treeDoubleClick(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)
- 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()
@@ -1111,78 +1043,91 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
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",
@@ -1190,10 +1135,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",
@@ -1207,26 +1152,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",
@@ -1237,34 +1180,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",
@@ -1273,140 +1226,130 @@ 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)
- ctxMenu._covertFolderToFile(nwItemLayout.DOCUMENT)
- assert SHARED.project.tree[hNewFolderOne].isFolderType() # type: ignore
+ mp.setattr(QMessageBox, "result", lambda *a: QtRejected)
+ ctxMenu._convertFolderToFile(nwItemLayout.DOCUMENT)
+ assert nodeOne.item.isFolderType()
# Convert the first folder to a document
- ctxMenu._covertFolderToFile(nwItemLayout.DOCUMENT)
- assert SHARED.project.tree[hNewFolderOne].isFileType() # type: ignore
- assert SHARED.project.tree[hNewFolderOne].isDocumentLayout() # type: ignore
+ assert nodeOne.item.isFolderType()
+ ctxMenu._convertFolderToFile(nwItemLayout.DOCUMENT)
+ 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._covertFolderToFile(nwItemLayout.NOTE)
- assert SHARED.project.tree[hNewFolderTwo].isFileType() # type: ignore
- assert SHARED.project.tree[hNewFolderTwo].isNoteLayout() # type: ignore
+ ctxMenu._convertFolderToFile(nwItemLayout.NOTE)
+ 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()
@@ -1417,44 +1360,46 @@ def testGuiProjTree_Templates(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
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)
@@ -1465,11 +1410,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")
@@ -1491,14 +1433,21 @@ 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
- 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
+ # 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
+ 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()
diff --git a/tests/test_gui/test_gui_statusbar.py b/tests/test_gui/test_gui_statusbar.py
index 15e66921..71d0d0d1 100644
--- a/tests/test_gui/test_gui_statusbar.py
+++ b/tests/test_gui/test_gui_statusbar.py
@@ -31,13 +31,12 @@ 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)
newDoc = SHARED.project.storage.getDocument(cHandle)
newDoc.writeDocument("# A Note\n\n")
- nwGUI.projView.projTree.revealNewTreeItem(cHandle)
nwGUI.rebuildIndex(beQuiet=True)
# Reference Time
@@ -87,10 +86,16 @@ 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._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()
diff --git a/tests/test_tools/test_tools_manussettings.py b/tests/test_tools/test_tools_manussettings.py
index 8adfd824..354a731c 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
@@ -169,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
@@ -184,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
@@ -199,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
@@ -216,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
@@ -234,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
@@ -249,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
@@ -266,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
@@ -286,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/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)
diff --git a/tests/tools.py b/tests/tools.py
index 504eb5b9..aaa11ef6 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"
@@ -181,13 +182,13 @@ 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 ""
+ cfHandle = project.newFolder("New Folder", nrHandle) or ""
cdHandle = project.newFile("New Chapter", cfHandle)
sdHandle = project.newFile("New Scene", cfHandle)
@@ -209,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