diff --git a/docs/source/int_interface.rst b/docs/source/int_interface.rst index cf6bc065..f5b8d68d 100644 --- a/docs/source/int_interface.rst +++ b/docs/source/int_interface.rst @@ -378,6 +378,8 @@ Most features are available as keyboard shortcuts. These are as follows: ":kbd:`Ctrl`:kbd:`F7`", "Toggle spell checking." ":kbd:`Ctrl`:kbd:`F10`", "Toggle automatic updating of project outline." ":kbd:`Ctrl`:kbd:`Del`", "If in the project tree, move a document to trash, or delete a folder." + ":kbd:`Ctrl`:kbd:`Up`", "Move item one step up in the project tree." + ":kbd:`Ctrl`:kbd:`Down`", "Move item one step down in the project tree." ":kbd:`Ctrl`:kbd:`'`", "Wrap selected text, or word under cursor, in single quotes." ":kbd:`Ctrl`:kbd:`""`", "Wrap selected text, or word under cursor, in double quotes." ":kbd:`Ctrl`:kbd:`Enter`", "Open the tag or reference under the cursor in the Viewer." @@ -392,9 +394,7 @@ Most features are available as keyboard shortcuts. These are as follows: ":kbd:`Ctrl`:kbd:`Shift`:kbd:`R`", "Close the document viewer." ":kbd:`Ctrl`:kbd:`Shift`:kbd:`S`", "Save the current project." ":kbd:`Ctrl`:kbd:`Shift`:kbd:`W`", "Close the current project." - ":kbd:`Ctrl`:kbd:`Shift`:kbd:`Z`", "Alternative sequence for redo last undo." - ":kbd:`Ctrl`:kbd:`Shift`:kbd:`Up`", "Move item one step up in the project tree." - ":kbd:`Ctrl`:kbd:`Shift`:kbd:`Down`", "Move item one step down in the project tree." + ":kbd:`Ctrl`:kbd:`Shift`:kbd:`Z`", "Undo move of project tree item." ":kbd:`F1`", "Open the documentation. This will either open the Qt Assistant, if available, or send you to the documentation website." ":kbd:`F2`", "If in the project tree, edit a document or folder settings. (Same as :kbd:`Ctrl`:kbd:`E`)" ":kbd:`F3`", "Find next occurrence of search word in current document. (Same as :kbd:`Ctrl`:kbd:`G`)" diff --git a/nw/constants/constants.py b/nw/constants/constants.py index 5b961e10..26bb8f7d 100644 --- a/nw/constants/constants.py +++ b/nw/constants/constants.py @@ -56,6 +56,9 @@ class nwLists(): # Item classes where the full list of novel layouts are allowed CLS_NOVEL = {nwItemClass.NOVEL, nwItemClass.ARCHIVE} + # Item classes which do not require items to have same class + FREE_CLASS = {nwItemClass.ARCHIVE, nwItemClass.TRASH} + # END Class nwLists class nwRegEx(): diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 2923938b..38efa1d0 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -260,15 +260,15 @@ class GuiMainMenu(QMenuBar): self.projMenu.addSeparator() # Project > Edit - self.aEditItem = QAction("Edit Project Item", self) - self.aEditItem.setStatusTip("Change item settings") + self.aEditItem = QAction("Edit Item", self) + self.aEditItem.setStatusTip("Change project item settings") self.aEditItem.setShortcuts(["Ctrl+E", "F2"]) self.aEditItem.triggered.connect(lambda: self.theParent.editItem(None)) self.projMenu.addAction(self.aEditItem) # Project > Delete - self.aDeleteItem = QAction("Delete Project Item", self) - self.aDeleteItem.setStatusTip("Delete selected item") + self.aDeleteItem = QAction("Delete Item", self) + self.aDeleteItem.setStatusTip("Delete selected project item") self.aDeleteItem.setShortcut("Ctrl+Del") self.aDeleteItem.triggered.connect(lambda: self.theParent.treeView.deleteItem(None)) self.projMenu.addAction(self.aDeleteItem) @@ -287,6 +287,13 @@ class GuiMainMenu(QMenuBar): self.aMoveDown.triggered.connect(lambda: self._moveTreeItem(1)) self.projMenu.addAction(self.aMoveDown) + # Project > Undo Last Action + self.aMoveUndo = QAction("Undo Last Move", self) + self.aMoveUndo.setStatusTip("Undo last item move") + self.aMoveUndo.setShortcut("Ctrl+Shift+Z") + self.aMoveUndo.triggered.connect(lambda: self.theParent.treeView.undoLastMove()) + self.projMenu.addAction(self.aMoveUndo) + # Project > Empty Trash self.aEmptyTrash = QAction("Empty Trash", self) self.aEmptyTrash.setStatusTip("Permanently delete all files in the Trash folder") diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 8d81a063..4b3aeb7e 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -33,12 +33,12 @@ from time import time from PyQt5.QtCore import Qt, QSize, pyqtSignal from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import ( - qApp, QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMenu, QAction + QTreeWidget, QTreeWidgetItem, QAbstractItemView, QMenu, QAction ) from nw.core import NWDoc from nw.constants import ( - nwLabels, nwItemType, nwItemClass, nwItemLayout, nwAlert, nwConst + nwLabels, nwItemType, nwItemClass, nwItemLayout, nwAlert, nwConst, nwLists ) logger = logging.getLogger(__name__) @@ -68,6 +68,7 @@ class GuiProjectTree(QTreeWidget): self._treeMap = {} self._treeChanged = False self._timeChanged = 0 + self._lastMove = {} ## # Build GUI @@ -361,6 +362,7 @@ class GuiProjectTree(QTreeWidget): return False cItem = pItem.takeChild(tIndex) pItem.insertChild(nIndex, cItem) + self._recordLastMove(cItem, pItem, tIndex) self.clearSelection() cItem.setSelected(True) @@ -517,8 +519,9 @@ class GuiProjectTree(QTreeWidget): theDoc = NWDoc(self.theProject, self.theParent) theDoc.deleteDocument(tHandle) - del self.theProject.projTree[tHandle] self.theIndex.deleteHandle(tHandle) + self._deleteTreeItem(tHandle) + self._setTreeChanged(True) else: # The file is not already in the trash folder, so we @@ -541,11 +544,12 @@ class GuiProjectTree(QTreeWidget): tIndex = trItemP.indexOfChild(trItemS) trItemC = trItemP.takeChild(tIndex) trItemT.addChild(trItemC) - nwItemS.setParent(self.theProject.projTree.trashRoot()) + self._updateItemParent(tHandle) self.propagateCount(tHandle, wCount) - self._setTreeChanged(True) self.theIndex.deleteHandle(tHandle) + self._recordLastMove(trItemS, trItemP, tIndex) + self._setTreeChanged(True) elif nwItemS.itemType == nwItemType.FOLDER: logger.debug("User requested folder %s deleted" % tHandle) @@ -556,7 +560,8 @@ class GuiProjectTree(QTreeWidget): tIndex = trItemP.indexOfChild(trItemS) if trItemS.childCount() == 0: trItemP.takeChild(tIndex) - del self.theProject.projTree[tHandle] + self._deleteTreeItem(tHandle) + self._setTreeChanged(True) else: self.makeAlert(( "Cannot delete folder. It is not empty. " @@ -570,7 +575,7 @@ class GuiProjectTree(QTreeWidget): tIndex = self.indexOfTopLevelItem(trItemS) if trItemS.childCount() == 0: self.takeTopLevelItem(tIndex) - del self.theProject.projTree[tHandle] + self._deleteTreeItem(tHandle) self.theParent.mainMenu.setAvailableRoot() self._setTreeChanged(True) else: @@ -679,6 +684,59 @@ class GuiProjectTree(QTreeWidget): logger.debug("%d items added to the project tree" % iCount) return True + def undoLastMove(self): + """Attempt to undo the last action. + """ + srcItem = self._lastMove.get("item", None) + dstItem = self._lastMove.get("parent", None) + dstIndex = self._lastMove.get("index", None) + + if not self.hasFocus(): + return False + + if srcItem is None or dstItem is None or dstIndex is None: + logger.verbose("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()) + wCount = int(srcItem.data(self.C_COUNT, Qt.UserRole)) + sHandle = srcItem.data(self.C_NAME, Qt.UserRole) + dHandle = dstItem.data(self.C_NAME, Qt.UserRole) + 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._updateItemParent(sHandle) + self.propagateCount(sHandle, wCount) + + snItem = self.theProject.projTree[sHandle] + dnItem = self.theProject.projTree[dHandle] + if dnItem.itemClass not in nwLists.FREE_CLASS: + logger.debug("Item %s class has been changed from %s to %s" % ( + sHandle, snItem.itemClass.name, dnItem.itemClass.name + )) + snItem.setClass(dnItem.itemClass) + self.setTreeItemValues(sHandle) + + self.clearSelection() + movItem.setSelected(True) + self._lastMove = {} + + return True + def getSelectedHandle(self): """Get the currently selected handle. If multiple items are selected, return the first. @@ -779,6 +837,7 @@ class GuiProjectTree(QTreeWidget): return sItem = self._getTreeItem(sHandle) + pItem = sItem.parent() dItem = self.itemFromIndex(dIndex) dHandle = dItem.data(self.C_NAME, Qt.UserRole) snItem = self.theProject.projTree[sHandle] @@ -791,11 +850,10 @@ class GuiProjectTree(QTreeWidget): isSame = snItem.itemClass == dnItem.itemClass isNone = snItem.itemClass == nwItemClass.NO_CLASS isNote = snItem.itemLayout == nwItemLayout.NOTE - onFile = dnItem.itemType == nwItemType.FILE isRoot = snItem.itemType == nwItemType.ROOT - onFree = dnItem.itemClass == nwItemClass.ARCHIVE - onFree |= dnItem.itemClass == nwItemClass.TRASH - onFree &= snItem.itemType == nwItemType.FILE + isFile = snItem.itemType == nwItemType.FILE + onFile = dnItem.itemType == nwItemType.FILE + onFree = dnItem.itemClass in nwLists.FREE_CLASS and isFile isOnTop = self.dropIndicatorPosition() == QAbstractItemView.OnItem if (isSame or isNone or isNote or onFree) and not (onFile and isOnTop) and not isRoot: logger.debug("Drag'n'drop of item %s accepted" % sHandle) @@ -807,14 +865,13 @@ class GuiProjectTree(QTreeWidget): # and the target is not a free root folder, update its class if not (isSame or onFree): logger.debug("Item %s class has been changed from %s to %s" % ( - sHandle, - snItem.itemClass.name, - dnItem.itemClass.name + sHandle, snItem.itemClass.name, dnItem.itemClass.name )) snItem.setClass(dnItem.itemClass) self.setTreeItemValues(sHandle) self.propagateCount(sHandle, wCount) + self._recordLastMove(sItem, pItem, pItem.indexOfChild(sItem)) # The items dropped into archive or trash should be removed # from the project index, for all other items, we rescan the @@ -844,6 +901,13 @@ class GuiProjectTree(QTreeWidget): """ return self._treeMap.get(tHandle, None) + def _deleteTreeItem(self, tHandle): + """Delete a tree item from the project and the map. + """ + del self.theProject.projTree[tHandle] + self._treeMap.pop(tHandle, None) + return + def _scanChildren(self, theList, theItem, theIndex): """This is a recursive function returning all items in a tree starting at a given QTreeWidgetItem. @@ -984,6 +1048,19 @@ class GuiProjectTree(QTreeWidget): return + def _recordLastMove(self, srcItem, parItem, parIndex): + """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 class GuiProjectTreeMenu(QMenu):