From aa64fdbfc3fbe71e542ca15abc8c2a45950ed4d7 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 29 Jul 2023 20:45:12 +0200 Subject: [PATCH 1/4] Annotate and clean up project tree source --- novelwriter/core/index.py | 32 +++--- novelwriter/gui/projtree.py | 224 +++++++++++++++--------------------- 2 files changed, 106 insertions(+), 150 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 5aa6b7fe..4e815ed8 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -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"" ## @@ -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 diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index e21f509d..8c6d1f9c 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -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, @@ -74,7 +74,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 @@ -132,83 +132,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 +205,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 +225,7 @@ class GuiProjectToolBar(QWidget): # Widget Label self.viewLabel = QLabel("%s" % 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 +329,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 +363,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 +379,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 +413,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 +452,7 @@ class GuiProjectTree(QTreeWidget): # Internal Variables self._treeMap = {} self._lastMove = {} - self._timeChanged = 0 + self._timeChanged = 0.0 # Build GUI # ========= @@ -553,7 +535,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 +640,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) @@ -1129,17 +1111,15 @@ class GuiProjectTree(QTreeWidget): return def openContextOnSelected(self): - """Open the context menu on the current selected item. - """ + """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 +1127,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 +1155,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. """ @@ -1343,20 +1322,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 +1349,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 +1381,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 +1416,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 +1434,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 +1446,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 +1455,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 +1464,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 +1478,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 +1503,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 +1578,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 +1586,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 +1680,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 +1732,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 +1749,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 +1759,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 +1770,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 +1780,6 @@ class GuiProjectTree(QTreeWidget): "parent": parItem, "index": parIndex, } - return # END Class GuiProjectTree From 1dadd1ddb7183d018c0361145d7fd0b0b94f824f Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 29 Jul 2023 20:49:23 +0200 Subject: [PATCH 2/4] Annotate some more functions in project tree source --- novelwriter/gui/projtree.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 8c6d1f9c..c7ac3de6 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -779,7 +779,7 @@ class GuiProjectTree(QTreeWidget): 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. @@ -827,7 +827,7 @@ class GuiProjectTree(QTreeWidget): 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. """ @@ -878,7 +878,8 @@ class GuiProjectTree(QTreeWidget): return True - def permanentlyDeleteItem(self, tHandle, askFirst=True, flush=True): + def permanentlyDeleteItem(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. """ @@ -942,7 +943,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. @@ -1035,7 +1036,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) @@ -1075,19 +1076,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 @@ -1101,7 +1100,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. @@ -1110,7 +1109,7 @@ class GuiProjectTree(QTreeWidget): self._recursiveSetExpanded(trItem, isExpanded) return - def openContextOnSelected(self): + def openContextOnSelected(self) -> bool: """Open the context menu on the current selected item.""" selItem = self.selectedItems() if selItem: From c52a37c276338865310208a5ccd73d5c7d016d62 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 29 Jul 2023 22:25:58 +0200 Subject: [PATCH 3/4] Add shift + arrow navigation feature to project tree (#1348) --- novelwriter/gui/projtree.py | 77 +++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index c7ac3de6..3af6aa9c 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -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 @@ -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) @@ -665,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 @@ -687,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 @@ -699,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] @@ -773,7 +817,7 @@ 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) @@ -820,7 +864,7 @@ 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) @@ -878,8 +922,7 @@ class GuiProjectTree(QTreeWidget): return True - def permanentlyDeleteItem(self, tHandle: str, - askFirst: bool = True, flush: bool = True) -> bool: + 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. """ @@ -1307,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)) From 26d97bd234380f7da47b0788ebf0b577dc170ce0 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 29 Jul 2023 22:26:13 +0200 Subject: [PATCH 4/4] Update tests and docs --- docs/source/usage_shortcuts.rst | 4 ++ tests/test_gui/test_gui_projtree.py | 88 +++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/docs/source/usage_shortcuts.rst b/docs/source/usage_shortcuts.rst index b132daae..1f7e709c 100644 --- a/docs/source/usage_shortcuts.rst +++ b/docs/source/usage_shortcuts.rst @@ -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`. diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index e538e97b..45e0ac04 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -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 . """ +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()