From 013fb58267101f2f87f389f43ac7924d2921c37f Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 31 Jan 2021 12:50:42 +0100 Subject: [PATCH 1/9] Block document action if editor does not have focus --- nw/gui/doceditor.py | 4 ++++ tests/test_gui/test_gui_doceditor.py | 3 +++ tests/test_gui/test_gui_mainmenu.py | 2 ++ 3 files changed, 9 insertions(+) diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py index bc83719b..e4a25e4e 100644 --- a/nw/gui/doceditor.py +++ b/nw/gui/doceditor.py @@ -646,6 +646,10 @@ class GuiDocEditor(QTextEdit): this class when calling these actions from other classes. """ logger.verbose("Requesting action: %s" % theAction.name) + if not self.hasFocus(): + logger.verbose("Editor does not have focus") + return False + if self.theHandle is None: logger.error("No document open") return False diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index ba435ad0..cb9f35d1 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -30,6 +30,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextCursor from PyQt5.QtWidgets import QAction, QMessageBox +from nw.gui.doceditor import GuiDocEditor from nw.gui.projtree import GuiProjectTree from nw.constants import nwItemType, nwDocAction @@ -44,6 +45,7 @@ def testGuiEditor_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj, refDir, outDi # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *args: True) + monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *args: True) # Create new, save, close project nwGUI.theProject.projTree.setSeed(42) @@ -353,6 +355,7 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *args: True) nwGUI.theProject.projTree.setSeed(42) assert nwGUI.openProject(nwLipsum) diff --git a/tests/test_gui/test_gui_mainmenu.py b/tests/test_gui/test_gui_mainmenu.py index dcd32e91..f6504abf 100644 --- a/tests/test_gui/test_gui_mainmenu.py +++ b/tests/test_gui/test_gui_mainmenu.py @@ -27,6 +27,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextCursor, QTextBlock from PyQt5.QtWidgets import QAction, QFileDialog, QMessageBox +from nw.gui.doceditor import GuiDocEditor from nw.constants import nwUnicode, nwDocAction, nwDocInsert, nwKeyWords keyDelay = 2 @@ -39,6 +40,7 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *args: True) # Test Document Action with No Project assert not nwGUI.docEditor.docAction(nwDocAction.COPY) From ddda934eedd1ce4c942ac0576692a25700bd46f7 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 31 Jan 2021 12:56:58 +0100 Subject: [PATCH 2/9] Fix tests --- tests/test_gui/test_gui_doceditor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index cb9f35d1..b169e457 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -28,8 +28,9 @@ from tools import cmpFiles from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextCursor -from PyQt5.QtWidgets import QAction, QMessageBox +from PyQt5.QtWidgets import QAction, QMessageBox, QDialog +from nw.gui.itemeditor import GuiItemEditor from nw.gui.doceditor import GuiDocEditor from nw.gui.projtree import GuiProjectTree from nw.constants import nwItemType, nwDocAction @@ -44,6 +45,9 @@ def testGuiEditor_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj, refDir, outDi """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "information", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(GuiItemEditor, "exec_", lambda *args: None) + monkeypatch.setattr(GuiItemEditor, "result", lambda *args: QDialog.Accepted) monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *args: True) monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *args: True) From cca895e4184cfbb38e06ac2cec5cad63139f1461 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 31 Jan 2021 13:35:40 +0100 Subject: [PATCH 3/9] Cleanup in main menu and move the Move Up/Down entries to the project menu --- nw/gui/mainmenu.py | 54 +++++++++++++++++++++------------------------- nw/gui/projtree.py | 2 +- nw/guimain.py | 4 ++-- 3 files changed, 27 insertions(+), 33 deletions(-) diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 1bdd1c32..2923938b 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -115,22 +115,25 @@ class GuiMainMenu(QMenuBar): ## def setSpellCheck(self, theMode): - """Set the spell check check box to theMode. This is controlled - by the document editor class, which holds the master spell check - flag. + """Forward spell check check state to its action. """ self.aSpellCheck.setChecked(theMode) return def setAutoOutline(self, theMode): - """Set the auto outline check box to theMode. Used during - initialisation. + """Forward auto outline check state to its action. """ self.aAutoOutline.setChecked(theMode) return + def setFocusMode(self, theMode): + """Forward focus mode check state to its action. + """ + self.aFocusMode.setChecked(theMode) + return + ## - # Menu Action + # Slots ## def _toggleSpellCheck(self, isChecked=False): @@ -164,17 +167,11 @@ class GuiMainMenu(QMenuBar): return True def _openWebsite(self, theUrl): - """Open an URL in the system's default browser. + """Open a URL in the system's default browser. """ QDesktopServices.openUrl(QUrl(theUrl)) return True - def _openIssue(self): - """Open the issue tracker URL in the system's default browser. - """ - QDesktopServices.openUrl(QUrl(nw.__issuesurl__)) - return True - ## # Menu Builders ## @@ -276,6 +273,20 @@ class GuiMainMenu(QMenuBar): self.aDeleteItem.triggered.connect(lambda: self.theParent.treeView.deleteItem(None)) self.projMenu.addAction(self.aDeleteItem) + # Project > Move Up + self.aMoveUp = QAction("Move Item Up", self) + self.aMoveUp.setStatusTip("Move project item up") + self.aMoveUp.setShortcut("Ctrl+Up") + self.aMoveUp.triggered.connect(lambda: self._moveTreeItem(-1)) + self.projMenu.addAction(self.aMoveUp) + + # Project > Move Down + self.aMoveDown = QAction("Move Item Down", self) + self.aMoveDown.setStatusTip("Move project item down") + self.aMoveDown.setShortcut("Ctrl+Down") + self.aMoveDown.triggered.connect(lambda: self._moveTreeItem(1)) + self.projMenu.addAction(self.aMoveDown) + # Project > Empty Trash self.aEmptyTrash = QAction("Empty Trash", self) self.aEmptyTrash.setStatusTip("Permanently delete all files in the Trash folder") @@ -891,23 +902,6 @@ class GuiMainMenu(QMenuBar): # Tools self.toolsMenu = self.addMenu("&Tools") - # Tools > Move Up - self.aMoveUp = QAction("Move Tree Item Up", self) - self.aMoveUp.setStatusTip("Move item up") - self.aMoveUp.setShortcut("Ctrl+Shift+Up") - self.aMoveUp.triggered.connect(lambda: self._moveTreeItem(-1)) - self.toolsMenu.addAction(self.aMoveUp) - - # Tools > Move Down - self.aMoveDown = QAction("Move Tree Item Down", self) - self.aMoveDown.setStatusTip("Move item down") - self.aMoveDown.setShortcut("Ctrl+Shift+Down") - self.aMoveDown.triggered.connect(lambda: self._moveTreeItem(1)) - self.toolsMenu.addAction(self.aMoveDown) - - # Tools > Separator - self.toolsMenu.addSeparator() - # Tools > Toggle Spell Check self.aSpellCheck = QAction("Check Spelling", self) self.aSpellCheck.setStatusTip("Toggle check spelling") diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 31cdb19a..8d81a063 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -335,7 +335,7 @@ class GuiProjectTree(QTreeWidget): logger.error("No project open") return False - if qApp.focusWidget() != self: + if not self.hasFocus(): return False tHandle = self.getSelectedHandle() diff --git a/nw/guimain.py b/nw/guimain.py index f6538bf0..eff2f3d3 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -1167,11 +1167,11 @@ class GuiMain(QMainWindow): """ if self.docEditor.theHandle is None: logger.error("No document open, so not activating Focus Mode") - self.mainMenu.aFocusMode.setChecked(self.isFocusMode) + self.mainMenu.setFocusMode(self.isFocusMode) return False self.isFocusMode = not self.isFocusMode - self.mainMenu.aFocusMode.setChecked(self.isFocusMode) + self.mainMenu.setFocusMode(self.isFocusMode) if self.isFocusMode: logger.debug("Activating Focus Mode") self.mainTabs.setCurrentWidget(self.splitDocs) From 3cecc1229f1c7afd92dda721d1dda0e2f3cd99b4 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 31 Jan 2021 13:44:44 +0100 Subject: [PATCH 4/9] Fix GUI blocking in tests --- tests/test_gui/test_gui_docviewer.py | 1 + tests/test_gui/test_gui_outline.py | 1 + tests/test_gui/test_gui_projsettings.py | 1 + tests/test_gui/test_gui_projtree.py | 7 +++++-- tests/test_gui/test_gui_projwizard.py | 1 + 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py index 8c928a97..3585c8c8 100644 --- a/tests/test_gui/test_gui_docviewer.py +++ b/tests/test_gui/test_gui_docviewer.py @@ -38,6 +38,7 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, nwLipsum): """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "information", lambda *args: QMessageBox.Yes) # Open project nwGUI.theProject.projTree.setSeed(42) diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py index 125e59b4..bfeb2f96 100644 --- a/tests/test_gui/test_gui_outline.py +++ b/tests/test_gui/test_gui_outline.py @@ -37,6 +37,7 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, nwLipsum): """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "information", lambda *args: QMessageBox.Yes) assert nwGUI.openProject(nwLipsum) nwGUI.mainConf.lastPath = nwLipsum diff --git a/tests/test_gui/test_gui_projsettings.py b/tests/test_gui/test_gui_projsettings.py index 50a5fff1..9955aac8 100644 --- a/tests/test_gui/test_gui_projsettings.py +++ b/tests/test_gui/test_gui_projsettings.py @@ -48,6 +48,7 @@ def testGuiProjSettings_Dialog(qtbot, monkeypatch, nwGUI, fncDir, fncProj, outDi # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "critical", lambda *args: QMessageBox.Yes) # Check that we cannot open when there is no project nwGUI.mainMenu.aProjectSettings.activate(QAction.Trigger) diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index 317b8c58..c750537c 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -38,6 +38,9 @@ def testGuiProjTree_TreeItems(qtbot, caplog, monkeypatch, nwGUI, nwMinimal): """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "critical", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "warning", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "information", lambda *args: QMessageBox.Yes) monkeypatch.setattr(GuiMain, "editItem", lambda *args: None) monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *args: True) @@ -96,12 +99,12 @@ def testGuiProjTree_TreeItems(qtbot, caplog, monkeypatch, nwGUI, nwMinimal): nwTree.setSelectedHandle("8c659a11cd429") # Shift focus and try to move item - monkeypatch.setattr("PyQt5.QtWidgets.qApp.focusWidget", lambda: None) + monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *args: False) assert not nwTree.moveTreeItem(1) assert nwTree.getTreeFromHandle("a6d311a93600a") == [ "a6d311a93600a", "f5ab3e30151e1", "8c659a11cd429", "44cb730c42048", "71ee45a3c0db9" ] - monkeypatch.setattr("PyQt5.QtWidgets.qApp.focusWidget", lambda: nwTree) + monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *args: True) # Move second item up twice (should give same result) nwGUI.mainMenu.aMoveUp.activate(QAction.Trigger) diff --git a/tests/test_gui/test_gui_projwizard.py b/tests/test_gui/test_gui_projwizard.py index f2a967fc..4208bc8f 100644 --- a/tests/test_gui/test_gui_projwizard.py +++ b/tests/test_gui/test_gui_projwizard.py @@ -46,6 +46,7 @@ def testGuiProjectWizard_Main(qtbot, monkeypatch, nwGUI, nwMinimal): """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "critical", lambda *args: QMessageBox.Yes) if sys.platform.startswith("darwin"): # Disable for macOS because the test segfaults on QWizard.show() From 2307dfaaac19766b0e1b54eeefab33aea166955c Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 31 Jan 2021 16:54:49 +0100 Subject: [PATCH 5/9] Add feature to undo last project tree move --- docs/source/int_interface.rst | 6 +- nw/constants/constants.py | 3 + nw/gui/mainmenu.py | 15 +++-- nw/gui/projtree.py | 105 +++++++++++++++++++++++++++++----- 4 files changed, 108 insertions(+), 21 deletions(-) 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): From a52b71ef43cea607d69b21f60eaa209a502a3f30 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 31 Jan 2021 22:06:13 +0100 Subject: [PATCH 6/9] Remove ToC.txt files from repo --- .gitignore | 1 + nw/constants/constants.py | 1 - sample/ToC.txt | 23 ----------------------- tests/lipsum/ToC.txt | 22 ---------------------- tests/minimal/ToC.txt | 10 ---------- 5 files changed, 1 insertion(+), 56 deletions(-) delete mode 100644 sample/ToC.txt delete mode 100644 tests/lipsum/ToC.txt delete mode 100644 tests/minimal/ToC.txt diff --git a/.gitignore b/.gitignore index 66c21d61..3d1b337e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ __pycache__ /sample/meta *.bak *.lock +ToC.txt # PyTest /prof/ diff --git a/nw/constants/constants.py b/nw/constants/constants.py index 26bb8f7d..e748c0ff 100644 --- a/nw/constants/constants.py +++ b/nw/constants/constants.py @@ -49,7 +49,6 @@ class nwConst(): class nwLists(): """Lists used for grouping various other constants. """ - # Regular user-accessible item types REG_TYPES = {nwItemType.ROOT, nwItemType.FOLDER, nwItemType.FILE} diff --git a/sample/ToC.txt b/sample/ToC.txt deleted file mode 100644 index b106e892..00000000 --- a/sample/ToC.txt +++ /dev/null @@ -1,23 +0,0 @@ - -Table of Contents -================= - -File Name Class Layout Document Label ---------------------------------------------------------------------- -content/53b69b83cdafc.nwd NOVEL TITLE Title Page -content/974e400180a99.nwd NOVEL PAGE Page -content/edca4be2fcaf8.nwd NOVEL PARTITION Part One -content/6a2d6d5f4f401.nwd NOVEL CHAPTER Chapter One -content/636b6aa9b697b.nwd NOVEL SCENE Making a Scene -content/bc0cbd2a407f3.nwd NOVEL SCENE Another Scene -content/ba8a28a246524.nwd NOVEL UNNUMBERED Interlude -content/96b68994dfa3d.nwd NOVEL NOTE A Note on Structure -content/88706ddc78b1b.nwd NOVEL CHAPTER Chapter Two -content/ae7339df26ded.nwd NOVEL SCENE We Found John! -content/14298de4d9524.nwd CHARACTER NOTE John Smith -content/bb2c23b3c42cc.nwd CHARACTER NOTE Jane Smith -content/b3e74dbc1f584.nwd WORLD NOTE Earth -content/f1471bef9f2ae.nwd WORLD NOTE Space -content/5eaea4e8cdee8.nwd WORLD NOTE Mars -content/8a5deb88c0e97.nwd NOVEL SCENE Old File -content/b8136a5a774a0.nwd NOVEL SCENE Delete Me! diff --git a/tests/lipsum/ToC.txt b/tests/lipsum/ToC.txt deleted file mode 100644 index a499c6f8..00000000 --- a/tests/lipsum/ToC.txt +++ /dev/null @@ -1,22 +0,0 @@ - - Table of Contents -=================== - - File Name Class Document Label --------------------------------------------------------------------------------- - content/04468803b92e1.nwd WORLD Ancient Europe - content/2426c6f0ca922.nwd PLOT Main - content/441420a886d82.nwd NOVEL Chapter Two - content/47666c91c7ccf.nwd NOVEL Scene Five - content/4c4f28287af27.nwd CHARACTER Mr. Nobody - content/7a992350f3eb6.nwd NOVEL Lorem Ipsum - content/846352075de7d.nwd NOVEL Interlude - content/88243afbe5ed8.nwd NOVEL Scene One - content/88d59a277361b.nwd NOVEL Prologue - content/8c58a65414c23.nwd NOVEL Front Matter - content/db7e733775d4d.nwd NOVEL Act One - content/eb103bc70c90c.nwd NOVEL Scene Three - content/f8c0562e50f1b.nwd NOVEL Scene Four - content/f96ec11c6a3da.nwd NOVEL Scene Two - content/fb609cd8319dc.nwd NOVEL Chapter One - diff --git a/tests/minimal/ToC.txt b/tests/minimal/ToC.txt deleted file mode 100644 index f5b94b72..00000000 --- a/tests/minimal/ToC.txt +++ /dev/null @@ -1,10 +0,0 @@ - - Table of Contents -=================== - - File Name Class Document Label --------------------------------------------------------------------------------- - content/8c659a11cd429.nwd NOVEL New Scene - content/a35baf2e93843.nwd NOVEL Title Page - content/f5ab3e30151e1.nwd NOVEL New Chapter - From 9596d24cca33ed888d4e29b2a71a689827530b20 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 31 Jan 2021 22:29:39 +0100 Subject: [PATCH 7/9] Some final tweaks to the drag and drop feature --- nw/gui/projtree.py | 22 +++++++++++++++------- sample/nwProject.nwx | 8 ++++---- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 4b3aeb7e..3573eaef 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -837,7 +837,6 @@ 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] @@ -846,16 +845,25 @@ class GuiProjectTree(QTreeWidget): self.makeAlert("The item cannot be moved to that location.", nwAlert.ERROR) return + pItem = sItem.parent() + pIndex = 0 + if pItem is not None: + pIndex = pItem.indexOfChild(sItem) + wCount = int(sItem.data(self.C_COUNT, Qt.UserRole)) + isFile = snItem.itemType == nwItemType.FILE + isRoot = snItem.itemType == nwItemType.ROOT + onFile = dnItem.itemType == nwItemType.FILE + isSame = snItem.itemClass == dnItem.itemClass isNone = snItem.itemClass == nwItemClass.NO_CLASS isNote = snItem.itemLayout == nwItemLayout.NOTE - isRoot = snItem.itemType == nwItemType.ROOT - 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: + + allowDrop = isSame or isNone or isNote or onFree + allowDrop &= not (self.dropIndicatorPosition() == QAbstractItemView.OnItem and onFile) + + if allowDrop and not isRoot: logger.debug("Drag'n'drop of item %s accepted" % sHandle) self.propagateCount(sHandle, 0) QTreeWidget.dropEvent(self, theEvent) @@ -871,7 +879,7 @@ class GuiProjectTree(QTreeWidget): self.setTreeItemValues(sHandle) self.propagateCount(sHandle, wCount) - self._recordLastMove(sItem, pItem, pItem.indexOfChild(sItem)) + self._recordLastMove(sItem, pItem, pIndex) # The items dropped into archive or trash should be removed # from the project index, for all other items, we rescan the diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 69bd2eee..cbb940be 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,13 +1,13 @@ - + Sample Project Sample Project Jane Smith Jay Doh - 874 + 881 157 - 42533 + 42668 False @@ -117,7 +117,7 @@ 1st Draft True SCENE - 1811 + 1810 318 8 1880 From 0c4d278de2ad3b552cb73bd9c0a79d01524ca2d3 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 31 Jan 2021 22:53:58 +0100 Subject: [PATCH 8/9] Extract duplicate code into a new function --- nw/gui/projtree.py | 84 ++++++++++++++++++++++++---------------------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 3573eaef..a2e7c7b0 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -298,10 +298,10 @@ class GuiProjectTree(QTreeWidget): # Save the text and index it newDoc.saveDocument(newText) - self.theParent.theIndex.scanText(tHandle, newText) + self.theIndex.scanText(tHandle, newText) # Get Word Counts - cC, wC, pC = self.theParent.theIndex.getCounts(tHandle) + cC, wC, pC = self.theIndex.getCounts(tHandle) nwItem.setCharCount(cC) nwItem.setWordCount(wC) nwItem.setParaCount(pC) @@ -719,17 +719,10 @@ class GuiProjectTree(QTreeWidget): 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._postItemMove(sHandle, snItem, dnItem, wCount) self.clearSelection() movItem.setSelected(True) @@ -867,32 +860,9 @@ class GuiProjectTree(QTreeWidget): logger.debug("Drag'n'drop of item %s accepted" % sHandle) self.propagateCount(sHandle, 0) QTreeWidget.dropEvent(self, theEvent) - self._updateItemParent(sHandle) - - # If the item does not have the same class as the target, - # 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 - )) - snItem.setClass(dnItem.itemClass) - self.setTreeItemValues(sHandle) - - self.propagateCount(sHandle, wCount) + self._postItemMove(sHandle, snItem, dnItem, wCount) self._recordLastMove(sItem, pItem, pIndex) - # The items dropped into archive or trash should be removed - # from the project index, for all other items, we rescan the - # file to ensure the index is up to date. - if onFree: - self.theIndex.deleteHandle(sHandle) - else: - self.theIndex.reIndexHandle(sHandle) - - # Trigger dependent updates - self._setTreeChanged(True) - self._emitItemChange(sHandle) - else: theEvent.ignore() logger.debug("Drag'n'drop of item %s not accepted" % sHandle) @@ -904,6 +874,40 @@ class GuiProjectTree(QTreeWidget): # Internal Functions ## + def _postItemMove(self, sHandle, snItem, dnItem, wCount): + """Run various maintenance tasks for a moved item. + """ + isFile = snItem.itemType == nwItemType.FILE + isSame = snItem.itemClass == dnItem.itemClass + onFree = dnItem.itemClass in nwLists.FREE_CLASS and isFile + + self._updateItemParent(sHandle) + + # If the item does not have the same class as the target, + # 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 + )) + snItem.setClass(dnItem.itemClass) + self.setTreeItemValues(sHandle) + + self.propagateCount(sHandle, wCount) + + # The items dropped into archive or trash should be removed + # from the project index, for all other items, we rescan the + # file to ensure the index is up to date. + if onFree: + self.theIndex.deleteHandle(sHandle) + else: + self.theIndex.reIndexHandle(sHandle) + + # Trigger dependent updates + self._setTreeChanged(True) + self._emitItemChange(sHandle) + + return + def _getTreeItem(self, tHandle): """Returns the QTreeWidgetItem of a given item handle. """ @@ -916,17 +920,17 @@ class GuiProjectTree(QTreeWidget): self._treeMap.pop(tHandle, None) return - def _scanChildren(self, theList, theItem, theIndex): + def _scanChildren(self, theList, tItem, tIndex): """This is a recursive function returning all items in a tree starting at a given QTreeWidgetItem. """ - tHandle = theItem.data(self.C_NAME, Qt.UserRole) + tHandle = tItem.data(self.C_NAME, Qt.UserRole) nwItem = self.theProject.projTree[tHandle] - nwItem.setExpanded(theItem.isExpanded()) - nwItem.setOrder(theIndex) + nwItem.setExpanded(tItem.isExpanded()) + nwItem.setOrder(tIndex) theList.append(tHandle) - for i in range(theItem.childCount()): - self._scanChildren(theList, theItem.child(i), i) + for i in range(tItem.childCount()): + self._scanChildren(theList, tItem.child(i), i) return theList def _addTreeItem(self, nwItem, nHandle=None): From af2fcbc059380d9be71dd5a34e316612bba4e752 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 31 Jan 2021 23:01:36 +0100 Subject: [PATCH 9/9] Add a simple test for now --- tests/test_gui/test_gui_projtree.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index c750537c..b0fdaa89 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -134,6 +134,15 @@ def testGuiProjTree_TreeItems(qtbot, caplog, monkeypatch, nwGUI, nwMinimal): "a6d311a93600a", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9", "8c659a11cd429" ] + # Move up twice, and undo + nwTree._lastMove = {} + nwGUI.mainMenu.aMoveUp.activate(QAction.Trigger) + nwGUI.mainMenu.aMoveUp.activate(QAction.Trigger) + nwGUI.mainMenu.aMoveUndo.activate(QAction.Trigger) + assert nwTree.getTreeFromHandle("a6d311a93600a") == [ + "a6d311a93600a", "f5ab3e30151e1", "44cb730c42048", "71ee45a3c0db9", "8c659a11cd429" + ] + # Move a root item (top level items are different) twice nwTree.flushTreeOrder() assert nwGUI.theProject.projTree._treeOrder.index("9d5247ab588e0") == 10