Add project tree navigation shortcuts (#1488)

This commit is contained in:
Veronica Berglyd Olsen
2023-07-29 22:41:07 +02:00
committed by GitHub
4 changed files with 250 additions and 194 deletions
+4
View File
@@ -92,6 +92,10 @@ The main shorcuts are as follows:
":kbd:`Shift`:kbd:`F1`", "Open the local user manual (PDF) if it is available." ":kbd:`Shift`:kbd:`F1`", "Open the local user manual (PDF) if it is available."
":kbd:`Shift`:kbd:`F3`", "Find previous occurrence of search word in current document." ":kbd:`Shift`:kbd:`F3`", "Find previous occurrence of search word in current document."
":kbd:`Shift`:kbd:`F6`", "Open the :guilabel:`Project Details` dialog." ":kbd:`Shift`:kbd:`F6`", "Open the :guilabel:`Project Details` dialog."
":kbd:`Shift`:kbd:`Up`", "Go to the previous item at same level in the project tree."
":kbd:`Shift`:kbd:`Down`", "Go to the next item at same level in the project tree."
":kbd:`Shift`:kbd:`Left`", "Go to the parent item in the project tree."
":kbd:`Shift`:kbd:`Right`", "Go to the first child item in the project tree."
.. note:: .. note::
On macOS, replace :kbd:`Ctrl` with :kbd:`Cmd`. On macOS, replace :kbd:`Ctrl` with :kbd:`Cmd`.
+14 -18
View File
@@ -76,7 +76,7 @@ class NWIndex:
a rebuild of the index data. a rebuild of the index data.
""" """
def __init__(self, project: NWProject): def __init__(self, project: NWProject) -> None:
self._project = project self._project = project
@@ -91,7 +91,7 @@ class NWIndex:
return return
def __repr__(self): def __repr__(self) -> str:
return f"<NWIndex project='{self._project.data.name}'>" return f"<NWIndex project='{self._project.data.name}'>"
## ##
@@ -99,14 +99,14 @@ class NWIndex:
## ##
@property @property
def indexBroken(self): def indexBroken(self) -> bool:
return self._indexBroken return self._indexBroken
## ##
# Public Methods # Public Methods
## ##
def clearIndex(self): def clearIndex(self) -> None:
"""Clear the index dictionaries and time stamps.""" """Clear the index dictionaries and time stamps."""
self._tagsIndex.clear() self._tagsIndex.clear()
self._itemIndex.clear() self._itemIndex.clear()
@@ -114,7 +114,7 @@ class NWIndex:
self._rootChange = {} self._rootChange = {}
return return
def rebuildIndex(self): def rebuildIndex(self) -> None:
"""Rebuild the entire index from scratch.""" """Rebuild the entire index from scratch."""
self.clearIndex() self.clearIndex()
for nwItem in self._project.tree: for nwItem in self._project.tree:
@@ -125,22 +125,20 @@ class NWIndex:
self._indexBroken = False self._indexBroken = False
return return
def deleteHandle(self, tHandle: str): def deleteHandle(self, tHandle: str) -> None:
"""Delete all entries of a given document handle.""" """Delete all entries of a given document handle."""
logger.debug("Removing item '%s' from the index", tHandle) logger.debug("Removing item '%s' from the index", tHandle)
for tTag in self._itemIndex.allItemTags(tHandle): for tTag in self._itemIndex.allItemTags(tHandle):
del self._tagsIndex[tTag] del self._tagsIndex[tTag]
del self._itemIndex[tHandle] del self._itemIndex[tHandle]
return return
def reIndexHandle(self, tHandle: str) -> bool: def reIndexHandle(self, tHandle: str | None) -> bool:
"""Put a file back into the index. This is used when files are """Put a file back into the index. This is used when files are
moved from the archive or trash folders back into the active moved from the archive or trash folders back into the active
project. project.
""" """
if not self._project.tree.checkType(tHandle, nwItemType.FILE): if tHandle is None or not self._project.tree.checkType(tHandle, nwItemType.FILE):
return False return False
logger.debug("Re-indexing item '%s'", tHandle) logger.debug("Re-indexing item '%s'", tHandle)
@@ -163,9 +161,8 @@ class NWIndex:
# Load and Save Index to/from File # Load and Save Index to/from File
## ##
def loadIndex(self): def loadIndex(self) -> bool:
"""Load index from last session from the project meta folder. """Load index from last session from the project meta folder."""
"""
indexFile = self._project.storage.getMetaFile(nwFiles.INDEX_FILE) indexFile = self._project.storage.getMetaFile(nwFiles.INDEX_FILE)
if not isinstance(indexFile, Path): if not isinstance(indexFile, Path):
return False return False
@@ -292,7 +289,7 @@ class NWIndex:
# Internal Indexer Helpers # Internal Indexer Helpers
## ##
def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict): def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict) -> None:
"""Scan an active document for meta data.""" """Scan an active document for meta data."""
nTitle = 0 # Line Number of the previous title nTitle = 0 # Line Number of the previous title
cTitle = TT_NONE # Tag of the current title cTitle = TT_NONE # Tag of the current title
@@ -355,7 +352,7 @@ class NWIndex:
return return
def _scanInactive(self, nwItem: NWItem, text: str): def _scanInactive(self, nwItem: NWItem, text: str) -> None:
"""Scan an inactive document for meta data.""" """Scan an inactive document for meta data."""
for aLine in text.splitlines(): for aLine in text.splitlines():
if aLine.startswith("#"): if aLine.startswith("#"):
@@ -387,9 +384,8 @@ class NWIndex:
self._itemIndex.setHeadingCounts(tHandle, sTitle, cC, wC, pC) self._itemIndex.setHeadingCounts(tHandle, sTitle, cC, wC, pC)
return return
def _indexKeyword( def _indexKeyword(self, tHandle: str, line: str, sTitle: str,
self, tHandle: str, line: str, sTitle: str, itemClass: nwItemClass, tags: dict itemClass: nwItemClass, tags: dict) -> None:
):
"""Validate and save the information about a reference to a tag """Validate and save the information about a reference to a tag
in another file, or the setting of a tag in the file. A record in another file, or the setting of a tag in the file. A record
of active tags is updated so that no longer used tags can be of active tags is updated so that no longer used tags can be
+161 -159
View File
@@ -32,8 +32,8 @@ from enum import Enum
from time import time from time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from PyQt5.QtGui import QPalette from PyQt5.QtGui import QDropEvent, QMouseEvent, QPalette
from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot from PyQt5.QtCore import QPoint, Qt, QSize, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QAbstractItemView, QDialog, QFrame, QHBoxLayout, QHeaderView, QLabel, QAbstractItemView, QDialog, QFrame, QHBoxLayout, QHeaderView, QLabel,
QMenu, QShortcut, QSizePolicy, QToolButton, QTreeWidget, QTreeWidgetItem, QMenu, QShortcut, QSizePolicy, QToolButton, QTreeWidget, QTreeWidgetItem,
@@ -41,7 +41,7 @@ from PyQt5.QtWidgets import (
) )
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.enum import nwDocMode, nwItemType, nwItemClass, nwItemLayout, nwAlert, nwWidget from novelwriter.common import minmax
from novelwriter.constants import nwHeaders, nwUnicode, trConst, nwLabels from novelwriter.constants import nwHeaders, nwUnicode, trConst, nwLabels
from novelwriter.core.item import NWItem from novelwriter.core.item import NWItem
from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter
@@ -49,6 +49,9 @@ from novelwriter.dialogs.docmerge import GuiDocMerge
from novelwriter.dialogs.docsplit import GuiDocSplit from novelwriter.dialogs.docsplit import GuiDocSplit
from novelwriter.dialogs.editlabel import GuiEditLabel from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.dialogs.projsettings import GuiProjectSettings from novelwriter.dialogs.projsettings import GuiProjectSettings
from novelwriter.enum import (
nwDocMode, nwItemType, nwItemClass, nwItemLayout, nwAlert, nwWidget
)
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain from novelwriter.guimain import GuiMain
@@ -74,7 +77,7 @@ class GuiProjectView(QWidget):
# Requests for the main GUI # Requests for the main GUI
projectSettingsRequest = pyqtSignal(int) projectSettingsRequest = pyqtSignal(int)
def __init__(self, mainGui: GuiMain): def __init__(self, mainGui: GuiMain) -> None:
super().__init__(parent=mainGui) super().__init__(parent=mainGui)
self.mainGui = mainGui self.mainGui = mainGui
@@ -104,6 +107,26 @@ class GuiProjectView(QWidget):
self.keyMoveDn.setContext(Qt.WidgetShortcut) self.keyMoveDn.setContext(Qt.WidgetShortcut)
self.keyMoveDn.activated.connect(lambda: self.projTree.moveTreeItem(1)) self.keyMoveDn.activated.connect(lambda: self.projTree.moveTreeItem(1))
self.keyGoPrev = QShortcut(self.projTree)
self.keyGoPrev.setKey("Shift+Up")
self.keyGoPrev.setContext(Qt.WidgetShortcut)
self.keyGoPrev.activated.connect(lambda: self.projTree.moveToNextItem(-1))
self.keyGoNext = QShortcut(self.projTree)
self.keyGoNext.setKey("Shift+Down")
self.keyGoNext.setContext(Qt.WidgetShortcut)
self.keyGoNext.activated.connect(lambda: self.projTree.moveToNextItem(1))
self.keyGoUp = QShortcut(self.projTree)
self.keyGoUp.setKey("Shift+Left")
self.keyGoUp.setContext(Qt.WidgetShortcut)
self.keyGoUp.activated.connect(lambda: self.projTree.moveToLevel(-1))
self.keyGoDown = QShortcut(self.projTree)
self.keyGoDown.setKey("Shift+Right")
self.keyGoDown.setContext(Qt.WidgetShortcut)
self.keyGoDown.activated.connect(lambda: self.projTree.moveToLevel(1))
self.keyUndoMv = QShortcut(self.projTree) self.keyUndoMv = QShortcut(self.projTree)
self.keyUndoMv.setKey("Ctrl+Shift+Z") self.keyUndoMv.setKey("Ctrl+Shift+Z")
self.keyUndoMv.setContext(Qt.WidgetShortcut) self.keyUndoMv.setContext(Qt.WidgetShortcut)
@@ -132,83 +155,71 @@ class GuiProjectView(QWidget):
# Methods # Methods
## ##
def updateTheme(self): def updateTheme(self) -> None:
"""Update theme elements. """Update theme elements."""
"""
self.projBar.updateTheme() self.projBar.updateTheme()
self.populateTree() self.populateTree()
return return
def initSettings(self): def initSettings(self) -> None:
"""Initialise GUI elements that depend on specific settings. """Initialise GUI elements that depend on specific settings."""
"""
self.projTree.initSettings() self.projTree.initSettings()
return return
def clearProject(self): def clearProject(self) -> None:
"""Clear project-related GUI content. """Clear project-related GUI content."""
"""
self.projBar.clearContent() self.projBar.clearContent()
self.projBar.setEnabled(False) self.projBar.setEnabled(False)
self.projTree.clearTree() self.projTree.clearTree()
return return
def openProjectTasks(self): def openProjectTasks(self) -> None:
"""Run open project tasks. """Run open project tasks."""
"""
self.projBar.buildQuickLinkMenu() self.projBar.buildQuickLinkMenu()
self.projBar.setEnabled(True) self.projBar.setEnabled(True)
return return
def saveProjectTasks(self): def saveProjectTasks(self) -> None:
"""Run save project tasks. """Run save project tasks."""
"""
self.projTree.saveTreeOrder() self.projTree.saveTreeOrder()
return return
def populateTree(self): def populateTree(self) -> None:
"""Build the tree structure from project data. """Build the tree structure from project data."""
"""
self.projTree.buildTree() self.projTree.buildTree()
return return
def setFocus(self): def setFocus(self) -> None:
"""Forward the set focus call to the tree widget. """Forward the set focus call to the tree widget."""
"""
self.projTree.setFocus() self.projTree.setFocus()
return return
def treeHasFocus(self): def treeHasFocus(self) -> bool:
"""Check if the project tree has focus. """Check if the project tree has focus."""
"""
return self.projTree.hasFocus() return self.projTree.hasFocus()
def renameTreeItem(self, tHandle=None): def renameTreeItem(self, tHandle: str | None = None) -> bool:
"""External request to rename an item or the currently selected """External request to rename an item or the currently selected
item. This is triggered by the global menu or keyboard shortcut. item. This is triggered by the global menu or keyboard shortcut.
""" """
if tHandle is None: if tHandle is None:
tHandle = self.projTree.getSelectedHandle() tHandle = self.projTree.getSelectedHandle()
if tHandle: return self.projTree.renameTreeItem(tHandle) if tHandle else False
return self.projTree.renameTreeItem(tHandle)
return
## ##
# Public Slots # Public Slots
## ##
@pyqtSlot(str, int, int, int) @pyqtSlot(str, int, int, int)
def updateCounts(self, tHandle, cCount, wCount, pCount): def updateCounts(self, tHandle: str, cCount: int, wCount: int, pCount: int) -> None:
"""Slot for updating the word count of a specific item. """Slot for updating the word count of a specific item."""
"""
self.projTree.propagateCount(tHandle, wCount, countChildren=True) self.projTree.propagateCount(tHandle, wCount, countChildren=True)
self.wordCountsChanged.emit() self.wordCountsChanged.emit()
return return
@pyqtSlot(str) @pyqtSlot(str)
def updateRootItem(self, tHandle): def updateRootItem(self, tHandle: str) -> None:
"""If any root item changes, rebuild the quick link root menu. """If any root item changes, rebuild the quick link menu."""
"""
self.projBar.buildQuickLinkMenu() self.projBar.buildQuickLinkMenu()
return return
@@ -217,7 +228,7 @@ class GuiProjectView(QWidget):
class GuiProjectToolBar(QWidget): class GuiProjectToolBar(QWidget):
def __init__(self, projView): def __init__(self, projView: GuiProjectView) -> None:
super().__init__(parent=projView) super().__init__(parent=projView)
logger.debug("Create: GuiProjectToolBar") logger.debug("Create: GuiProjectToolBar")
@@ -237,7 +248,7 @@ class GuiProjectToolBar(QWidget):
# Widget Label # Widget Label
self.viewLabel = QLabel("<b>%s</b>" % self.tr("Project Content")) self.viewLabel = QLabel("<b>%s</b>" % self.tr("Project Content"))
self.viewLabel.setContentsMargins(0, 0, 0, 0) self.viewLabel.setContentsMargins(0, 0, 0, 0)
self.viewLabel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.viewLabel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# Quick Links # Quick Links
self.mQuick = QMenu() self.mQuick = QMenu()
@@ -341,9 +352,8 @@ class GuiProjectToolBar(QWidget):
# Methods # Methods
## ##
def updateTheme(self): def updateTheme(self) -> None:
"""Update theme elements. """Update theme elements."""
"""
qPalette = self.palette() qPalette = self.palette()
qPalette.setBrush(QPalette.Window, qPalette.base()) qPalette.setBrush(QPalette.Window, qPalette.base())
self.setPalette(qPalette) self.setPalette(qPalette)
@@ -376,17 +386,14 @@ class GuiProjectToolBar(QWidget):
return return
def clearContent(self): def clearContent(self) -> None:
"""Clear dynamic content on the tool bar. """Clear dynamic content on the tool bar."""
"""
self.mQuick.clear() self.mQuick.clear()
return return
def buildQuickLinkMenu(self): def buildQuickLinkMenu(self) -> None:
"""Build the quick link menu. """Build the quick link menu."""
"""
logger.debug("Rebuilding quick links menu") logger.debug("Rebuilding quick links menu")
self.mQuick.clear() self.mQuick.clear()
for n, (tHandle, nwItem) in enumerate(self.theProject.tree.iterRoots(None)): for n, (tHandle, nwItem) in enumerate(self.theProject.tree.iterRoots(None)):
aRoot = self.mQuick.addAction(nwItem.itemName) aRoot = self.mQuick.addAction(nwItem.itemName)
@@ -395,16 +402,14 @@ class GuiProjectToolBar(QWidget):
aRoot.triggered.connect( aRoot.triggered.connect(
lambda n, tHandle=tHandle: self.projView.setSelectedHandle(tHandle, doScroll=True) lambda n, tHandle=tHandle: self.projView.setSelectedHandle(tHandle, doScroll=True)
) )
return return
## ##
# Internal Functions # Internal Functions
## ##
def _buildRootMenu(self): def _buildRootMenu(self) -> None:
"""Build the rood folder menu. """Build the rood folder menu."""
"""
def addClass(itemClass): def addClass(itemClass):
aNew = self.mAddRoot.addAction(trConst(nwLabels.CLASS_NAME[itemClass])) aNew = self.mAddRoot.addAction(trConst(nwLabels.CLASS_NAME[itemClass]))
aNew.setIcon(self.mainTheme.getIcon(nwLabels.CLASS_ICON[itemClass])) aNew.setIcon(self.mainTheme.getIcon(nwLabels.CLASS_ICON[itemClass]))
@@ -431,7 +436,7 @@ class GuiProjectToolBar(QWidget):
## ##
@pyqtSlot(str) @pyqtSlot(str)
def _treeSelectionChanged(self, tHandle): def _treeSelectionChanged(self, tHandle: str) -> None:
"""Toggle the visibility of the new item entries for novel """Toggle the visibility of the new item entries for novel
documents. They should only be visible if novel documents can documents. They should only be visible if novel documents can
actually be added. actually be added.
@@ -470,7 +475,7 @@ class GuiProjectTree(QTreeWidget):
# Internal Variables # Internal Variables
self._treeMap = {} self._treeMap = {}
self._lastMove = {} self._lastMove = {}
self._timeChanged = 0 self._timeChanged = 0.0
# Build GUI # Build GUI
# ========= # =========
@@ -553,7 +558,7 @@ class GuiProjectTree(QTreeWidget):
self.clear() self.clear()
self._treeMap = {} self._treeMap = {}
self._lastMove = {} self._lastMove = {}
self._timeChanged = 0 self._timeChanged = 0.0
return return
def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None, def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None,
@@ -658,11 +663,11 @@ class GuiProjectTree(QTreeWidget):
return True return True
def revealNewTreeItem(self, tHandle: str, nHandle: str | None = None, def revealNewTreeItem(self, tHandle: str | None, nHandle: str | None = None,
wordCount: bool = False) -> bool: wordCount: bool = False) -> bool:
"""Reveal a newly added project item in the project tree.""" """Reveal a newly added project item in the project tree."""
nwItem = self.theProject.tree[tHandle] nwItem = self.theProject.tree[tHandle] if tHandle else None
if not nwItem: if tHandle is None or nwItem is None:
return False return False
trItem = self._addTreeItem(nwItem, nHandle) trItem = self._addTreeItem(nwItem, nHandle)
@@ -683,21 +688,21 @@ class GuiProjectTree(QTreeWidget):
return True return True
def moveTreeItem(self, nStep: int) -> bool: def moveTreeItem(self, step: int) -> bool:
"""Move an item up or down in the tree.""" """Move an item up or down in the tree."""
tHandle = self.getSelectedHandle() tHandle = self.getSelectedHandle()
trItem = self._getTreeItem(tHandle) tItem = self._getTreeItem(tHandle)
if trItem is None: if tItem is None:
logger.debug("No item selected") logger.debug("No item selected")
return False return False
pItem = trItem.parent() pItem = tItem.parent()
isExp = trItem.isExpanded() isExp = tItem.isExpanded()
if pItem is None: if pItem is None:
tIndex = self.indexOfTopLevelItem(trItem) tIndex = self.indexOfTopLevelItem(tItem)
nChild = self.topLevelItemCount() nChild = self.topLevelItemCount()
nIndex = tIndex + nStep nIndex = tIndex + step
if nIndex < 0 or nIndex >= nChild: if nIndex < 0 or nIndex >= nChild:
return False return False
@@ -705,10 +710,10 @@ class GuiProjectTree(QTreeWidget):
self.insertTopLevelItem(nIndex, cItem) self.insertTopLevelItem(nIndex, cItem)
else: else:
tIndex = pItem.indexOfChild(trItem) tIndex = pItem.indexOfChild(tItem)
nChild = pItem.childCount() nChild = pItem.childCount()
nIndex = tIndex + nStep nIndex = tIndex + step
if nIndex < 0 or nIndex >= nChild: if nIndex < 0 or nIndex >= nChild:
return False return False
@@ -717,11 +722,32 @@ class GuiProjectTree(QTreeWidget):
self._recordLastMove(cItem, pItem, tIndex) self._recordLastMove(cItem, pItem, tIndex)
self._alertTreeChange(tHandle, flush=True) self._alertTreeChange(tHandle, flush=True)
self.setCurrentItem(trItem) self.setCurrentItem(tItem)
trItem.setExpanded(isExp) tItem.setExpanded(isExp)
return True 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) -> bool: def renameTreeItem(self, tHandle: str) -> bool:
"""Open a dialog to edit the label of an item.""" """Open a dialog to edit the label of an item."""
tItem = self.theProject.tree[tHandle] tItem = self.theProject.tree[tHandle]
@@ -791,13 +817,13 @@ class GuiProjectTree(QTreeWidget):
return False return False
if self.theProject.tree.isTrash(tHandle) or nwItem.isRootType(): if self.theProject.tree.isTrash(tHandle) or nwItem.isRootType():
status = self.permanentlyDeleteItem(tHandle) status = self.permDeleteItem(tHandle)
else: else:
status = self.moveItemToTrash(tHandle) status = self.moveItemToTrash(tHandle)
return status return status
def emptyTrash(self): def emptyTrash(self) -> bool:
"""Permanently delete all documents in the Trash folder. This """Permanently delete all documents in the Trash folder. This
function only asks for confirmation once, and calls the regular function only asks for confirmation once, and calls the regular
deleteItem function for each document in the Trash folder. deleteItem function for each document in the Trash folder.
@@ -838,14 +864,14 @@ class GuiProjectTree(QTreeWidget):
for tHandle in reversed(self.getTreeFromHandle(trashHandle)): for tHandle in reversed(self.getTreeFromHandle(trashHandle)):
if tHandle == trashHandle: if tHandle == trashHandle:
continue continue
self.permanentlyDeleteItem(tHandle, askFirst=False, flush=False) self.permDeleteItem(tHandle, askFirst=False, flush=False)
if nTrash > 0: if nTrash > 0:
self._alertTreeChange(trashHandle, flush=True) self._alertTreeChange(trashHandle, flush=True)
return True return True
def moveItemToTrash(self, tHandle, askFirst=True, flush=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, """Move an item to Trash. Root folders cannot be moved to Trash,
so such a request is cancelled. so such a request is cancelled.
""" """
@@ -896,7 +922,7 @@ class GuiProjectTree(QTreeWidget):
return True return True
def permanentlyDeleteItem(self, tHandle, askFirst=True, flush=True): def permDeleteItem(self, tHandle: str, askFirst: bool = True, flush: bool = True) -> bool:
"""Permanently delete a tree item from the project and the map. """Permanently delete a tree item from the project and the map.
Root items are handled a little different than other items. Root items are handled a little different than other items.
""" """
@@ -960,7 +986,7 @@ class GuiProjectTree(QTreeWidget):
return True return True
def setTreeItemValues(self, tHandle): def setTreeItemValues(self, tHandle: str) -> None:
"""Set the name and flag values for a tree item from a handle in """Set the name and flag values for a tree item from a handle in
the project tree. Does not trigger a tree change as the data is the project tree. Does not trigger a tree change as the data is
already coming from the project tree. already coming from the project tree.
@@ -1053,7 +1079,7 @@ class GuiProjectTree(QTreeWidget):
logger.info("%d item(s) added to the project tree", count) logger.info("%d item(s) added to the project tree", count)
return return
def undoLastMove(self): def undoLastMove(self) -> bool:
"""Attempt to undo the last action.""" """Attempt to undo the last action."""
srcItem = self._lastMove.get("item", None) srcItem = self._lastMove.get("item", None)
dstItem = self._lastMove.get("parent", None) dstItem = self._lastMove.get("parent", None)
@@ -1093,19 +1119,17 @@ class GuiProjectTree(QTreeWidget):
return True return True
def getSelectedHandle(self): def getSelectedHandle(self) -> str | None:
"""Get the currently selected handle. If multiple items are """Get the currently selected handle. If multiple items are
selected, return the first. selected, return the first.
""" """
selItem = self.selectedItems() selItem = self.selectedItems()
if selItem: if selItem:
return selItem[0].data(self.C_DATA, self.D_HANDLE) return selItem[0].data(self.C_DATA, self.D_HANDLE)
return None return None
def setSelectedHandle(self, tHandle, doScroll=False): def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> bool:
"""Set a specific handle as the selected item. """Set a specific handle as the selected item."""
"""
tItem = self._getTreeItem(tHandle) tItem = self._getTreeItem(tHandle)
if tItem is None: if tItem is None:
return False return False
@@ -1119,7 +1143,7 @@ class GuiProjectTree(QTreeWidget):
return True return True
def setExpandedFromHandle(self, tHandle, isExpanded): def setExpandedFromHandle(self, tHandle: str | None, isExpanded: bool) -> None:
"""Iterate through items below tHandle and change expanded """Iterate through items below tHandle and change expanded
status for all child items. If tHandle is None, it affects the status for all child items. If tHandle is None, it affects the
entire tree. entire tree.
@@ -1128,18 +1152,16 @@ class GuiProjectTree(QTreeWidget):
self._recursiveSetExpanded(trItem, isExpanded) self._recursiveSetExpanded(trItem, isExpanded)
return return
def openContextOnSelected(self): def openContextOnSelected(self) -> bool:
"""Open the context menu on the current selected item. """Open the context menu on the current selected item."""
"""
selItem = self.selectedItems() selItem = self.selectedItems()
if selItem: if selItem:
pos = self.visualItemRect(selItem[0]).center() pos = self.visualItemRect(selItem[0]).center()
return self._openContextMenu(pos) return self._openContextMenu(pos)
return False return False
def changedSince(self, checkTime): def changedSince(self, checkTime: float) -> bool:
"""Check if the tree has changed since a given time. """Check if the tree has changed since a given time."""
"""
return self._timeChanged > checkTime return self._timeChanged > checkTime
## ##
@@ -1147,16 +1169,15 @@ class GuiProjectTree(QTreeWidget):
## ##
@pyqtSlot() @pyqtSlot()
def _treeSelectionChange(self): def _treeSelectionChange(self) -> None:
"""The user changed which item is selected. """The user changed which item is selected."""
"""
tHandle = self.getSelectedHandle() tHandle = self.getSelectedHandle()
if tHandle is not None: if tHandle is not None:
self.projView.selectedItemChanged.emit(tHandle) self.projView.selectedItemChanged.emit(tHandle)
return return
@pyqtSlot("QTreeWidgetItem*", int) @pyqtSlot("QTreeWidgetItem*", int)
def _treeDoubleClick(self, trItem, colNo): def _treeDoubleClick(self, trItem: QTreeWidgetItem, colNo: int) -> None:
"""Capture a double-click event and either request the document """Capture a double-click event and either request the document
for editing if it is a file, or expand/close the node it is not. for editing if it is a file, or expand/close the node it is not.
""" """
@@ -1176,7 +1197,7 @@ class GuiProjectTree(QTreeWidget):
return return
@pyqtSlot("QPoint") @pyqtSlot("QPoint")
def _openContextMenu(self, clickPos): def _openContextMenu(self, clickPos: QPoint) -> bool:
"""The user right clicked an element in the project tree, so we """The user right clicked an element in the project tree, so we
open a context menu in-place. open a context menu in-place.
""" """
@@ -1329,7 +1350,7 @@ class GuiProjectTree(QTreeWidget):
if tItem.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild): if tItem.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
aDelete = ctxMenu.addAction(self.tr("Delete Permanently")) aDelete = ctxMenu.addAction(self.tr("Delete Permanently"))
aDelete.triggered.connect(lambda: self.permanentlyDeleteItem(tHandle)) aDelete.triggered.connect(lambda: self.permDeleteItem(tHandle))
else: else:
aMoveTrash = ctxMenu.addAction(self.tr("Move to Trash")) aMoveTrash = ctxMenu.addAction(self.tr("Move to Trash"))
aMoveTrash.triggered.connect(lambda: self.moveItemToTrash(tHandle)) aMoveTrash.triggered.connect(lambda: self.moveItemToTrash(tHandle))
@@ -1343,20 +1364,20 @@ class GuiProjectTree(QTreeWidget):
# Events # Events
## ##
def mousePressEvent(self, theEvent): def mousePressEvent(self, event: QMouseEvent) -> None:
"""Overload mousePressEvent to clear selection if clicking the """Overload mousePressEvent to clear selection if clicking the
mouse in a blank area of the tree view, and to load a document mouse in a blank area of the tree view, and to load a document
for viewing if the user middle-clicked. for viewing if the user middle-clicked.
""" """
super().mousePressEvent(theEvent) super().mousePressEvent(event)
if theEvent.button() == Qt.LeftButton: if event.button() == Qt.LeftButton:
selItem = self.indexAt(theEvent.pos()) selItem = self.indexAt(event.pos())
if not selItem.isValid(): if not selItem.isValid():
self.clearSelection() self.clearSelection()
elif theEvent.button() == Qt.MiddleButton: elif event.button() == Qt.MiddleButton:
selItem = self.itemAt(theEvent.pos()) selItem = self.itemAt(event.pos())
if not isinstance(selItem, QTreeWidgetItem): if not isinstance(selItem, QTreeWidgetItem):
return return
@@ -1370,36 +1391,31 @@ class GuiProjectTree(QTreeWidget):
return return
def dropEvent(self, theEvent): def dropEvent(self, event: QDropEvent) -> None:
"""Overload the drop item event to ensure relevant data has been """Overload the drop item event to ensure relevant data has been
updated. updated.
""" """
sHandle = self.getSelectedHandle() sHandle = self.getSelectedHandle()
if sHandle is None: sItem = self._getTreeItem(sHandle) if sHandle else None
if sHandle is None or sItem is None:
logger.error("Invalid drag and drop event") logger.error("Invalid drag and drop event")
return return
logger.debug("Drag'n'drop of item '%s' accepted", sHandle) logger.debug("Drag'n'drop of item '%s' accepted", sHandle)
sItem = self._getTreeItem(sHandle) isExpanded = sItem.isExpanded()
isExpanded = False
if sItem is not None:
isExpanded = sItem.isExpanded()
pItem = sItem.parent() pItem = sItem.parent()
pIndex = 0 pIndex = pItem.indexOfChild(sItem) if pItem else 0
if pItem is not None:
pIndex = pItem.indexOfChild(sItem)
wCount = self._getItemWordCount(sHandle) wCount = self._getItemWordCount(sHandle)
self.propagateCount(sHandle, 0) self.propagateCount(sHandle, 0)
super().dropEvent(theEvent) super().dropEvent(event)
self._postItemMove(sHandle, wCount) self._postItemMove(sHandle, wCount)
self._recordLastMove(sItem, pItem, pIndex) self._recordLastMove(sItem, pItem, pIndex)
self._alertTreeChange(sHandle, flush=True) self._alertTreeChange(sHandle, flush=True)
if sItem is not None:
sItem.setExpanded(isExpanded) sItem.setExpanded(isExpanded)
return return
@@ -1407,13 +1423,12 @@ class GuiProjectTree(QTreeWidget):
# Internal Functions # Internal Functions
## ##
def _postItemMove(self, tHandle, wCount): def _postItemMove(self, tHandle: str, wCount: int) -> bool:
"""Run various maintenance tasks for a moved item. """Run various maintenance tasks for a moved item."""
"""
trItemS = self._getTreeItem(tHandle) trItemS = self._getTreeItem(tHandle)
nwItemS = self.theProject.tree[tHandle] nwItemS = self.theProject.tree[tHandle]
trItemP = trItemS.parent() trItemP = trItemS.parent() if trItemS else None
if trItemP is None: if trItemP is None or nwItemS is None:
logger.error("Failed to find new parent item of '%s'", tHandle) logger.error("Failed to find new parent item of '%s'", tHandle)
return False return False
@@ -1443,21 +1458,17 @@ class GuiProjectTree(QTreeWidget):
return True return True
def _getItemWordCount(self, tHandle): def _getItemWordCount(self, tHandle: str) -> int:
"""Retrun the word count of a given item handle. """Return the word count of a given item handle."""
"""
tItem = self._getTreeItem(tHandle) tItem = self._getTreeItem(tHandle)
if tItem is None: return int(tItem.data(self.C_DATA, self.D_WORDS)) if tItem else 0
return 0
return int(tItem.data(self.C_DATA, self.D_WORDS))
def _getTreeItem(self, tHandle: str | None) -> QTreeWidgetItem | None: def _getTreeItem(self, tHandle: str | None) -> QTreeWidgetItem | None:
"""Return the QTreeWidgetItem of a given item handle.""" """Return the QTreeWidgetItem of a given item handle."""
return self._treeMap.get(tHandle, None) if tHandle else None return self._treeMap.get(tHandle, None) if tHandle else None
def _toggleItemActive(self, tHandle): def _toggleItemActive(self, tHandle: str) -> None:
"""Toggle the active status of an item. """Toggle the active status of an item."""
"""
tItem = self.theProject.tree[tHandle] tItem = self.theProject.tree[tHandle]
if tItem is not None: if tItem is not None:
tItem.setActive(not tItem.isActive) tItem.setActive(not tItem.isActive)
@@ -1465,7 +1476,7 @@ class GuiProjectTree(QTreeWidget):
self._alertTreeChange(tHandle, flush=False) self._alertTreeChange(tHandle, flush=False)
return return
def _recursiveSetExpanded(self, trItem, isExpanded): def _recursiveSetExpanded(self, trItem: QTreeWidgetItem, isExpanded: bool) -> None:
"""Recursive function to set expanded status starting from (and """Recursive function to set expanded status starting from (and
not including) a given item. not including) a given item.
""" """
@@ -1477,9 +1488,8 @@ class GuiProjectTree(QTreeWidget):
self._recursiveSetExpanded(chItem, isExpanded) self._recursiveSetExpanded(chItem, isExpanded)
return return
def _changeItemStatus(self, tHandle, tStatus): def _changeItemStatus(self, tHandle: str, tStatus: str) -> None:
"""Set a new status value of an item. """Set a new status value of an item."""
"""
tItem = self.theProject.tree[tHandle] tItem = self.theProject.tree[tHandle]
if tItem is not None: if tItem is not None:
tItem.setStatus(tStatus) tItem.setStatus(tStatus)
@@ -1487,9 +1497,8 @@ class GuiProjectTree(QTreeWidget):
self._alertTreeChange(tHandle, flush=False) self._alertTreeChange(tHandle, flush=False)
return return
def _changeItemImport(self, tHandle, tImport): def _changeItemImport(self, tHandle: str, tImport: str) -> None:
"""Set a new importance value of an item. """Set a new importance value of an item."""
"""
tItem = self.theProject.tree[tHandle] tItem = self.theProject.tree[tHandle]
if tItem is not None: if tItem is not None:
tItem.setImport(tImport) tItem.setImport(tImport)
@@ -1497,9 +1506,8 @@ class GuiProjectTree(QTreeWidget):
self._alertTreeChange(tHandle, flush=False) self._alertTreeChange(tHandle, flush=False)
return return
def _changeItemLayout(self, tHandle, itemLayout): def _changeItemLayout(self, tHandle: str, itemLayout: nwItemLayout) -> None:
"""Set a new item layout value of an item. """Set a new item layout value of an item."""
"""
tItem = self.theProject.tree[tHandle] tItem = self.theProject.tree[tHandle]
if tItem is not None: if tItem is not None:
if itemLayout == nwItemLayout.DOCUMENT and tItem.documentAllowed(): if itemLayout == nwItemLayout.DOCUMENT and tItem.documentAllowed():
@@ -1512,9 +1520,8 @@ class GuiProjectTree(QTreeWidget):
self._alertTreeChange(tHandle, flush=False) self._alertTreeChange(tHandle, flush=False)
return return
def _covertFolderToFile(self, tHandle, itemLayout): def _covertFolderToFile(self, tHandle: str, itemLayout: nwItemLayout) -> None:
"""Convert a folder to a note or document. """Convert a folder to a note or document."""
"""
tItem = self.theProject.tree[tHandle] tItem = self.theProject.tree[tHandle]
if tItem is not None and tItem.isFolderType(): if tItem is not None and tItem.isFolderType():
msgYes = self.mainGui.askQuestion( msgYes = self.mainGui.askQuestion(
@@ -1538,7 +1545,7 @@ class GuiProjectTree(QTreeWidget):
logger.info("Folder conversion cancelled") logger.info("Folder conversion cancelled")
return return
def _mergeDocuments(self, tHandle, newFile): def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
"""Merge an item's child documents into a single document.""" """Merge an item's child documents into a single document."""
logger.info("Request to merge items under handle '%s'", tHandle) logger.info("Request to merge items under handle '%s'", tHandle)
itemList = self.getTreeFromHandle(tHandle) itemList = self.getTreeFromHandle(tHandle)
@@ -1613,7 +1620,7 @@ class GuiProjectTree(QTreeWidget):
return True return True
def _splitDocument(self, tHandle): def _splitDocument(self, tHandle: str) -> bool:
"""Split a document into multiple documents.""" """Split a document into multiple documents."""
logger.info("Request to split items with handle '%s'", tHandle) logger.info("Request to split items with handle '%s'", tHandle)
@@ -1621,8 +1628,8 @@ class GuiProjectTree(QTreeWidget):
if tItem is None: if tItem is None:
return False return False
if not tItem.isFileType(): if not tItem.isFileType() or tItem.itemParent is None:
logger.error("Only documents can be split") logger.error("Only valid document items can be split")
return False return False
dlgSplit = GuiDocSplit(self.mainGui, tHandle) dlgSplit = GuiDocSplit(self.mainGui, tHandle)
@@ -1715,9 +1722,8 @@ class GuiProjectTree(QTreeWidget):
return itemList return itemList
def _addTreeItem( def _addTreeItem(self, nwItem: NWItem | None,
self, nwItem: NWItem | None, nHandle: str | None = None nHandle: str | None = None) -> QTreeWidgetItem | None:
) -> QTreeWidgetItem | None:
"""Create a QTreeWidgetItem from an NWItem and add it to the """Create a QTreeWidgetItem from an NWItem and add it to the
project tree. Returns the widget if the item is valid, otherwise project tree. Returns the widget if the item is valid, otherwise
a None is returned. a None is returned.
@@ -1768,7 +1774,7 @@ class GuiProjectTree(QTreeWidget):
return newItem return newItem
def _addTrashRoot(self): def _addTrashRoot(self) -> QTreeWidgetItem | None:
"""Adds the trash root folder if it doesn't already exist in the """Adds the trash root folder if it doesn't already exist in the
project tree. project tree.
""" """
@@ -1785,7 +1791,7 @@ class GuiProjectTree(QTreeWidget):
return trItem return trItem
def _alertTreeChange(self, tHandle, flush=False): def _alertTreeChange(self, tHandle: str | None, flush: bool = False) -> None:
"""Update information on tree change state, and emit necessary """Update information on tree change state, and emit necessary
signals. A flush is only needed if an item is moved, created or signals. A flush is only needed if an item is moved, created or
deleted. deleted.
@@ -1795,10 +1801,7 @@ class GuiProjectTree(QTreeWidget):
if flush: if flush:
self.saveTreeOrder() self.saveTreeOrder()
if tHandle is None: if tHandle is None or tHandle not in self.theProject.tree:
return
if tHandle not in self.theProject.tree:
return return
tItem = self.theProject.tree[tHandle] tItem = self.theProject.tree[tHandle]
@@ -1809,9 +1812,9 @@ class GuiProjectTree(QTreeWidget):
return return
def _recordLastMove(self, srcItem, parItem, parIndex): def _recordLastMove(self, srcItem: QTreeWidgetItem,
"""Record the last action so that it can be undone. parItem: QTreeWidgetItem, parIndex: int) -> None:
""" """Record the last action so that it can be undone."""
prevItem = self._lastMove.get("item", None) prevItem = self._lastMove.get("item", None)
if prevItem is None or srcItem != prevItem: if prevItem is None or srcItem != prevItem:
self._lastMove = { self._lastMove = {
@@ -1819,7 +1822,6 @@ class GuiProjectTree(QTreeWidget):
"parent": parItem, "parent": parItem,
"index": parIndex, "index": parIndex,
} }
return return
# END Class GuiProjectTree # END Class GuiProjectTree
+71 -17
View File
@@ -18,6 +18,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import pytest import pytest
@@ -32,7 +33,7 @@ from PyQt5.QtWidgets import QMessageBox, QMenu, QTreeWidgetItem, QDialog
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.enum import nwItemLayout, nwItemType, nwItemClass from novelwriter.enum import nwItemLayout, nwItemType, nwItemClass
from novelwriter.guimain import GuiMain from novelwriter.guimain import GuiMain
from novelwriter.gui.projtree import GuiProjectTree from novelwriter.gui.projtree import GuiProjectTree, GuiProjectView
from novelwriter.dialogs.docmerge import GuiDocMerge from novelwriter.dialogs.docmerge import GuiDocMerge
from novelwriter.dialogs.docsplit import GuiDocSplit from novelwriter.dialogs.docsplit import GuiDocSplit
from novelwriter.dialogs.editlabel import GuiEditLabel from novelwriter.dialogs.editlabel import GuiEditLabel
@@ -427,35 +428,35 @@ def testGuiProjTree_PermanentlyDeleteItem(qtbot, caplog, monkeypatch, nwGUI, pro
# Invalid item # Invalid item
caplog.clear() caplog.clear()
assert projTree.permanentlyDeleteItem(C.hInvalid) is False assert projTree.permDeleteItem(C.hInvalid) is False
assert "Could not find tree item for deletion" in caplog.text assert "Could not find tree item for deletion" in caplog.text
# Not deleting root item in use # Not deleting root item in use
caplog.clear() caplog.clear()
assert projTree.permanentlyDeleteItem(C.hNovelRoot) is False assert projTree.permDeleteItem(C.hNovelRoot) is False
assert "Root folders can only be deleted when they are empty" in caplog.text assert "Root folders can only be deleted when they are empty" in caplog.text
assert C.hNovelRoot in theProject.tree assert C.hNovelRoot in theProject.tree
# Deleting unused root item is allowed # Deleting unused root item is allowed
caplog.clear() caplog.clear()
assert projTree.permanentlyDeleteItem(C.hPlotRoot) is True assert projTree.permDeleteItem(C.hPlotRoot) is True
assert C.hPlotRoot not in theProject.tree assert C.hPlotRoot not in theProject.tree
# User cancels action # User cancels action
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No)
assert projTree.permanentlyDeleteItem(C.hTitlePage) is False assert projTree.permDeleteItem(C.hTitlePage) is False
assert C.hTitlePage in theProject.tree assert C.hTitlePage in theProject.tree
# Deleting file is OK, and if it is open, it should close # Deleting file is OK, and if it is open, it should close
assert nwGUI.openDocument(C.hTitlePage) is True assert nwGUI.openDocument(C.hTitlePage) is True
assert nwGUI.docEditor.docHandle() == C.hTitlePage assert nwGUI.docEditor.docHandle() == C.hTitlePage
assert projTree.permanentlyDeleteItem(C.hTitlePage) is True assert projTree.permDeleteItem(C.hTitlePage) is True
assert C.hTitlePage not in theProject.tree assert C.hTitlePage not in theProject.tree
assert nwGUI.docEditor.docHandle() is None assert nwGUI.docEditor.docHandle() is None
# Deleting folder + files recursively is ok # Deleting folder + files recursively is ok
assert projTree.permanentlyDeleteItem(C.hChapterDir) is True assert projTree.permDeleteItem(C.hChapterDir) is True
assert C.hChapterDir not in theProject.tree assert C.hChapterDir not in theProject.tree
assert C.hChapterDoc not in theProject.tree assert C.hChapterDoc not in theProject.tree
assert C.hSceneDoc not in theProject.tree assert C.hSceneDoc not in theProject.tree
@@ -904,15 +905,15 @@ def testGuiProjTree_Duplicate(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mock
@pytest.mark.gui @pytest.mark.gui
def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI, projPath, mockRnd): def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
"""Test various parts of the project tree class not covered by """Test various parts of the project tree class not covered by
other tests. other tests.
""" """
# Create a project # Create a project
buildTestProject(nwGUI, projPath) buildTestProject(nwGUI, projPath)
projView = nwGUI.projView projView: GuiProjectView = nwGUI.projView
projTree = nwGUI.projView.projTree projTree: GuiProjectTree = nwGUI.projView.projTree
# Method: initSettings # Method: initSettings
# ==================== # ====================
@@ -938,12 +939,12 @@ def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Try to add an orphaned file to the tree # Try to add an orphaned file to the tree
nHandle = nwGUI.theProject.newFile("Test", C.hNovelRoot) nHandle = nwGUI.theProject.newFile("Test", C.hNovelRoot)
nwGUI.theProject.tree[nHandle].setParent(None) nwGUI.theProject.tree[nHandle].setParent(None) # type: ignore
assert projTree.revealNewTreeItem(nHandle) is False assert projTree.revealNewTreeItem(nHandle) is False
# Try to add an item with unknown parent to the tree # Try to add an item with unknown parent to the tree
nHandle = nwGUI.theProject.newFile("Test", C.hNovelRoot) nHandle = nwGUI.theProject.newFile("Test", C.hNovelRoot)
nwGUI.theProject.tree[nHandle].setParent(C.hInvalid) nwGUI.theProject.tree[nHandle].setParent(C.hInvalid) # type: ignore
assert projTree.revealNewTreeItem(nHandle) is False assert projTree.revealNewTreeItem(nHandle) is False
# Method: undoLastMove # Method: undoLastMove
@@ -971,7 +972,7 @@ def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
assert nwGUI.docEditor.docHandle() is None assert nwGUI.docEditor.docHandle() is None
# When the item cannot be found # When the item cannot be found
projTree._getTreeItem(C.hTitlePage).setSelected(True) projTree._getTreeItem(C.hTitlePage).setSelected(True) # type: ignore
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.tree.NWTree.__getitem__", lambda *a: None) mp.setattr("novelwriter.core.tree.NWTree.__getitem__", lambda *a: None)
projTree._treeDoubleClick(QTreeWidgetItem(), 0) projTree._treeDoubleClick(QTreeWidgetItem(), 0)
@@ -980,14 +981,67 @@ def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Successfully open a file # Successfully open a file
projTree._treeDoubleClick(projTree._getTreeItem(C.hTitlePage), 0) projTree._treeDoubleClick(projTree._getTreeItem(C.hTitlePage), 0)
assert nwGUI.docEditor.docHandle() == C.hTitlePage assert nwGUI.docEditor.docHandle() == C.hTitlePage
projTree._getTreeItem(C.hTitlePage).setSelected(False) projTree._getTreeItem(C.hTitlePage).setSelected(False) # type: ignore
# A non-file item should be expanded instead # A non-file item should be expanded instead
projTree._getTreeItem(C.hNovelRoot).setExpanded(False) projTree._getTreeItem(C.hNovelRoot).setExpanded(False) # type: ignore
projTree._getTreeItem(C.hNovelRoot).setSelected(True) projTree._getTreeItem(C.hNovelRoot).setSelected(True) # type: ignore
projTree._treeDoubleClick(projTree._getTreeItem(C.hNovelRoot), 1) projTree._treeDoubleClick(projTree._getTreeItem(C.hNovelRoot), 1)
assert nwGUI.docEditor.docHandle() == C.hTitlePage assert nwGUI.docEditor.docHandle() == C.hTitlePage
assert projTree._getTreeItem(C.hNovelRoot).isExpanded() is True 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
# qtbot.stop() # qtbot.stop()