From 1a2bd7d640ace6b65f02f6812ae15ad1811e6d4b Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 21 Nov 2023 20:26:19 +0100 Subject: [PATCH 1/8] Update Qt enums and set selection to multi select --- novelwriter/gui/projtree.py | 70 ++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 9e60c69a..1315b955 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -98,42 +98,42 @@ class GuiProjectView(QWidget): # Keyboard Shortcuts self.keyMoveUp = QShortcut(self.projTree) self.keyMoveUp.setKey("Ctrl+Up") - self.keyMoveUp.setContext(Qt.WidgetShortcut) + self.keyMoveUp.setContext(Qt.ShortcutContext.WidgetShortcut) self.keyMoveUp.activated.connect(lambda: self.projTree.moveTreeItem(-1)) self.keyMoveDn = QShortcut(self.projTree) self.keyMoveDn.setKey("Ctrl+Down") - self.keyMoveDn.setContext(Qt.WidgetShortcut) + self.keyMoveDn.setContext(Qt.ShortcutContext.WidgetShortcut) self.keyMoveDn.activated.connect(lambda: self.projTree.moveTreeItem(1)) self.keyGoPrev = QShortcut(self.projTree) self.keyGoPrev.setKey("Alt+Up") - self.keyGoPrev.setContext(Qt.WidgetShortcut) + self.keyGoPrev.setContext(Qt.ShortcutContext.WidgetShortcut) self.keyGoPrev.activated.connect(lambda: self.projTree.moveToNextItem(-1)) self.keyGoNext = QShortcut(self.projTree) self.keyGoNext.setKey("Alt+Down") - self.keyGoNext.setContext(Qt.WidgetShortcut) + self.keyGoNext.setContext(Qt.ShortcutContext.WidgetShortcut) self.keyGoNext.activated.connect(lambda: self.projTree.moveToNextItem(1)) self.keyGoUp = QShortcut(self.projTree) self.keyGoUp.setKey("Alt+Left") - self.keyGoUp.setContext(Qt.WidgetShortcut) + self.keyGoUp.setContext(Qt.ShortcutContext.WidgetShortcut) self.keyGoUp.activated.connect(lambda: self.projTree.moveToLevel(-1)) self.keyGoDown = QShortcut(self.projTree) self.keyGoDown.setKey("Alt+Right") - self.keyGoDown.setContext(Qt.WidgetShortcut) + self.keyGoDown.setContext(Qt.ShortcutContext.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) + self.keyUndoMv.setContext(Qt.ShortcutContext.WidgetShortcut) self.keyUndoMv.activated.connect(lambda: self.projTree.undoLastMove()) self.keyContext = QShortcut(self.projTree) self.keyContext.setKey("Ctrl+.") - self.keyContext.setContext(Qt.WidgetShortcut) + self.keyContext.setContext(Qt.ShortcutContext.WidgetShortcut) self.keyContext.activated.connect(lambda: self.projTree.openContextOnSelected()) # Signals @@ -261,7 +261,7 @@ class GuiProjectToolBar(QWidget): self.tbQuick.setShortcut("Ctrl+L") self.tbQuick.setIconSize(QSize(iPx, iPx)) self.tbQuick.setMenu(self.mQuick) - self.tbQuick.setPopupMode(QToolButton.InstantPopup) + self.tbQuick.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) # Move Buttons self.tbMoveU = QToolButton(self) @@ -310,7 +310,7 @@ class GuiProjectToolBar(QWidget): self.tbAdd.setShortcut("Ctrl+N") self.tbAdd.setIconSize(QSize(iPx, iPx)) self.tbAdd.setMenu(self.mAdd) - self.tbAdd.setPopupMode(QToolButton.InstantPopup) + self.tbAdd.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) # More Options Menu self.mMore = QMenu(self) @@ -331,7 +331,7 @@ class GuiProjectToolBar(QWidget): self.tbMore.setToolTip(self.tr("More Options")) self.tbMore.setIconSize(QSize(iPx, iPx)) self.tbMore.setMenu(self.mMore) - self.tbMore.setPopupMode(QToolButton.InstantPopup) + self.tbMore.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) # Assemble self.outerBox = QHBoxLayout() @@ -358,7 +358,7 @@ class GuiProjectToolBar(QWidget): def updateTheme(self) -> None: """Update theme elements.""" qPalette = self.palette() - qPalette.setBrush(QPalette.Window, qPalette.base()) + qPalette.setBrush(QPalette.ColorRole.Window, qPalette.base()) self.setPalette(qPalette) fadeCol = qPalette.text().color() @@ -483,7 +483,7 @@ class GuiProjectTree(QTreeWidget): # ========= # Context Menu - self.setContextMenuPolicy(Qt.CustomContextMenu) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._openContextMenu) # Tree Settings @@ -491,7 +491,7 @@ class GuiProjectTree(QTreeWidget): cMg = CONFIG.pxInt(6) self.setIconSize(QSize(iPx, iPx)) - self.setFrameStyle(QFrame.NoFrame) + self.setFrameStyle(QFrame.Shape.NoFrame) self.setUniformRowHeights(True) self.setAllColumnsShowFocus(True) self.setExpandsOnDoubleClick(False) @@ -504,16 +504,16 @@ class GuiProjectTree(QTreeWidget): treeHeader = self.header() treeHeader.setStretchLastSection(False) treeHeader.setMinimumSectionSize(iPx + cMg) - treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.Stretch) - treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeToContents) - treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.Fixed) - treeHeader.setSectionResizeMode(self.C_STATUS, QHeaderView.Fixed) + treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch) + treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeMode.ResizeToContents) + treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.ResizeMode.Fixed) + treeHeader.setSectionResizeMode(self.C_STATUS, QHeaderView.ResizeMode.Fixed) treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg) treeHeader.resizeSection(self.C_STATUS, iPx + cMg) # Allow Move by Drag & Drop self.setDragEnabled(True) - self.setDragDropMode(QAbstractItemView.InternalMove) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDropIndicatorShown(True) # Disable built-in auto scroll as it isn't working in some Qt @@ -524,15 +524,15 @@ class GuiProjectTree(QTreeWidget): # Due to a bug, this stops working somewhere between Qt 5.15.3 # and 5.15.8, so this is also blocked in dropEvent (see #1569) trRoot = self.invisibleRootItem() - trRoot.setFlags(trRoot.flags() ^ Qt.ItemIsDropEnabled) + trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled) # Cached values self._lblActive = self.tr("Active") self._lblInactive = self.tr("Inactive") # Set selection options - self.setSelectionMode(QAbstractItemView.SingleSelection) - self.setSelectionBehavior(QAbstractItemView.SelectRows) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) # Connect signals self.itemDoubleClicked.connect(self._treeDoubleClick) @@ -556,13 +556,13 @@ class GuiProjectTree(QTreeWidget): """Set or update tree widget settings.""" # Scroll bars if CONFIG.hideVScroll: - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) else: - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) if CONFIG.hideHScroll: - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) else: - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) return ## @@ -1155,7 +1155,7 @@ class GuiProjectTree(QTreeWidget): selIndex = self.selectedIndexes() if selIndex and doScroll: - self.scrollTo(selIndex[0], QAbstractItemView.PositionAtCenter) + self.scrollTo(selIndex[0], QAbstractItemView.ScrollHint.PositionAtCenter) return True @@ -1399,12 +1399,12 @@ class GuiProjectTree(QTreeWidget): """ super().mousePressEvent(event) - if event.button() == Qt.LeftButton: + if event.button() == Qt.MouseButton.LeftButton: selItem = self.indexAt(event.pos()) if not selItem.isValid(): self.clearSelection() - elif event.button() == Qt.MiddleButton: + elif event.button() == Qt.MouseButton.MiddleButton: selItem = self.itemAt(event.pos()) if not isinstance(selItem, QTreeWidgetItem): return @@ -1611,7 +1611,7 @@ class GuiProjectTree(QTreeWidget): dlgMerge = GuiDocMerge(self.mainGui, tHandle, itemList) dlgMerge.exec_() - if dlgMerge.result() == QDialog.Accepted: + if dlgMerge.result() == QDialog.DialogCode.Accepted: mrgData = dlgMerge.getData() mrgList = mrgData.get("finalItems", []) @@ -1681,7 +1681,7 @@ class GuiProjectTree(QTreeWidget): dlgSplit = GuiDocSplit(self.mainGui, tHandle) dlgSplit.exec_() - if dlgSplit.result() == QDialog.Accepted: + if dlgSplit.result() == QDialog.DialogCode.Accepted: splitData, splitText = dlgSplit.getData() @@ -1786,10 +1786,10 @@ class GuiProjectTree(QTreeWidget): newItem.setText(self.C_ACTIVE, "") newItem.setText(self.C_STATUS, "") - newItem.setTextAlignment(self.C_NAME, Qt.AlignLeft) - newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight) - newItem.setTextAlignment(self.C_ACTIVE, Qt.AlignLeft) - newItem.setTextAlignment(self.C_STATUS, Qt.AlignLeft) + newItem.setTextAlignment(self.C_NAME, Qt.AlignmentFlag.AlignLeft) + newItem.setTextAlignment(self.C_COUNT, Qt.AlignmentFlag.AlignRight) + newItem.setTextAlignment(self.C_ACTIVE, Qt.AlignmentFlag.AlignLeft) + newItem.setTextAlignment(self.C_STATUS, Qt.AlignmentFlag.AlignLeft) newItem.setData(self.C_DATA, self.D_HANDLE, tHandle) newItem.setData(self.C_DATA, self.D_WORDS, 0) From f75ed33ceccd02e20be585064dd58ed1b15a300e Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 21 Nov 2023 22:26:10 +0100 Subject: [PATCH 2/8] Add a working implementation of multi-item move --- novelwriter/gui/projtree.py | 103 +++++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 38 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 1315b955..7324d3e7 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -31,7 +31,7 @@ from enum import Enum from time import time from typing import TYPE_CHECKING -from PyQt5.QtGui import QDragMoveEvent, QDropEvent, QMouseEvent, QPalette +from PyQt5.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QMouseEvent, QPalette from PyQt5.QtCore import QPoint, QTimer, Qt, QSize, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QAbstractItemView, QDialog, QFrame, QHBoxLayout, QHeaderView, QLabel, @@ -142,7 +142,6 @@ class GuiProjectView(QWidget): # Function Mappings self.emptyTrash = self.projTree.emptyTrash self.requestDeleteItem = self.projTree.requestDeleteItem - self.propagateCount = self.projTree.propagateCount self.getSelectedHandle = self.projTree.getSelectedHandle self.setSelectedHandle = self.projTree.setSelectedHandle self.changedSince = self.projTree.changedSince @@ -478,6 +477,7 @@ class GuiProjectTree(QTreeWidget): self._treeMap = {} self._lastMove = {} self._timeChanged = 0.0 + self._popAlert = None # Build GUI # ========= @@ -514,7 +514,6 @@ class GuiProjectTree(QTreeWidget): # Allow Move by Drag & Drop self.setDragEnabled(True) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) - self.setDropIndicatorShown(True) # Disable built-in auto scroll as it isn't working in some Qt # releases (see #1561) and instead use our own implementation @@ -523,8 +522,8 @@ class GuiProjectTree(QTreeWidget): # But don't allow drop on root level # Due to a bug, this stops working somewhere between Qt 5.15.3 # and 5.15.8, so this is also blocked in dropEvent (see #1569) - trRoot = self.invisibleRootItem() - trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled) + # trRoot = self.invisibleRootItem() + # trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled) # Cached values self._lblActive = self.tr("Active") @@ -926,14 +925,13 @@ class GuiProjectTree(QTreeWidget): logger.info("Action cancelled by user") return False - wCount = self._getItemWordCount(tHandle) self.propagateCount(tHandle, 0) tIndex = trItemP.indexOfChild(trItemS) trItemC = trItemP.takeChild(tIndex) trItemT.addChild(trItemC) - self._postItemMove(tHandle, wCount) + self._postItemMove(tHandle) self._recordLastMove(trItemS, trItemP, tIndex) self._alertTreeChange(tHandle, flush=flush) @@ -1120,14 +1118,13 @@ class GuiProjectTree(QTreeWidget): dHandle = dstItem.data(self.C_DATA, self.D_HANDLE) logger.debug("Moving item '%s' back to '%s', index %d", sHandle, dHandle, dstIndex) - wCount = self._getItemWordCount(sHandle) self.propagateCount(sHandle, 0) parItem = srcItem.parent() srcIndex = parItem.indexOfChild(srcItem) movItem = parItem.takeChild(srcIndex) dstItem.insertChild(dstIndex, movItem) - self._postItemMove(sHandle, wCount) + self._postItemMove(sHandle) self._alertTreeChange(sHandle, flush=True) self.setCurrentItem(movItem) @@ -1190,6 +1187,15 @@ class GuiProjectTree(QTreeWidget): tHandle = self.getSelectedHandle() if tHandle is not None: self.projView.selectedItemChanged.emit(tHandle) + + # When selecting multiple items, don't allow including root + # items in the selection and instead deselect them + items = self.selectedItems() + if items and len(items) > 1: + for item in items: + if item.parent() is None: + item.setSelected(False) + return @pyqtSlot("QTreeWidgetItem*", int) @@ -1419,6 +1425,31 @@ class GuiProjectTree(QTreeWidget): return + def startDrag(self, dropAction: Qt.DropActions) -> None: + """Capture the drag and drop handling to pop alerts.""" + super().startDrag(dropAction) + if self._popAlert: + SHARED.error(self._popAlert) + self._popAlert = None + return + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: + """Check that we're only dragging items that are siblings, and + not a root level item. + """ + items = self.selectedItems() + if items and (parent := items[0].parent()) and all(x.parent() is parent for x in items): + super().dragEnterEvent(event) + else: + logger.warning("Drag action is not allowed and has been cancelled") + self._popAlert = self.tr( + "Drag and drop is only allowed for single, non-root items, " + "or multiple items with the same parent." + ) + event.mimeData().clear() + event.ignore() + return + def dragMoveEvent(self, event: QDragMoveEvent) -> None: """Capture the drag move event to enable edge auto scroll.""" y = event.pos().y() @@ -1437,35 +1468,35 @@ class GuiProjectTree(QTreeWidget): """Overload the drop item event to ensure the drag and drop action is allowed, and update relevant data. """ - sHandle = self.getSelectedHandle() - sItem = self._getTreeItem(sHandle) if sHandle else None - if sHandle is None or sItem is None or sItem.parent() is None: - logger.error("Invalid drag and drop event") - event.ignore() - return - if not self.indexAt(event.pos()).isValid(): - # Needed due to a bug somewhere around Qt 5.15.8 that - # ignores the invisible root item flags + # Make sure nothing can be dropped on invisible root logger.error("Invalid drop location") event.ignore() return - logger.debug("Drag'n'drop of item '%s' accepted", sHandle) + mItems: dict[str, tuple[QTreeWidgetItem, QTreeWidgetItem, bool]] = {} + sItems = self.selectedItems() + if sItems and (parent := sItems[0].parent()) and all(x.parent() is parent for x in sItems): + for sItem in sItems: + if (pItem := sItem.parent()): + mHandle = str(sItem.data(self.C_DATA, self.D_HANDLE)) + mItems[mHandle] = (sItem, pItem, sItem.isExpanded()) + else: + logger.error("Cannot drag and drop a root item") + event.ignore() + return - isExpanded = sItem.isExpanded() - pItem = sItem.parent() - pIndex = pItem.indexOfChild(sItem) if pItem else 0 + for mHandle in mItems: + self.propagateCount(mHandle, 0) - wCount = self._getItemWordCount(sHandle) - self.propagateCount(sHandle, 0) + super().dropEvent(event) - super().dropEvent(event) - self._postItemMove(sHandle, wCount) - self._recordLastMove(sItem, pItem, pIndex) - self._alertTreeChange(sHandle, flush=True) + for mHandle, (sItem, pItem, isExpanded) in mItems.items(): + self._postItemMove(mHandle) + sItem.setExpanded(isExpanded) + self._alertTreeChange(mHandle, flush=False) - sItem.setExpanded(isExpanded) + self.saveTreeOrder() return @@ -1473,17 +1504,16 @@ class GuiProjectTree(QTreeWidget): # Internal Functions ## - def _postItemMove(self, tHandle: str, wCount: int) -> bool: + def _postItemMove(self, tHandle: str) -> None: """Run various maintenance tasks for a moved item.""" trItemS = self._getTreeItem(tHandle) nwItemS = SHARED.project.tree[tHandle] trItemP = trItemS.parent() if trItemS else None if trItemP is None or nwItemS is None: logger.error("Failed to find new parent item of '%s'", tHandle) - return False + return - # Update item parent handle in the project, make sure meta data - # is updated accordingly, and update word count + # Update item parent handle in the project pHandle = trItemP.data(self.C_DATA, self.D_HANDLE) nwItemS.setParent(pHandle) trItemP.setExpanded(True) @@ -1494,19 +1524,16 @@ class GuiProjectTree(QTreeWidget): for mHandle in mHandles: logger.debug("Updating item '%s'", mHandle) SHARED.project.tree.updateItemData(mHandle) - - # Update the index if nwItemS.isInactiveClass(): SHARED.project.index.deleteHandle(mHandle) else: SHARED.project.index.reIndexHandle(mHandle) - self.setTreeItemValues(mHandle) # Trigger dependent updates - self.propagateCount(tHandle, wCount) + self.propagateCount(tHandle, nwItemS.wordCount) - return True + return def _getItemWordCount(self, tHandle: str) -> int: """Return the word count of a given item handle.""" From 33a30f3875db46d27d5d1834ed955f3a47b7a8b1 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 21 Nov 2023 23:13:12 +0100 Subject: [PATCH 3/8] Remove tree undo and fix drop on root issue --- novelwriter/gui/projtree.py | 92 ++++------------------------- tests/test_gui/test_gui_projtree.py | 52 ++++------------ 2 files changed, 24 insertions(+), 120 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 7324d3e7..fed97b76 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -126,11 +126,6 @@ class GuiProjectView(QWidget): self.keyGoDown.setContext(Qt.ShortcutContext.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.ShortcutContext.WidgetShortcut) - self.keyUndoMv.activated.connect(lambda: self.projTree.undoLastMove()) - self.keyContext = QShortcut(self.projTree) self.keyContext.setKey("Ctrl+.") self.keyContext.setContext(Qt.ShortcutContext.WidgetShortcut) @@ -320,9 +315,6 @@ class GuiProjectToolBar(QWidget): self.aCollapse = self.mMore.addAction(self.tr("Collapse All")) self.aCollapse.triggered.connect(lambda: self.projTree.setExpandedFromHandle(None, False)) - self.aMoreUndo = self.mMore.addAction(self.tr("Undo Move")) - self.aMoreUndo.triggered.connect(lambda: self.projTree.undoLastMove()) - self.aEmptyTrash = self.mMore.addAction(self.tr("Empty Trash")) self.aEmptyTrash.triggered.connect(lambda: self.projTree.emptyTrash()) @@ -475,7 +467,6 @@ class GuiProjectTree(QTreeWidget): # Internal Variables self._treeMap = {} - self._lastMove = {} self._timeChanged = 0.0 self._popAlert = None @@ -522,8 +513,8 @@ class GuiProjectTree(QTreeWidget): # But don't allow drop on root level # Due to a bug, this stops working somewhere between Qt 5.15.3 # and 5.15.8, so this is also blocked in dropEvent (see #1569) - # trRoot = self.invisibleRootItem() - # trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled) + trRoot = self.invisibleRootItem() + trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled) # Cached values self._lblActive = self.tr("Active") @@ -572,7 +563,6 @@ class GuiProjectTree(QTreeWidget): """Clear the GUI content and the related map.""" self.clear() self._treeMap = {} - self._lastMove = {} self._timeChanged = 0.0 return @@ -743,7 +733,6 @@ class GuiProjectTree(QTreeWidget): cItem = pItem.takeChild(tIndex) pItem.insertChild(nIndex, cItem) - self._recordLastMove(cItem, pItem, tIndex) self._alertTreeChange(tHandle, flush=True) self.setCurrentItem(tItem) @@ -932,7 +921,6 @@ class GuiProjectTree(QTreeWidget): trItemT.addChild(trItemC) self._postItemMove(tHandle) - self._recordLastMove(trItemS, trItemP, tIndex) self._alertTreeChange(tHandle, flush=flush) logger.debug("Moved item '%s' to Trash", tHandle) @@ -1093,45 +1081,6 @@ class GuiProjectTree(QTreeWidget): logger.info("%d item(s) added to the project tree", count) return - def undoLastMove(self) -> bool: - """Attempt to undo the last action.""" - srcItem = self._lastMove.get("item", None) - dstItem = self._lastMove.get("parent", None) - dstIndex = self._lastMove.get("index", None) - - srcOK = isinstance(srcItem, QTreeWidgetItem) - dstOk = isinstance(dstItem, QTreeWidgetItem) - if not srcOK or not dstOk or dstIndex is None: - logger.debug("No tree move to undo") - return False - - if srcItem not in self._treeMap.values(): - logger.warning("Source item no longer exists") - return False - - if dstItem not in self._treeMap.values(): - logger.warning("Previous parent item no longer exists") - return False - - dstIndex = min(max(0, dstIndex), dstItem.childCount()) - sHandle = srcItem.data(self.C_DATA, self.D_HANDLE) - dHandle = dstItem.data(self.C_DATA, self.D_HANDLE) - logger.debug("Moving item '%s' back to '%s', index %d", sHandle, dHandle, dstIndex) - - self.propagateCount(sHandle, 0) - parItem = srcItem.parent() - srcIndex = parItem.indexOfChild(srcItem) - movItem = parItem.takeChild(srcIndex) - dstItem.insertChild(dstIndex, movItem) - - self._postItemMove(sHandle) - self._alertTreeChange(sHandle, flush=True) - - self.setCurrentItem(movItem) - self._lastMove = {} - - return True - def getSelectedHandle(self) -> str | None: """Get the currently selected handle. If multiple items are selected, return the first. @@ -1468,30 +1417,25 @@ class GuiProjectTree(QTreeWidget): """Overload the drop item event to ensure the drag and drop action is allowed, and update relevant data. """ - if not self.indexAt(event.pos()).isValid(): - # Make sure nothing can be dropped on invisible root + tItem = self.itemAt(event.pos()) + dropOn = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnItem + # Make sure nothing can be dropped on invisible root (see #1569) + if not tItem or tItem.parent() is None and not dropOn: logger.error("Invalid drop location") event.ignore() return - mItems: dict[str, tuple[QTreeWidgetItem, QTreeWidgetItem, bool]] = {} + mItems: dict[str, tuple[QTreeWidgetItem, bool]] = {} sItems = self.selectedItems() if sItems and (parent := sItems[0].parent()) and all(x.parent() is parent for x in sItems): for sItem in sItems: - if (pItem := sItem.parent()): - mHandle = str(sItem.data(self.C_DATA, self.D_HANDLE)) - mItems[mHandle] = (sItem, pItem, sItem.isExpanded()) - else: - logger.error("Cannot drag and drop a root item") - event.ignore() - return - - for mHandle in mItems: + mHandle = str(sItem.data(self.C_DATA, self.D_HANDLE)) + mItems[mHandle] = (sItem, sItem.isExpanded()) self.propagateCount(mHandle, 0) super().dropEvent(event) - for mHandle, (sItem, pItem, isExpanded) in mItems.items(): + for mHandle, (sItem, isExpanded) in mItems.items(): self._postItemMove(mHandle) sItem.setExpanded(isExpanded) self._alertTreeChange(mHandle, flush=False) @@ -1530,8 +1474,8 @@ class GuiProjectTree(QTreeWidget): SHARED.project.index.reIndexHandle(mHandle) self.setTreeItemValues(mHandle) - # Trigger dependent updates - self.propagateCount(tHandle, nwItemS.wordCount) + # Update word count + self.propagateCount(tHandle, nwItemS.wordCount, countChildren=True) return @@ -1884,16 +1828,4 @@ class GuiProjectTree(QTreeWidget): return - 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 = { - "item": srcItem, - "parent": parItem, - "index": parIndex, - } - return - # END Class GuiProjectTree diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index 0f834d65..c2163081 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -251,11 +251,6 @@ def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010", "0000000000012", "0000000000011", ] - assert projTree.undoLastMove() is True - assert projTree.getTreeFromHandle(C.hChapterDir) == [ - C.hChapterDir, C.hChapterDoc, C.hSceneDoc, - "0000000000010", "0000000000011", "0000000000012", - ] # Root Folder # =========== @@ -988,17 +983,14 @@ def testGuiProjTree_DragAndDrop(qtbot, monkeypatch, caplog, nwGUI: GuiMain, proj mouse = Qt.MouseButton.LeftButton modifier = Qt.KeyboardModifier.NoModifier - # Move an item - # The actual move is blocked, but the undo history should record - # the event, although without an actual move implied - assert projTree._lastMove == {} + projTree.saveTreeOrder() + treeOrder = SHARED.project.tree._order + + # Move an item, but no selection event = QDropEvent(nPos, action, mime, mouse, modifier) projTree.dropEvent(event) - assert projTree._lastMove == { - "item": projTree._getTreeItem(C.hSceneDoc), - "parent": projTree._getTreeItem(C.hChapterDir), - "index": 1, - } + projTree.saveTreeOrder() + assert SHARED.project.tree._order == treeOrder # Invalid location caplog.clear() @@ -1006,23 +998,19 @@ def testGuiProjTree_DragAndDrop(qtbot, monkeypatch, caplog, nwGUI: GuiMain, proj projTree.dropEvent(event) assert event.isAccepted() is False assert "Invalid drop location" in caplog.text - - # No item selected - caplog.clear() - event = QDropEvent(nPos, action, mime, mouse, modifier) - projTree.clearSelection() - projTree.dropEvent(event) - assert event.isAccepted() is False - assert "Invalid drag and drop event" in caplog.text + projTree.saveTreeOrder() + assert SHARED.project.tree._order == treeOrder # Root item selected caplog.clear() event = QDropEvent(nPos, action, mime, mouse, modifier) projTree.clearSelection() - projTree.setSelectedHandle(C.hNovelRoot, True) + projTree._getTreeItem(C.hTitlePage).setSelected(True) # type: ignore + projTree._getTreeItem(C.hNovelRoot).setSelected(True) # type: ignore projTree.dropEvent(event) assert event.isAccepted() is False - assert "Invalid drag and drop event" in caplog.text + projTree.saveTreeOrder() + assert SHARED.project.tree._order == treeOrder # qtbot.stop() @@ -1070,22 +1058,6 @@ def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd) SHARED.project.tree[nHandle].setParent(C.hInvalid) # type: ignore assert projTree.revealNewTreeItem(nHandle) is False - # Method: undoLastMove - # ==================== - - # Nothing to move - assert projTree.undoLastMove() is False - - projTree._lastMove["item"] = QTreeWidgetItem() - projTree._lastMove["parent"] = QTreeWidgetItem() - projTree._lastMove["index"] = 0 - assert projTree.undoLastMove() is False - - projTree._lastMove["item"] = projTree._treeMap[C.hTitlePage] - projTree._lastMove["parent"] = QTreeWidgetItem() - projTree._lastMove["index"] = 0 - assert projTree.undoLastMove() is False - # Slot: _treeDoubleClick # ====================== From 40f6a108edca9b3322854f6f164559b2c26a97d5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 22 Nov 2023 00:03:51 +0100 Subject: [PATCH 4/8] Add a multi-select context menu and handlers --- novelwriter/gui/projtree.py | 94 +++++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index fed97b76..0c9189e7 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -1185,6 +1185,9 @@ class GuiProjectTree(QTreeWidget): logger.debug("No item found") return False + if len(self.selectedItems()) > 1: + return self._openMultiSelectContextMenu(clickPos, tItem) + ctxMenu = QMenu(self) # Trash Folder @@ -1236,7 +1239,7 @@ class GuiProjectTree(QTreeWidget): lambda n, key=key: self._changeItemStatus(tHandle, key) ) mStatus.addSeparator() - aManage1 = mStatus.addAction("Manage Labels ...") + aManage1 = mStatus.addAction(self.tr("Manage Labels ...")) aManage1.triggered.connect( lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_STATUS) ) @@ -1249,7 +1252,7 @@ class GuiProjectTree(QTreeWidget): lambda n, key=key: self._changeItemImport(tHandle, key) ) mImport.addSeparator() - aManage2 = mImport.addAction("Manage Labels ...") + aManage2 = mImport.addAction(self.tr("Manage Labels ...")) aManage2.triggered.connect( lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_IMPORT) ) @@ -1512,7 +1515,7 @@ class GuiProjectTree(QTreeWidget): def _changeItemStatus(self, tHandle: str, tStatus: str) -> None: """Set a new status value of an item.""" tItem = SHARED.project.tree[tHandle] - if tItem is not None: + if tItem and tItem.isDocumentLayout(): tItem.setStatus(tStatus) self.setTreeItemValues(tItem.itemHandle) self._alertTreeChange(tHandle, flush=False) @@ -1521,7 +1524,7 @@ class GuiProjectTree(QTreeWidget): def _changeItemImport(self, tHandle: str, tImport: str) -> None: """Set a new importance value of an item.""" tItem = SHARED.project.tree[tHandle] - if tItem is not None: + if tItem and tItem.isNoteLayout(): tItem.setImport(tImport) self.setTreeItemValues(tItem.itemHandle) self._alertTreeChange(tHandle, flush=False) @@ -1828,4 +1831,87 @@ class GuiProjectTree(QTreeWidget): return + ## + # Multi-Select Context Menu Functions + ## + + def _openMultiSelectContextMenu(self, clickPos: QPoint, tItem: NWItem) -> bool: + """Alternative menu for multiple selected items.""" + ctxMenu = QMenu(self) + + mActive = ctxMenu.addMenu(self.tr("Set Active to ...")) + aActive = mActive.addAction(SHARED.theme.getIcon("checked"), self.tr("Active")) + aActive.triggered.connect(lambda: self._iterItemActive(True)) + aInactive = mActive.addAction(SHARED.theme.getIcon("unchecked"), self.tr("Inactive")) + aInactive.triggered.connect(lambda: self._iterItemActive(False)) + + if tItem.isNovelLike(): + mStatus = ctxMenu.addMenu(self.tr("Set Status to ...")) + for n, (key, entry) in enumerate(SHARED.project.data.itemStatus.items()): + aStatus = mStatus.addAction(entry["icon"], entry["name"]) + aStatus.triggered.connect(lambda n, key=key: self._iterSetItemStatus(key)) + mStatus.addSeparator() + aManage1 = mStatus.addAction(self.tr("Manage Labels ...")) + aManage1.triggered.connect( + lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_STATUS) + ) + else: + mImport = ctxMenu.addMenu(self.tr("Set Importance to ...")) + for n, (key, entry) in enumerate(SHARED.project.data.itemImport.items()): + aImport = mImport.addAction(entry["icon"], entry["name"], ) + aImport.triggered.connect(lambda n, key=key: self._iterSetItemImport(key)) + mImport.addSeparator() + aManage2 = mImport.addAction(self.tr("Manage Labels ...")) + aManage2.triggered.connect( + lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_IMPORT) + ) + + ctxMenu.addSeparator() + + aMoveTrash = ctxMenu.addAction(self.tr("Move to Trash")) + aMoveTrash.triggered.connect(self._iterMoveToTrash) + + # Show Context Menu + ctxMenu.exec_(self.viewport().mapToGlobal(clickPos)) + + return True + + def _iterItemActive(self, active: bool) -> None: + """Change the active status multiple items.""" + for item in self.selectedItems(): + tHandle = str(item.data(self.C_DATA, self.D_HANDLE)) + tItem = SHARED.project.tree[tHandle] + if tItem and tItem.isFileType(): + tItem.setActive(active) + self.setTreeItemValues(tItem.itemHandle) + self._alertTreeChange(tHandle, flush=False) + return + + def _iterSetItemStatus(self, tStatus: str) -> None: + """Change the status value for multiple items.""" + for item in self.selectedItems(): + tHandle = str(item.data(self.C_DATA, self.D_HANDLE)) + self._changeItemStatus(tHandle, tStatus) + return + + def _iterSetItemImport(self, tStatus: str) -> None: + """Change the importance value for multiple items.""" + for item in self.selectedItems(): + tHandle = str(item.data(self.C_DATA, self.D_HANDLE)) + self._changeItemImport(tHandle, tStatus) + return + + @pyqtSlot() + def _iterMoveToTrash(self) -> None: + """Iterate through files and move them to Trash.""" + items = self.selectedItems() + if SHARED.question(self.tr("Move {0} items to Trash?").format(len(items))): + for item in self.selectedItems(): + tHandle = str(item.data(self.C_DATA, self.D_HANDLE)) + tItem = SHARED.project.tree[tHandle] + if tItem and tItem.isFileType(): + self.moveItemToTrash(tHandle, askFirst=False, flush=False) + self.saveTreeOrder() + return + # END Class GuiProjectTree From d7730fb7c2654d5c394682aba8542fc7e7b65109 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 22 Nov 2023 00:12:14 +0100 Subject: [PATCH 5/8] Clean up a few bits --- novelwriter/gui/projtree.py | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 0c9189e7..a77ac58b 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -1356,25 +1356,16 @@ class GuiProjectTree(QTreeWidget): for viewing if the user middle-clicked. """ super().mousePressEvent(event) - if event.button() == Qt.MouseButton.LeftButton: selItem = self.indexAt(event.pos()) if not selItem.isValid(): self.clearSelection() - elif event.button() == Qt.MouseButton.MiddleButton: selItem = self.itemAt(event.pos()) - if not isinstance(selItem, QTreeWidgetItem): - return - - tHandle = selItem.data(self.C_DATA, self.D_HANDLE) - tItem = SHARED.project.tree[tHandle] - if tItem is None: - return - - if tItem.isFileType(): - self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False) - + if selItem: + tHandle = selItem.data(self.C_DATA, self.D_HANDLE) + if (tItem := SHARED.project.tree[tHandle]) and tItem.isFileType(): + self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False) return def startDrag(self, dropAction: Qt.DropActions) -> None: @@ -1395,8 +1386,8 @@ class GuiProjectTree(QTreeWidget): else: logger.warning("Drag action is not allowed and has been cancelled") self._popAlert = self.tr( - "Drag and drop is only allowed for single, non-root items, " - "or multiple items with the same parent." + "Drag and drop is only allowed for single items, non-root " + "items, or multiple items with the same parent." ) event.mimeData().clear() event.ignore() @@ -1876,13 +1867,13 @@ class GuiProjectTree(QTreeWidget): return True - def _iterItemActive(self, active: bool) -> None: + def _iterItemActive(self, isActive: bool) -> None: """Change the active status multiple items.""" for item in self.selectedItems(): tHandle = str(item.data(self.C_DATA, self.D_HANDLE)) tItem = SHARED.project.tree[tHandle] if tItem and tItem.isFileType(): - tItem.setActive(active) + tItem.setActive(isActive) self.setTreeItemValues(tItem.itemHandle) self._alertTreeChange(tHandle, flush=False) return From ea0358b2edad9d06b6b08f4768e45ea89493004c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 22 Nov 2023 18:08:07 +0100 Subject: [PATCH 6/8] Move project tree context menu to a separate class --- novelwriter/gui/projtree.py | 577 +++++++++++++++++++----------------- 1 file changed, 308 insertions(+), 269 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index a77ac58b..4b261afd 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -836,6 +836,7 @@ class GuiProjectTree(QTreeWidget): return status + @pyqtSlot() def emptyTrash(self) -> bool: """Permanently delete all documents in the Trash folder. This function only asks for confirmation once, and calls the regular @@ -1175,163 +1176,29 @@ class GuiProjectTree(QTreeWidget): tItem = None tHandle = None hasChild = False - selItem = self.itemAt(clickPos) - if isinstance(selItem, QTreeWidgetItem): - tHandle = selItem.data(self.C_DATA, self.D_HANDLE) + sItem = self.itemAt(clickPos) + sItems = self.selectedItems() + if isinstance(sItem, QTreeWidgetItem): + tHandle = sItem.data(self.C_DATA, self.D_HANDLE) tItem = SHARED.project.tree[tHandle] - hasChild = selItem.childCount() > 0 + hasChild = sItem.childCount() > 0 if tItem is None or tHandle is None: logger.debug("No item found") return False - if len(self.selectedItems()) > 1: - return self._openMultiSelectContextMenu(clickPos, tItem) - - ctxMenu = QMenu(self) - - # Trash Folder - # ============ - + ctxMenu = _TreeContextMenu(self, tItem) trashHandle = SHARED.project.tree.trashRoot - if tItem.itemHandle == trashHandle and trashHandle is not None: - # The trash folder only has one option - aEmptyTrash = ctxMenu.addAction(self.tr("Empty Trash")) - aEmptyTrash.triggered.connect(lambda: self.emptyTrash()) - ctxMenu.exec_(self.viewport().mapToGlobal(clickPos)) - return True - - # Document Actions - # ================ - - isRoot = tItem.isRootType() - isFolder = tItem.isFolderType() - isFile = tItem.isFileType() - - if isFile: - aOpenDoc = ctxMenu.addAction(self.tr("Open Document")) - aOpenDoc.triggered.connect( - lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "", True) - ) - aViewDoc = ctxMenu.addAction(self.tr("View Document")) - aViewDoc.triggered.connect( - lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False) - ) - ctxMenu.addSeparator() - - # Edit Item Settings - # ================== - - aLabel = ctxMenu.addAction(self.tr("Rename")) - aLabel.triggered.connect(lambda: self.renameTreeItem(tHandle)) - - if isFile: - aActive = ctxMenu.addAction(self.tr("Toggle Active")) - aActive.triggered.connect(lambda: self._toggleItemActive(tHandle)) - - checkMark = f" ({nwUnicode.U_CHECK})" - if tItem.isNovelLike(): - mStatus = ctxMenu.addMenu(self.tr("Set Status to ...")) - for n, (key, entry) in enumerate(SHARED.project.data.itemStatus.items()): - entryName = entry["name"] + (checkMark if tItem.itemStatus == key else "") - aStatus = mStatus.addAction(entry["icon"], entryName) - aStatus.triggered.connect( - lambda n, key=key: self._changeItemStatus(tHandle, key) - ) - mStatus.addSeparator() - aManage1 = mStatus.addAction(self.tr("Manage Labels ...")) - aManage1.triggered.connect( - lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_STATUS) - ) + if trashHandle and tHandle == trashHandle: + ctxMenu.buildTrashMenu() + elif len(sItems) > 1: + handles = [str(x.data(self.C_DATA, self.D_HANDLE)) for x in sItems] + ctxMenu.buildMultiSelectMenu(handles) else: - mImport = ctxMenu.addMenu(self.tr("Set Importance to ...")) - for n, (key, entry) in enumerate(SHARED.project.data.itemImport.items()): - entryName = entry["name"] + (checkMark if tItem.itemImport == key else "") - aImport = mImport.addAction(entry["icon"], entryName) - aImport.triggered.connect( - lambda n, key=key: self._changeItemImport(tHandle, key) - ) - mImport.addSeparator() - aManage2 = mImport.addAction(self.tr("Manage Labels ...")) - aManage2.triggered.connect( - lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_IMPORT) - ) + ctxMenu.buildSingleSelectMenu(hasChild) - # Transform Item - # ============== - - if not isRoot: - mTrans = ctxMenu.addMenu(self.tr("Transform")) - - trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT]) - trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE]) - - isDocFile = isFile and tItem.isDocumentLayout() - isNoteFile = isFile and tItem.isNoteLayout() - - if isNoteFile and tItem.documentAllowed(): - aConvert1 = mTrans.addAction(self.tr("Convert to {0}").format(trDoc)) - aConvert1.triggered.connect( - lambda: self._changeItemLayout(tHandle, nwItemLayout.DOCUMENT) - ) - - if isDocFile: - aConvert2 = mTrans.addAction(self.tr("Convert to {0}").format(trNote)) - aConvert2.triggered.connect( - lambda: self._changeItemLayout(tHandle, nwItemLayout.NOTE) - ) - - if isFolder and tItem.documentAllowed(): - aConvert3 = mTrans.addAction(self.tr("Convert to {0}").format(trDoc)) - aConvert3.triggered.connect( - lambda: self._covertFolderToFile(tHandle, nwItemLayout.DOCUMENT) - ) - - if isFolder: - aConvert4 = mTrans.addAction(self.tr("Convert to {0}").format(trNote)) - aConvert4.triggered.connect( - lambda: self._covertFolderToFile(tHandle, nwItemLayout.NOTE) - ) - - if hasChild and isFile: - aMerge1 = mTrans.addAction(self.tr("Merge Child Items into Self")) - aMerge1.triggered.connect(lambda: self._mergeDocuments(tHandle, False)) - aMerge2 = mTrans.addAction(self.tr("Merge Child Items into New")) - aMerge2.triggered.connect(lambda: self._mergeDocuments(tHandle, True)) - - if hasChild and isFolder: - aMerge3 = mTrans.addAction(self.tr("Merge Documents in Folder")) - aMerge3.triggered.connect(lambda: self._mergeDocuments(tHandle, True)) - - if isFile: - aSplit1 = mTrans.addAction(self.tr("Split Document by Headers")) - aSplit1.triggered.connect(lambda: self._splitDocument(tHandle)) - - # Expand/Collapse/Delete/Duplicate - # ================================ - - ctxMenu.addSeparator() - - if hasChild: - aExpand = ctxMenu.addAction(self.tr("Expand All")) - aExpand.triggered.connect(lambda: self.setExpandedFromHandle(tHandle, True)) - aCollapse = ctxMenu.addAction(self.tr("Collapse All")) - aCollapse.triggered.connect(lambda: self.setExpandedFromHandle(tHandle, False)) - aDuplicate = ctxMenu.addAction(self.tr("Duplicate from Here")) - aDuplicate.triggered.connect(lambda: self._duplicateFromHandle(tHandle)) - elif isFile: - aDuplicate = ctxMenu.addAction(self.tr("Duplicate Document")) - aDuplicate.triggered.connect(lambda: self._duplicateFromHandle(tHandle)) - - if tItem.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild): - aDelete = ctxMenu.addAction(self.tr("Delete Permanently")) - aDelete.triggered.connect(lambda: self.permDeleteItem(tHandle)) - else: - aMoveTrash = ctxMenu.addAction(self.tr("Move to Trash")) - aMoveTrash.triggered.connect(lambda: self.moveItemToTrash(tHandle)) - - # Show Context Menu ctxMenu.exec_(self.viewport().mapToGlobal(clickPos)) + ctxMenu.deleteLater() return True @@ -1482,15 +1349,6 @@ class GuiProjectTree(QTreeWidget): """Return the QTreeWidgetItem of a given item handle.""" return self._treeMap.get(tHandle, None) if tHandle else None - def _toggleItemActive(self, tHandle: str) -> None: - """Toggle the active status of an item.""" - tItem = SHARED.project.tree[tHandle] - if tItem is not None: - tItem.setActive(not tItem.isActive) - self.setTreeItemValues(tItem.itemHandle) - self._alertTreeChange(tHandle, flush=False) - return - def _recursiveSetExpanded(self, trItem: QTreeWidgetItem, isExpanded: bool) -> None: """Recursive function to set expanded status starting from (and not including) a given item. @@ -1503,60 +1361,6 @@ class GuiProjectTree(QTreeWidget): self._recursiveSetExpanded(chItem, isExpanded) return - def _changeItemStatus(self, tHandle: str, tStatus: str) -> None: - """Set a new status value of an item.""" - tItem = SHARED.project.tree[tHandle] - if tItem and tItem.isDocumentLayout(): - tItem.setStatus(tStatus) - self.setTreeItemValues(tItem.itemHandle) - self._alertTreeChange(tHandle, flush=False) - return - - def _changeItemImport(self, tHandle: str, tImport: str) -> None: - """Set a new importance value of an item.""" - tItem = SHARED.project.tree[tHandle] - if tItem and tItem.isNoteLayout(): - tItem.setImport(tImport) - self.setTreeItemValues(tItem.itemHandle) - self._alertTreeChange(tHandle, flush=False) - return - - def _changeItemLayout(self, tHandle: str, itemLayout: nwItemLayout) -> None: - """Set a new item layout value of an item.""" - tItem = SHARED.project.tree[tHandle] - if tItem is not None: - if itemLayout == nwItemLayout.DOCUMENT and tItem.documentAllowed(): - tItem.setLayout(nwItemLayout.DOCUMENT) - self.setTreeItemValues(tHandle) - self._alertTreeChange(tHandle, flush=False) - elif itemLayout == nwItemLayout.NOTE: - tItem.setLayout(nwItemLayout.NOTE) - self.setTreeItemValues(tHandle) - self._alertTreeChange(tHandle, flush=False) - return - - def _covertFolderToFile(self, tHandle: str, itemLayout: nwItemLayout) -> None: - """Convert a folder to a note or document.""" - tItem = SHARED.project.tree[tHandle] - if tItem is not None and tItem.isFolderType(): - msgYes = SHARED.question(self.tr( - "Do you want to convert the folder to a {0}? " - "This action cannot be reversed." - ).format(trConst(nwLabels.LAYOUT_NAME[itemLayout]))) - if msgYes and itemLayout == nwItemLayout.DOCUMENT and tItem.documentAllowed(): - tItem.setType(nwItemType.FILE) - tItem.setLayout(nwItemLayout.DOCUMENT) - self.setTreeItemValues(tHandle) - self._alertTreeChange(tHandle, flush=False) - elif msgYes and itemLayout == nwItemLayout.NOTE: - tItem.setType(nwItemType.FILE) - tItem.setLayout(nwItemLayout.NOTE) - self.setTreeItemValues(tHandle) - self._alertTreeChange(tHandle, flush=False) - else: - logger.info("Folder conversion cancelled") - return - def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool: """Merge an item's child documents into a single document.""" logger.info("Request to merge items under handle '%s'", tHandle) @@ -1822,87 +1626,322 @@ class GuiProjectTree(QTreeWidget): return +# END Class GuiProjectTree + + +class _TreeContextMenu(QMenu): + + def __init__(self, projTree: GuiProjectTree, nwItem: NWItem) -> None: + super().__init__(parent=projTree) + + self.projTree = projTree + self.projView = projTree.projView + + self._item = nwItem + self._handle = nwItem.itemHandle + self._items: list[str] = [] + + logger.debug("Ready: _TreeContextMenu") + + return + + def __del__(self): # pragma: no cover + logger.debug("Delete: _TreeContextMenu") + return + ## - # Multi-Select Context Menu Functions + # Methods ## - def _openMultiSelectContextMenu(self, clickPos: QPoint, tItem: NWItem) -> bool: - """Alternative menu for multiple selected items.""" - ctxMenu = QMenu(self) + def buildTrashMenu(self) -> None: + """Build the special menu for the Trash folder.""" + action = self.addAction(self.tr("Empty Trash")) + action.triggered.connect(self.projTree.emptyTrash) + return - mActive = ctxMenu.addMenu(self.tr("Set Active to ...")) - aActive = mActive.addAction(SHARED.theme.getIcon("checked"), self.tr("Active")) - aActive.triggered.connect(lambda: self._iterItemActive(True)) - aInactive = mActive.addAction(SHARED.theme.getIcon("unchecked"), self.tr("Inactive")) - aInactive.triggered.connect(lambda: self._iterItemActive(False)) + def buildSingleSelectMenu(self, hasChild: bool) -> None: + """Build the single-select menu.""" + isFile = self._item.isFileType() + isFolder = self._item.isFolderType() + isRoot = self._item.isRootType() - if tItem.isNovelLike(): - mStatus = ctxMenu.addMenu(self.tr("Set Status to ...")) + # Document Actions + if isFile: + self._docActions() + self.addSeparator() + + # Edit Item Settings + aLabel = self.addAction(self.tr("Rename")) + aLabel.triggered.connect(lambda: self.projTree.renameTreeItem(self._handle)) + if isFile: + self._itemActive(False) + self._itemStatusImport(False) + + # Transform Item + if isFile or isFolder: + self._itemTransform(isFile, isFolder, hasChild) + self.addSeparator() + + # Process Item + self._itemProcess(isFile, isFolder, isRoot, hasChild) + + return + + def buildMultiSelectMenu(self, items: list[str]) -> None: + """Build the multi-select menu.""" + self._items = items + self._itemActive(True) + self._itemStatusImport(True) + self.addSeparator() + self._moveToTrash(True) + return + + ## + # Menu Builders + ## + + def _docActions(self) -> None: + """Add document actions.""" + action = self.addAction(self.tr("Open Document")) + action.triggered.connect( + lambda: self.projView.openDocumentRequest.emit(self._handle, nwDocMode.EDIT, "", True) + ) + action = self.addAction(self.tr("View Document")) + action.triggered.connect( + lambda: self.projView.openDocumentRequest.emit(self._handle, nwDocMode.VIEW, "", False) + ) + return + + def _itemActive(self, multi: bool) -> None: + """Add Active/Inactive actions.""" + if multi: + mSub = self.addMenu(self.tr("Set Active to ...")) + aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self.tr("Active")) + aOne.triggered.connect(lambda: self._iterItemActive(True)) + aTwo = mSub.addAction(SHARED.theme.getIcon("unchecked"), self.tr("Inactive")) + aTwo.triggered.connect(lambda: self._iterItemActive(False)) + else: + action = self.addAction(self.tr("Toggle Active")) + action.triggered.connect(self._toggleItemActive) + return + + def _itemStatusImport(self, multi: bool) -> None: + """Add actions for changing status or importance.""" + if self._item.isNovelLike(): + menu = self.addMenu(self.tr("Set Status to ...")) + current = self._item.itemStatus for n, (key, entry) in enumerate(SHARED.project.data.itemStatus.items()): - aStatus = mStatus.addAction(entry["icon"], entry["name"]) - aStatus.triggered.connect(lambda n, key=key: self._iterSetItemStatus(key)) - mStatus.addSeparator() - aManage1 = mStatus.addAction(self.tr("Manage Labels ...")) - aManage1.triggered.connect( + name = entry["name"] + if not multi and current == key: + name += f" ({nwUnicode.U_CHECK})" + action = menu.addAction(entry["icon"], name) + if multi: + action.triggered.connect(lambda n, key=key: self._iterSetItemStatus(key)) + else: + action.triggered.connect(lambda n, key=key: self._changeItemStatus(key)) + menu.addSeparator() + action = menu.addAction(self.tr("Manage Labels ...")) + action.triggered.connect( lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_STATUS) ) else: - mImport = ctxMenu.addMenu(self.tr("Set Importance to ...")) + menu = self.addMenu(self.tr("Set Importance to ...")) + current = self._item.itemImport for n, (key, entry) in enumerate(SHARED.project.data.itemImport.items()): - aImport = mImport.addAction(entry["icon"], entry["name"], ) - aImport.triggered.connect(lambda n, key=key: self._iterSetItemImport(key)) - mImport.addSeparator() - aManage2 = mImport.addAction(self.tr("Manage Labels ...")) - aManage2.triggered.connect( + name = entry["name"] + if not multi and current == key: + name += f" ({nwUnicode.U_CHECK})" + action = menu.addAction(entry["icon"], name) + if multi: + action.triggered.connect(lambda n, key=key: self._iterSetItemImport(key)) + else: + action.triggered.connect(lambda n, key=key: self._changeItemImport(key)) + menu.addSeparator() + action = menu.addAction(self.tr("Manage Labels ...")) + action.triggered.connect( lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_IMPORT) ) - - ctxMenu.addSeparator() - - aMoveTrash = ctxMenu.addAction(self.tr("Move to Trash")) - aMoveTrash.triggered.connect(self._iterMoveToTrash) - - # Show Context Menu - ctxMenu.exec_(self.viewport().mapToGlobal(clickPos)) - - return True - - def _iterItemActive(self, isActive: bool) -> None: - """Change the active status multiple items.""" - for item in self.selectedItems(): - tHandle = str(item.data(self.C_DATA, self.D_HANDLE)) - tItem = SHARED.project.tree[tHandle] - if tItem and tItem.isFileType(): - tItem.setActive(isActive) - self.setTreeItemValues(tItem.itemHandle) - self._alertTreeChange(tHandle, flush=False) return - def _iterSetItemStatus(self, tStatus: str) -> None: - """Change the status value for multiple items.""" - for item in self.selectedItems(): - tHandle = str(item.data(self.C_DATA, self.D_HANDLE)) - self._changeItemStatus(tHandle, tStatus) + def _itemTransform(self, isFile: bool, isFolder: bool, hasChild: bool) -> None: + """Add actions for the Transform menu.""" + menu = self.addMenu(self.tr("Transform")) + + tree = self.projTree + tHandle = self._handle + + trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT]) + trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE]) + loDoc = nwItemLayout.DOCUMENT + loNote = nwItemLayout.NOTE + isDocFile = isFile and self._item.isDocumentLayout() + isNoteFile = isFile and self._item.isNoteLayout() + + if isNoteFile and self._item.documentAllowed(): + action = menu.addAction(self.tr("Convert to {0}").format(trDoc)) + action.triggered.connect(lambda: self._changeItemLayout(loDoc)) + + if isDocFile: + action = menu.addAction(self.tr("Convert to {0}").format(trNote)) + action.triggered.connect(lambda: self._changeItemLayout(loNote)) + + if isFolder and self._item.documentAllowed(): + action = menu.addAction(self.tr("Convert to {0}").format(trDoc)) + action.triggered.connect(lambda: self._covertFolderToFile(loDoc)) + + if isFolder: + action = menu.addAction(self.tr("Convert to {0}").format(trNote)) + action.triggered.connect(lambda: self._covertFolderToFile(loNote)) + + if hasChild and isFile: + action = menu.addAction(self.tr("Merge Child Items into Self")) + action.triggered.connect(lambda: tree._mergeDocuments(tHandle, False)) + action = menu.addAction(self.tr("Merge Child Items into New")) + action.triggered.connect(lambda: tree._mergeDocuments(tHandle, True)) + + if hasChild and isFolder: + action = menu.addAction(self.tr("Merge Documents in Folder")) + action.triggered.connect(lambda: tree._mergeDocuments(tHandle, True)) + + if isFile: + action = menu.addAction(self.tr("Split Document by Headers")) + action.triggered.connect(lambda: tree._splitDocument(tHandle)) + return - def _iterSetItemImport(self, tStatus: str) -> None: - """Change the importance value for multiple items.""" - for item in self.selectedItems(): - tHandle = str(item.data(self.C_DATA, self.D_HANDLE)) - self._changeItemImport(tHandle, tStatus) + def _itemProcess(self, isFile: bool, isFolder: bool, isRoot: bool, hasChild: bool) -> None: + """Add actions for item processing.""" + tree = self.projTree + tHandle = self._handle + if hasChild: + action = self.addAction(self.tr("Expand All")) + action.triggered.connect(lambda: tree.setExpandedFromHandle(tHandle, True)) + action = self.addAction(self.tr("Collapse All")) + action.triggered.connect(lambda: tree.setExpandedFromHandle(tHandle, False)) + action = self.addAction(self.tr("Duplicate from Here")) + action.triggered.connect(lambda: tree._duplicateFromHandle(tHandle)) + elif isFile: + action = self.addAction(self.tr("Duplicate Document")) + action.triggered.connect(lambda: tree._duplicateFromHandle(tHandle)) + + if self._item.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild): + action = self.addAction(self.tr("Delete Permanently")) + action.triggered.connect(lambda: tree.permDeleteItem(tHandle)) + else: + action = self.addAction(self.tr("Move to Trash")) + action.triggered.connect(lambda: tree.moveItemToTrash(tHandle)) + return + def _moveToTrash(self, multi: bool) -> None: + """Add move to Trash action.""" + action = self.addAction(self.tr("Move to Trash")) + if multi: + action.triggered.connect(self._iterMoveToTrash) + return + + ## + # Private Slots + ## + @pyqtSlot() def _iterMoveToTrash(self) -> None: """Iterate through files and move them to Trash.""" - items = self.selectedItems() - if SHARED.question(self.tr("Move {0} items to Trash?").format(len(items))): - for item in self.selectedItems(): - tHandle = str(item.data(self.C_DATA, self.D_HANDLE)) + if SHARED.question(self.tr("Move {0} items to Trash?").format(len(self._items))): + for tHandle in self._items: tItem = SHARED.project.tree[tHandle] if tItem and tItem.isFileType(): - self.moveItemToTrash(tHandle, askFirst=False, flush=False) - self.saveTreeOrder() + self.projTree.moveItemToTrash(tHandle, askFirst=False, flush=False) + self.projTree.saveTreeOrder() return -# END Class GuiProjectTree + @pyqtSlot() + def _toggleItemActive(self) -> None: + """Toggle the active status of an item.""" + self._item.setActive(not self._item.isActive) + self.projTree.setTreeItemValues(self._handle) + self.projTree._alertTreeChange(self._handle, flush=False) + return + + ## + # Internal Functions + ## + + def _iterItemActive(self, isActive: bool) -> None: + """Set the active status of multiple items.""" + for tHandle in self._items: + tItem = SHARED.project.tree[tHandle] + if tItem and tItem.isFileType(): + tItem.setActive(isActive) + self.projTree.setTreeItemValues(tHandle) + self.projTree._alertTreeChange(tHandle, flush=False) + return + + def _changeItemStatus(self, key: str) -> None: + """Set a new status value of an item.""" + self._item.setStatus(key) + self.projTree.setTreeItemValues(self._handle) + self.projTree._alertTreeChange(self._handle, flush=False) + return + + def _iterSetItemStatus(self, key: str) -> None: + """Change the status value for multiple items.""" + for tHandle in self._items: + tItem = SHARED.project.tree[tHandle] + if tItem and tItem.isNovelLike(): + tItem.setStatus(key) + self.projTree.setTreeItemValues(tHandle) + self.projTree._alertTreeChange(tHandle, flush=False) + return + + def _changeItemImport(self, key: str) -> None: + """Set a new importance value of an item.""" + self._item.setImport(key) + self.projTree.setTreeItemValues(self._handle) + self.projTree._alertTreeChange(self._handle, flush=False) + return + + def _iterSetItemImport(self, key: str) -> None: + """Change the status value for multiple items.""" + for tHandle in self._items: + tItem = SHARED.project.tree[tHandle] + if tItem and not tItem.isNovelLike(): + tItem.setImport(key) + self.projTree.setTreeItemValues(tHandle) + self.projTree._alertTreeChange(tHandle, flush=False) + return + + def _changeItemLayout(self, itemLayout: nwItemLayout) -> None: + """Set a new item layout value of an item.""" + if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed(): + self._item.setLayout(nwItemLayout.DOCUMENT) + self.projTree.setTreeItemValues(self._handle) + self.projTree._alertTreeChange(self._handle, flush=False) + elif itemLayout == nwItemLayout.NOTE: + self._item.setLayout(nwItemLayout.NOTE) + self.projTree.setTreeItemValues(self._handle) + self.projTree._alertTreeChange(self._handle, flush=False) + return + + def _covertFolderToFile(self, itemLayout: nwItemLayout) -> None: + """Convert a folder to a note or document.""" + if self._item.isFolderType(): + msgYes = SHARED.question(self.tr( + "Do you want to convert the folder to a {0}? " + "This action cannot be reversed." + ).format(trConst(nwLabels.LAYOUT_NAME[itemLayout]))) + if msgYes and itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed(): + self._item.setType(nwItemType.FILE) + self._item.setLayout(nwItemLayout.DOCUMENT) + self.projTree.setTreeItemValues(self._handle) + self.projTree._alertTreeChange(self._handle, flush=False) + elif msgYes and itemLayout == nwItemLayout.NOTE: + self._item.setType(nwItemType.FILE) + self._item.setLayout(nwItemLayout.NOTE) + self.projTree.setTreeItemValues(self._handle) + self.projTree._alertTreeChange(self._handle, flush=False) + else: + logger.info("Folder conversion cancelled") + return + +# END Class _TreeContextMenu From 62ea54e1ed35ac565dbfd368e3ac3979e9915413 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 22 Nov 2023 18:08:34 +0100 Subject: [PATCH 7/8] Add test coverage of tree context menu class --- tests/test_gui/test_gui_projtree.py | 408 +++++++++++++++++++--------- 1 file changed, 287 insertions(+), 121 deletions(-) diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index c2163081..cbc603ed 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -23,6 +23,7 @@ from __future__ import annotations import pytest from pathlib import Path +from novelwriter.core.item import NWItem from tools import C, buildTestProject from mocked import causeOSError @@ -32,9 +33,9 @@ from PyQt5.QtCore import QMimeData, QPoint, QTimer, Qt from PyQt5.QtWidgets import QMessageBox, QMenu, QTreeWidget, QTreeWidgetItem, QDialog from novelwriter import CONFIG, SHARED -from novelwriter.enum import nwItemLayout, nwItemType, nwItemClass +from novelwriter.enum import nwItemLayout, nwItemType, nwItemClass, nwWidget from novelwriter.guimain import GuiMain -from novelwriter.gui.projtree import GuiProjectTree, GuiProjectView +from novelwriter.gui.projtree import GuiProjectTree, GuiProjectView, _TreeContextMenu from novelwriter.dialogs.docmerge import GuiDocMerge from novelwriter.dialogs.docsplit import GuiDocSplit from novelwriter.dialogs.editlabel import GuiEditLabel @@ -514,125 +515,6 @@ def testGuiProjTree_EmptyTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mock # END Test testGuiProjTree_EmptyTrash -@pytest.mark.gui -def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd): - """Test the building of the project tree context menu. All this does - is test that the menu builds. It doesn't open the actual menu, - """ - monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) - monkeypatch.setattr(QMenu, "exec_", lambda *a: None) - - # Create a project - buildTestProject(nwGUI, projPath) - - # Handles for new objects - hCharNote = "0000000000011" - hNovelNote = "0000000000012" - hSubNote = "0000000000013" - hNewFolderOne = "0000000000014" - hNewFolderTwo = "0000000000016" - - projView = nwGUI.projView - projTree = nwGUI.projView.projTree - projTree.setExpandedFromHandle(None, True) - - projTree._addTrashRoot() - hTrashRoot = SHARED.project.tree.trashRoot - - projTree.setSelectedHandle(C.hCharRoot) - projTree.newTreeItem(nwItemType.FILE) - projTree.setSelectedHandle(C.hNovelRoot) - projTree.newTreeItem(nwItemType.FILE, isNote=True) - - SHARED.project.newFile("SubNote", hNovelNote) - projTree.revealNewTreeItem(hSubNote) - assert SHARED.project.tree[hSubNote].itemParent == hNovelNote # type: ignore - - def itemPos(tHandle): - return projTree.visualItemRect(projTree._getTreeItem(tHandle)).center() - - # No item under menu - assert projTree._openContextMenu(projTree.viewport().rect().bottomRight()) is False - - # Generate the possible menu combinations - assert projTree._openContextMenu(itemPos(hTrashRoot)) is True - assert projTree._openContextMenu(itemPos(C.hNovelRoot)) is True - assert projTree._openContextMenu(itemPos(hNovelNote)) is True - assert projTree._openContextMenu(itemPos(C.hTitlePage)) is True - assert projTree._openContextMenu(itemPos(C.hChapterDir)) is True - assert projTree._openContextMenu(itemPos(C.hChapterDoc)) is True - assert projTree._openContextMenu(itemPos(C.hCharRoot)) is True - assert projTree._openContextMenu(itemPos(hCharNote)) is True - assert projTree._openContextMenu(itemPos(hNovelNote)) is True - - # Check the keyboard shortcut handler as well - projTree.setSelectedHandle(C.hNovelRoot) - assert projTree.openContextOnSelected() is True - projTree.clearSelection() - assert projTree.openContextOnSelected() is False - - # Direct Edit Functions - # ===================== - # Trigger the dedicated functions the menu entries connect to - nwItem = SHARED.project.tree[hNovelNote] - assert nwItem is not None - - # Toggle active flag - assert nwItem.isActive is True - projTree._toggleItemActive(hNovelNote) - assert nwItem.isActive is False - - # Change item status - assert nwItem.itemStatus == "s000000" - projTree._changeItemStatus(hNovelNote, "s000001") - assert nwItem.itemStatus == "s000001" - - # Change item importance - assert nwItem.itemImport == "i000004" - projTree._changeItemImport(hNovelNote, "i000005") - assert nwItem.itemImport == "i000005" - - # Change item layout - assert nwItem.itemLayout == nwItemLayout.NOTE - projTree._changeItemLayout(hNovelNote, nwItemLayout.DOCUMENT) - assert nwItem.itemLayout == nwItemLayout.DOCUMENT - projTree._changeItemLayout(hNovelNote, nwItemLayout.NOTE) - assert nwItem.itemLayout == nwItemLayout.NOTE - - # Convert Folders to Documents - # ============================ - - projView.setSelectedHandle(hNovelNote) - assert projView.projTree.newTreeItem(nwItemType.FOLDER) is True - projView.setSelectedHandle(hNewFolderOne) - assert projView.projTree.newTreeItem(nwItemType.FILE) is True - - projView.setSelectedHandle(hNovelNote) - assert projView.projTree.newTreeItem(nwItemType.FOLDER) is True - projView.setSelectedHandle(hNewFolderTwo) - assert projView.projTree.newTreeItem(nwItemType.FILE, isNote=True) is True - - # Click no on the dialog - with monkeypatch.context() as mp: - mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.No) - projTree._covertFolderToFile(hNewFolderOne, nwItemLayout.DOCUMENT) - assert SHARED.project.tree[hNewFolderOne].isFolderType() # type: ignore - - # Convert the first folder to a document - projTree._covertFolderToFile(hNewFolderOne, nwItemLayout.DOCUMENT) - assert SHARED.project.tree[hNewFolderOne].isFileType() # type: ignore - assert SHARED.project.tree[hNewFolderOne].isDocumentLayout() # type: ignore - - # Convert the second folder to a note - projTree._covertFolderToFile(hNewFolderTwo, nwItemLayout.NOTE) - assert SHARED.project.tree[hNewFolderTwo].isFileType() # type: ignore - assert SHARED.project.tree[hNewFolderTwo].isNoteLayout() # type: ignore - - # qtbot.stop() - -# END Test testGuiProjTree_ContextMenu - - @pytest.mark.gui def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText): """Test the merge document function.""" @@ -1141,3 +1023,287 @@ def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd) # qtbot.stop() # END Test testGuiProjTree_Other + + +@pytest.mark.gui +def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd): + """Test the building of the project tree context menu. All this does + is test that the menu builds. It doesn't open the actual menu, + """ + monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) + + # Create a project + buildTestProject(nwGUI, projPath) + nwGUI.openProject(projPath) + nwGUI.switchFocus(nwWidget.TREE) + + # Handles for new objects + hCharNote = "0000000000011" + hNovelNote = "0000000000012" + hSubNote = "0000000000013" + hNewFolderOne = "0000000000014" + hNewFolderTwo = "0000000000016" + + projView = nwGUI.projView + projTree = nwGUI.projView.projTree + projTree.setExpandedFromHandle(None, True) + + projTree._addTrashRoot() + hTrashRoot = SHARED.project.tree.trashRoot + + projTree.setSelectedHandle(C.hCharRoot) + projTree.newTreeItem(nwItemType.FILE) + projTree.setSelectedHandle(C.hNovelRoot) + projTree.newTreeItem(nwItemType.FILE, isNote=True) + + SHARED.project.newFile("SubNote", hNovelNote) + projTree.revealNewTreeItem(hSubNote) + assert SHARED.project.tree[hSubNote].itemParent == hNovelNote # type: ignore + + def itemPos(tHandle): + return projTree.visualItemRect(projTree._getTreeItem(tHandle)).center() + + # Pop the menu + with monkeypatch.context() as mp: + mp.setattr(QMenu, "exec_", lambda *a: None) + projTree.clearSelection() + + # No item under menu + assert projTree._openContextMenu(projTree.viewport().rect().bottomRight()) is False + + # Open Trash Menu + assert projTree._openContextMenu(itemPos(hTrashRoot)) is True + + # Open Single Select Menu + assert projTree._openContextMenu(itemPos(C.hNovelRoot)) is True + + # Open Multi-Select Menu + projTree._getTreeItem(hNovelNote).setSelected(True) + projTree._getTreeItem(hSubNote).setSelected(True) + assert projTree._openContextMenu(itemPos(hCharNote)) is True + + # Check the keyboard shortcut handler as well + projTree.setSelectedHandle(C.hNovelRoot) + assert projTree.openContextOnSelected() is True + projTree.clearSelection() + assert projTree.openContextOnSelected() is False + + # Menu Builders + # ============= + + # Context Menu on Root Item + nwItem = SHARED.project.tree[C.hNovelRoot] + assert isinstance(nwItem, NWItem) + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildSingleSelectMenu(True) + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Rename", "Set Status to ...", "Expand All", "Collapse All", + "Duplicate from Here", "Delete Permanently", + ] + + # Context Menu on Folder Item + nwItem = SHARED.project.tree[C.hChapterDir] + assert isinstance(nwItem, NWItem) + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildSingleSelectMenu(True) + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Rename", "Set Status to ...", "Transform", "Expand All", "Collapse All", + "Duplicate from Here", "Move to Trash", + ] + + def getTransformSubMenu(menu: QMenu) -> list[str]: + for action in menu.actions(): + if action.text() == "Transform": + return [x.text() for x in action.menu().actions() if x.text()] + return [] + + # Context Menu on Document File Item + nwItem = SHARED.project.tree[C.hChapterDoc] + assert isinstance(nwItem, NWItem) + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildSingleSelectMenu(True) + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Open Document", "View Document", "Rename", "Toggle Active", "Set Status to ...", + "Transform", "Expand All", "Collapse All", "Duplicate from Here", "Move to Trash", + ] + assert getTransformSubMenu(ctxMenu) == [ + "Convert to Project Note", "Merge Child Items into Self", + "Merge Child Items into New", "Split Document by Headers" + ] + + # Context Menu on Note File Item in Character Folder + nwItem = SHARED.project.tree[hCharNote] + assert isinstance(nwItem, NWItem) + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildSingleSelectMenu(False) + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Open Document", "View Document", "Rename", "Toggle Active", "Set Importance to ...", + "Transform", "Duplicate Document", "Move to Trash", + ] + assert getTransformSubMenu(ctxMenu) == [ + "Split Document by Headers", + ] + + # Context Menu on Note File Item in Novel Tree + nwItem = SHARED.project.tree[hNovelNote] + assert isinstance(nwItem, NWItem) + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildSingleSelectMenu(False) + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Open Document", "View Document", "Rename", "Toggle Active", "Set Status to ...", + "Transform", "Duplicate Document", "Move to Trash", + ] + assert getTransformSubMenu(ctxMenu) == [ + "Convert to Novel Document", "Split Document by Headers", + ] + + # Context Menu on Multiple Items, Clicked on Document + nwItem = SHARED.project.tree[hNovelNote] + assert isinstance(nwItem, NWItem) + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote]) + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Set Active to ...", "Set Status to ...", "Move to Trash", + ] + + # Context Menu on Multiple Items, Clicked on Note + nwItem = SHARED.project.tree[hCharNote] + assert isinstance(nwItem, NWItem) + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote]) + actions = [x.text() for x in ctxMenu.actions() if x.text()] + assert actions == [ + "Set Active to ...", "Set Importance to ...", "Move to Trash", + ] + + # Direct Edit Functions, Single + # ============================= + + nwItem = SHARED.project.tree[hNovelNote] + assert isinstance(nwItem, NWItem) + + # # Toggle active flag + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildSingleSelectMenu(False) + assert nwItem.isActive is True + ctxMenu._toggleItemActive() + assert nwItem.isActive is False + + # Change item status + assert nwItem.itemStatus == "s000000" + ctxMenu._changeItemStatus("s000001") + assert nwItem.itemStatus == "s000001" + + # Change item importance + assert nwItem.itemImport == "i000004" + ctxMenu._changeItemImport("i000005") + assert nwItem.itemImport == "i000005" + + # Change item layout + assert nwItem.itemLayout == nwItemLayout.NOTE + ctxMenu._changeItemLayout(nwItemLayout.DOCUMENT) + assert nwItem.itemLayout == nwItemLayout.DOCUMENT + ctxMenu._changeItemLayout(nwItemLayout.NOTE) + assert nwItem.itemLayout == nwItemLayout.NOTE + + # Convert Folders to Documents + # ============================ + + projView.setSelectedHandle(hNovelNote) + assert projView.projTree.newTreeItem(nwItemType.FOLDER) is True + projView.setSelectedHandle(hNewFolderOne) + assert projView.projTree.newTreeItem(nwItemType.FILE) is True + + projView.setSelectedHandle(hNovelNote) + assert projView.projTree.newTreeItem(nwItemType.FOLDER) is True + projView.setSelectedHandle(hNewFolderTwo) + assert projView.projTree.newTreeItem(nwItemType.FILE, isNote=True) is True + + nwItem = SHARED.project.tree[hNewFolderOne] + assert isinstance(nwItem, NWItem) + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildSingleSelectMenu(False) + + # Click no on the dialog + with monkeypatch.context() as mp: + mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.No) + ctxMenu._covertFolderToFile(nwItemLayout.DOCUMENT) + assert SHARED.project.tree[hNewFolderOne].isFolderType() # type: ignore + + # Convert the first folder to a document + ctxMenu._covertFolderToFile(nwItemLayout.DOCUMENT) + assert SHARED.project.tree[hNewFolderOne].isFileType() # type: ignore + assert SHARED.project.tree[hNewFolderOne].isDocumentLayout() # type: ignore + + nwItem = SHARED.project.tree[hNewFolderTwo] + assert isinstance(nwItem, NWItem) + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildSingleSelectMenu(False) + + # Convert the second folder to a note + ctxMenu._covertFolderToFile(nwItemLayout.NOTE) + assert SHARED.project.tree[hNewFolderTwo].isFileType() # type: ignore + assert SHARED.project.tree[hNewFolderTwo].isNoteLayout() # type: ignore + + # Direct Edit Functions, Multi + # ============================ + + nwItem = SHARED.project.tree[hCharNote] + assert isinstance(nwItem, NWItem) + ctxMenu = _TreeContextMenu(projTree, nwItem) + ctxMenu.buildMultiSelectMenu([hCharNote, hNovelNote, hSubNote]) + + projTree.clearSelection() + projTree._getTreeItem(hCharNote).setSelected(True) + projTree._getTreeItem(hNovelNote).setSelected(True) + projTree._getTreeItem(hSubNote).setSelected(True) + + # Item Active + assert SHARED.project.tree[hCharNote].isActive is True # type: ignore + assert SHARED.project.tree[hNovelNote].isActive is False # type: ignore + assert SHARED.project.tree[hSubNote].isActive is True # type: ignore + ctxMenu._iterItemActive(False) + assert SHARED.project.tree[hCharNote].isActive is False # type: ignore + assert SHARED.project.tree[hNovelNote].isActive is False # type: ignore + assert SHARED.project.tree[hSubNote].isActive is False # type: ignore + ctxMenu._iterItemActive(True) + assert SHARED.project.tree[hCharNote].isActive is True # type: ignore + assert SHARED.project.tree[hNovelNote].isActive is True # type: ignore + assert SHARED.project.tree[hSubNote].isActive is True # type: ignore + + # Item Status + assert SHARED.project.tree[hCharNote].itemStatus == "s000000" # type: ignore + assert SHARED.project.tree[hNovelNote].itemStatus == "s000001" # type: ignore + assert SHARED.project.tree[hSubNote].itemStatus == "s000000" # type: ignore + ctxMenu._iterSetItemStatus("s000003") + assert SHARED.project.tree[hCharNote].itemStatus == "s000000" # type: ignore + assert SHARED.project.tree[hNovelNote].itemStatus == "s000003" # type: ignore + assert SHARED.project.tree[hSubNote].itemStatus == "s000003" # type: ignore + + # Item Importance + assert SHARED.project.tree[hCharNote].itemImport == "i000004" # type: ignore + assert SHARED.project.tree[hNovelNote].itemImport == "i000005" # type: ignore + assert SHARED.project.tree[hSubNote].itemImport == "i000004" # type: ignore + ctxMenu._iterSetItemImport("i000007") + assert SHARED.project.tree[hCharNote].itemImport == "i000007" # type: ignore + assert SHARED.project.tree[hNovelNote].itemImport == "i000005" # type: ignore + assert SHARED.project.tree[hSubNote].itemImport == "i000004" # type: ignore + + # Move to Trash + assert SHARED.project.tree[hCharNote].itemRoot == C.hCharRoot # type: ignore + assert SHARED.project.tree[hNovelNote].itemRoot == C.hNovelRoot # type: ignore + assert SHARED.project.tree[hSubNote].itemRoot == C.hNovelRoot # type: ignore + ctxMenu._iterMoveToTrash() + assert SHARED.project.tree[hCharNote].itemRoot == hTrashRoot # type: ignore + assert SHARED.project.tree[hNovelNote].itemRoot == hTrashRoot # type: ignore + assert SHARED.project.tree[hSubNote].itemRoot == hTrashRoot # type: ignore + + # qtbot.stop() + +# END Test testGuiProjTree_ContextMenu From 445414d126c7b98d887553a2840e33e3f3dd5102 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 22 Nov 2023 18:48:10 +0100 Subject: [PATCH 8/8] Improve test coverage of project tree --- tests/test_gui/test_gui_projtree.py | 145 ++++++++++++++++++++++------ 1 file changed, 116 insertions(+), 29 deletions(-) diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index cbc603ed..76495311 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -23,18 +23,19 @@ from __future__ import annotations import pytest from pathlib import Path -from novelwriter.core.item import NWItem +from novelwriter.core.project import NWProject from tools import C, buildTestProject from mocked import causeOSError -from PyQt5.QtGui import QDragMoveEvent, QDropEvent -from PyQt5.QtCore import QMimeData, QPoint, QTimer, Qt +from PyQt5.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QMouseEvent +from PyQt5.QtCore import QEvent, QMimeData, QPoint, QTimer, Qt from PyQt5.QtWidgets import QMessageBox, QMenu, QTreeWidget, QTreeWidgetItem, QDialog from novelwriter import CONFIG, SHARED from novelwriter.enum import nwItemLayout, nwItemType, nwItemClass, nwWidget from novelwriter.guimain import GuiMain +from novelwriter.core.item import NWItem from novelwriter.gui.projtree import GuiProjectTree, GuiProjectView, _TreeContextMenu from novelwriter.dialogs.docmerge import GuiDocMerge from novelwriter.dialogs.docsplit import GuiDocSplit @@ -51,81 +52,84 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRn project = SHARED.project # Try to add item with no project - assert projView.projTree.newTreeItem(nwItemType.FILE) is False + assert projTree.newTreeItem(nwItemType.FILE) is False # Create a project buildTestProject(nwGUI, projPath) # No itemType set - projView.projTree.clearSelection() - assert projView.projTree.newTreeItem(None) is False + projTree.clearSelection() + assert projTree.newTreeItem(None) is False # Root Items # ========== # No class set - assert projView.projTree.newTreeItem(nwItemType.ROOT) is False + assert projTree.newTreeItem(nwItemType.ROOT) is False # Create root item - assert projView.projTree.newTreeItem(nwItemType.ROOT, nwItemClass.WORLD) is True + assert projTree.newTreeItem(nwItemType.ROOT, nwItemClass.WORLD) is True assert "0000000000010" in project.tree # File/Folder Items # ================= # No location selected for new item - projView.projTree.clearSelection() + projTree.clearSelection() caplog.clear() - assert projView.projTree.newTreeItem(nwItemType.FILE) is False - assert projView.projTree.newTreeItem(nwItemType.FOLDER) is False + assert projTree.newTreeItem(nwItemType.FILE) is False + assert projTree.newTreeItem(nwItemType.FOLDER) is False assert "Did not find anywhere" in caplog.text # Create new folder as child of Novel folder projView.setSelectedHandle(C.hNovelRoot) - assert projView.projTree.newTreeItem(nwItemType.FOLDER) is True + assert projTree.newTreeItem(nwItemType.FOLDER) is True assert project.tree["0000000000011"].itemParent == C.hNovelRoot # type: ignore assert project.tree["0000000000011"].itemRoot == C.hNovelRoot # type: ignore assert project.tree["0000000000011"].itemClass == nwItemClass.NOVEL # type: ignore # Add a new file in the new folder projView.setSelectedHandle("0000000000011") - assert projView.projTree.newTreeItem(nwItemType.FILE) is True + assert projTree.newTreeItem(nwItemType.FILE) is True assert project.tree["0000000000012"].itemParent == "0000000000011" # type: ignore assert project.tree["0000000000012"].itemRoot == C.hNovelRoot # type: ignore assert project.tree["0000000000012"].itemClass == nwItemClass.NOVEL # type: ignore # Add a new chapter next to the other new file projView.setSelectedHandle("0000000000012") - assert projView.projTree.newTreeItem(nwItemType.FILE, hLevel=2) is True + assert projTree.newTreeItem(nwItemType.FILE, hLevel=2) is True assert project.tree["0000000000013"].itemParent == "0000000000011" # type: ignore assert project.tree["0000000000013"].itemRoot == C.hNovelRoot # type: ignore assert project.tree["0000000000013"].itemClass == nwItemClass.NOVEL # type: ignore assert nwGUI.openDocument("0000000000013") assert nwGUI.docEditor.getText() == "## New Chapter\n\n" + assert projTree._getItemWordCount("0000000000013") == 2 # Add a new scene next to the other new file projView.setSelectedHandle("0000000000012") - assert projView.projTree.newTreeItem(nwItemType.FILE, hLevel=3) is True + assert projTree.newTreeItem(nwItemType.FILE, hLevel=3) is True assert project.tree["0000000000014"].itemParent == "0000000000011" # type: ignore assert project.tree["0000000000014"].itemRoot == C.hNovelRoot # type: ignore assert project.tree["0000000000014"].itemClass == nwItemClass.NOVEL # type: ignore assert nwGUI.openDocument("0000000000014") assert nwGUI.docEditor.getText() == "### New Scene\n\n" + assert projTree._getItemWordCount("0000000000014") == 2 # Add a new file to the characters folder projView.setSelectedHandle(C.hCharRoot) - assert projView.projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) is True + assert projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) is True assert project.tree["0000000000015"].itemParent == C.hCharRoot # type: ignore assert project.tree["0000000000015"].itemRoot == C.hCharRoot # type: ignore assert project.tree["0000000000015"].itemClass == nwItemClass.CHARACTER # type: ignore assert nwGUI.openDocument("0000000000015") assert nwGUI.docEditor.getText() == "# New Note\n\n" + assert projTree._getItemWordCount("0000000000015") == 2 # Make sure the sibling folder bug trap works projView.setSelectedHandle("0000000000013") project.tree["0000000000013"].setParent(None) # This should not happen # type: ignore caplog.clear() - assert projView.projTree.newTreeItem(nwItemType.FILE) is False + assert projTree.newTreeItem(nwItemType.FILE) is False assert "Internal error" in caplog.text project.tree["0000000000013"].setParent("0000000000011") # type: ignore @@ -133,13 +137,17 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRn with monkeypatch.context() as mp: mp.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("", False)) projView.setSelectedHandle("0000000000013") - assert projView.projTree.newTreeItem(nwItemType.FILE) is False + assert projTree.newTreeItem(nwItemType.FILE) is False # Get the trash folder - projView.projTree._addTrashRoot() + with monkeypatch.context() as mp: + mp.setattr(NWProject, "trashFolder", lambda *a: None) + assert projTree._addTrashRoot() is None + + assert isinstance(projTree._addTrashRoot(), QTreeWidgetItem) trashHandle = project.trashFolder() projView.setSelectedHandle(trashHandle) - assert projView.projTree.newTreeItem(nwItemType.FILE) is False + assert projTree.newTreeItem(nwItemType.FILE) is False assert "Cannot add new files or folders to the Trash folder" in caplog.text # Rename Item @@ -158,14 +166,14 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRn # ============ # Also check error handling in reveal function - assert projView.projTree.revealNewTreeItem("abc") is False + assert projTree.revealNewTreeItem("abc") is False # Add an item that cannot be displayed in the tree nHandle = project.newFile("Test", None) # type: ignore - assert projView.projTree.revealNewTreeItem(nHandle) is False + assert projTree.revealNewTreeItem(nHandle) is False # Adding an invalid item directly to the tree should also fail - assert projView.projTree._addTreeItem(None) is None + assert projTree._addTreeItem(None) is None # Clean up # qtbot.stop() @@ -183,7 +191,7 @@ def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): projTree = nwGUI.projView.projTree # Try to move item with no project - assert projView.projTree.moveTreeItem(1) is False + assert projTree.moveTreeItem(1) is False # Create a project buildTestProject(nwGUI, projPath) @@ -245,7 +253,7 @@ def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): "0000000000010", "0000000000011", "0000000000012", ] - # Move down again, and restore via undo + # Move down again projView.setSelectedHandle("0000000000011") assert projTree.moveTreeItem(1) is True assert projTree.getTreeFromHandle(C.hChapterDir) == [ @@ -894,6 +902,45 @@ def testGuiProjTree_DragAndDrop(qtbot, monkeypatch, caplog, nwGUI: GuiMain, proj projTree.saveTreeOrder() assert SHARED.project.tree._order == treeOrder + # Make sure illegal drag events are cancelled + with monkeypatch.context() as mp: + mp.setattr(QTreeWidget, "dragEnterEvent", lambda *a: None) + mime = QMimeData() + mime.setText("foobar") + event = QDragEnterEvent(nPos, action, mime, mouse, modifier) + projTree.clearSelection() + projTree._getTreeItem(C.hNovelRoot).setSelected(True) # type: ignore + projTree._getTreeItem(C.hTitlePage).setSelected(True) # type: ignore + projTree._getTreeItem(C.hChapterDoc).setSelected(True) # type: ignore + assert projTree.selectedItems() == [ # Novel Root selection is cancelled automatically + projTree._getTreeItem(C.hTitlePage), projTree._getTreeItem(C.hChapterDoc) + ] + projTree.dragEnterEvent(event) + assert mime.text() == "" + assert projTree._popAlert is not None + + # Pop the alert + with monkeypatch.context() as mp: + mp.setattr(QTreeWidget, "startDrag", lambda *a: None) + projTree.startDrag(None) # type: ignore + assert projTree._popAlert is None + + # Valid drag events are processed + with monkeypatch.context() as mp: + mp.setattr(QTreeWidget, "dragEnterEvent", lambda *a: None) + mime = QMimeData() + mime.setText("foobar") + event = QDragEnterEvent(nPos, action, mime, mouse, modifier) + projTree.clearSelection() + projTree._getTreeItem(C.hChapterDoc).setSelected(True) # type: ignore + projTree._getTreeItem(C.hSceneDoc).setSelected(True) # type: ignore + assert projTree.selectedItems() == [ # Novel Root selection is cancelled automatically + projTree._getTreeItem(C.hChapterDoc), projTree._getTreeItem(C.hSceneDoc) + ] + projTree.dragEnterEvent(event) + assert mime.text() == "foobar" + assert projTree._popAlert is None + # qtbot.stop() # END Test testGuiProjTree_DragAndDrop @@ -1020,6 +1067,46 @@ def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd) projTree.moveToLevel(1) assert projTree.getSelectedHandle() is None + # Mouse Button Clicks + # =================== + + eType = QEvent.Type.MouseButtonPress + pos = projTree.visualItemRect(projTree._getTreeItem(C.hChapterDoc)).center() + button = Qt.MouseButton.MiddleButton + modifier = Qt.KeyboardModifier.NoModifier + + # Trigger the viewer + event = QMouseEvent(eType, pos, button, button, modifier) + projTree.mousePressEvent(event) + assert nwGUI.docViewer.docHandle == C.hChapterDoc + + # Trigger the left click clear + pos = QPoint(5000, 5000) + button = Qt.MouseButton.LeftButton + event = QMouseEvent(eType, pos, button, button, modifier) + projTree.setSelectedHandle(C.hChapterDoc) + projTree.mousePressEvent(event) + assert projTree.selectedItems() == [] + + # Rename Item + # =========== + + with monkeypatch.context() as mp: + mp.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("FooBar", True)) + projTree.clearSelection() + assert SHARED.project.tree[C.hChapterDoc].itemName == "New Chapter" # type: ignore + assert projView.renameTreeItem(C.hChapterDoc) is True + assert SHARED.project.tree[C.hChapterDoc].itemName == "FooBar" # type: ignore + + projTree.setSelectedHandle(C.hSceneDoc) + assert SHARED.project.tree[C.hSceneDoc].itemName == "New Scene" # type: ignore + assert projView.renameTreeItem() is True + assert SHARED.project.tree[C.hSceneDoc].itemName == "FooBar" # type: ignore + + # Check Crash Resistance + # ====================== + projTree._postItemMove("dfghj") # This should exit cleanly + # qtbot.stop() # END Test testGuiProjTree_Other @@ -1216,14 +1303,14 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd): # ============================ projView.setSelectedHandle(hNovelNote) - assert projView.projTree.newTreeItem(nwItemType.FOLDER) is True + assert projTree.newTreeItem(nwItemType.FOLDER) is True projView.setSelectedHandle(hNewFolderOne) - assert projView.projTree.newTreeItem(nwItemType.FILE) is True + assert projTree.newTreeItem(nwItemType.FILE) is True projView.setSelectedHandle(hNovelNote) - assert projView.projTree.newTreeItem(nwItemType.FOLDER) is True + assert projTree.newTreeItem(nwItemType.FOLDER) is True projView.setSelectedHandle(hNewFolderTwo) - assert projView.projTree.newTreeItem(nwItemType.FILE, isNote=True) is True + assert projTree.newTreeItem(nwItemType.FILE, isNote=True) is True nwItem = SHARED.project.tree[hNewFolderOne] assert isinstance(nwItem, NWItem)