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