diff --git a/nw/enum.py b/nw/enum.py index 82658a48..ad6642ab 100644 --- a/nw/enum.py +++ b/nw/enum.py @@ -14,10 +14,12 @@ from enum import Enum class nwItemType(Enum): - NONE = 0 - ROOT = 1 - FOLDER = 2 - FILE = 3 + NONE = 0 + ROOT = 1 + FOLDER = 2 + FILE = 3 + TRASHFOLDER = 4 + TRASHFILE = 5 # END Enum nwItemType @@ -27,7 +29,28 @@ class nwItemClass(Enum): NOVEL = 1 CHAPTER = 2 SCENE = 3 - CHARACTER = 4 - WORLD = 5 + PLOT = 4 + CHARACTER = 6 + WORLD = 6 + TIMELINE = 7 + OBJECT = 8 # END Enum nwItemClass + +class nwItemAction(Enum): + + NONE = 0 + ADD_ROOT = 1 + ADD_FOLDER = 2 + ADD_FILE = 3 + MOVE_UP = 4 + MOVE_DOWN = 5 + MOVE_TO = 6 + MOVE_TRASH = 7 + SPLIT = 8 + MERGE = 9 + DELETE = 10 + DELETE_ROOT = 11 + EMPTY_TRASH = 12 + +# END Enum nwItemAction diff --git a/nw/gui/doctree.py b/nw/gui/doctree.py index 7a37e51d..489dc89c 100644 --- a/nw/gui/doctree.py +++ b/nw/gui/doctree.py @@ -42,10 +42,6 @@ class GuiDocTree(QTreeWidget): if not self.debugGUI: self.hideColumn(3) - # Context Menu - self.setContextMenuPolicy(Qt.CustomContextMenu) - self.customContextMenuRequested.connect(self._openContextMenu) - # Allow Move by Drag & Drop self.setDragEnabled(True) self.setDragDropMode(QAbstractItemView.InternalMove) @@ -78,7 +74,7 @@ class GuiDocTree(QTreeWidget): tHandle = self.theProject.newFile("New File", nwItemClass.NONE, pHandle) elif itemType == nwItemType.FOLDER: if pHandle is None: - logger.error("Failed to add new item.") + logger.error("Failed to add new item") return pItem = self.theProject.projTree[rHandle] if pItem.itemClass == nwItemClass.NOVEL: @@ -90,7 +86,7 @@ class GuiDocTree(QTreeWidget): elif itemType == nwItemType.ROOT: tHandle = self.theProject.newRoot("Root Folder", nwItemClass.NONE) else: - logger.error("Failed to add new item.") + logger.error("Failed to add new item") return # Add the new item to the tree @@ -114,25 +110,6 @@ class GuiDocTree(QTreeWidget): ] return retVals - ## - # Context Menu - ## - - def _openContextMenu(self, thePos): - - sIDs = self.selectedIndexes() - print(sIDs) - - ctxMenu = QMenu(self) - - menuNewObject = QAction(QIcon.fromTheme("folder-new"), "New Object", ctxMenu) - # menuNewObject.triggered.connect(self.newProject) - ctxMenu.addAction(menuNewObject) - - ctxMenu.exec_(self.viewport().mapToGlobal(thePos)) - - return - ## # Internal Functions ## @@ -190,4 +167,18 @@ class GuiDocTree(QTreeWidget): return selItem[0].text(3) return None + ## + # Event Overloading + ## + + def mousePressEvent(self, theEvent): + """Overload mousePressEvent to clear selection if clicking the mouse in a blank + area of the tree view. + """ + QTreeWidget.mousePressEvent(self, theEvent) + selItem = self.indexAt(theEvent.pos()) + if not selItem.isValid(): + self.clearSelection() + return + # END Class GuiDocTree diff --git a/nw/gui/doctreectx.py b/nw/gui/doctreectx.py new file mode 100644 index 00000000..d7bece76 --- /dev/null +++ b/nw/gui/doctreectx.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +"""novelWriter GUI Document Tree Context Menu + + novelWriter – GUI Document Tree Context Menu +============================================== + Class holding the context menu of the left side document tree view + + File History: + Created: 2019-04-14 [0.0.1] + +""" + +import logging +import nw + +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QMenu, QAction + +from nw.enum import nwItemType, nwItemClass, nwItemAction + +logger = logging.getLogger(__name__) + +class GuiDocTreeCtx(QMenu): + + def __init__(self, theDocTree, theProject, thePosition): + QMenu.__init__(self, theDocTree) + + logger.debug("Initialising DocTree ContextMenu ...") + self.mainConf = nw.CONFIG + self.theProject = theProject + self.theDocTree = theDocTree + + self.selAction = None + self.selClass = None + self.selType = None + self.selTarget = None + + selItems = self.theDocTree.selectedItems() + if len(selItems) == 0: + self.selHandle = None + else: + self.selHandle = selItems[0].text(3) + print(self.selHandle) + + self._buildMenu() + + logger.debug("DocTree ContextMenu initialisation complete") + + self.exec_(self.theDocTree.viewport().mapToGlobal(thePosition)) + + return + + def _buildMenu(self): + vActs = self.theProject.getActionList(self.selHandle) + if nwItemAction.ADD_ROOT in vActs.keys(): + self._buildMenuAddRoot(vActs) + if nwItemAction.ADD_FOLDER in vActs.keys(): + self._buildMenuAddFolder(vActs) + if nwItemAction.ADD_FILE in vActs.keys(): + self._buildMenuAddFile(vActs) + if nwItemAction.MOVE_UP in vActs.keys(): + self._buildMenuMoveUp(vActs) + if nwItemAction.MOVE_DOWN in vActs.keys(): + self._buildMenuMoveDown(vActs) + if nwItemAction.MOVE_TRASH in vActs.keys(): + self._buildMenuMoveTrash(vActs) + if nwItemAction.MOVE_TO in vActs.keys(): + self._buildMenuMoveTo(vActs) + if nwItemAction.SPLIT in vActs.keys(): + self._buildMenuSplit(vActs) + if nwItemAction.MERGE in vActs.keys(): + self._buildMenuMerge(vActs) + if nwItemAction.DELETE in vActs.keys(): + self._buildMenuDelete(vActs) + if nwItemAction.DELETE_ROOT in vActs.keys(): + self._buildMenuDeleteRoot(vActs) + if nwItemAction.EMPTY_TRASH in vActs.keys(): + self._buildMenuEmptyTrash(vActs) + + ## + # Build Sub Menus + ## + + def _buildMenuDelete(self, vActs): + mnuItem = QAction("Delete Permanently", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.DELETE, None, None + )) + self.addAction(mnuItem) + + def _buildMenuDeleteRoot(self, vActs): + mnuItem = QAction("Remove Root", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.DELETE_ROOT, None, None + )) + self.addAction(mnuItem) + + def _buildMenuEmptyTrash(self, vActs): + mnuItem = QAction("Empty Trash", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.EMPTY_TRASH, None, None + )) + self.addAction(mnuItem) + + def _buildMenuSplit(self, vActs): + mnuItem = QAction("Split File", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.SPLIT, None, None + )) + self.addAction(mnuItem) + + def _buildMenuMerge(self, vActs): + mnuItem = QAction("Merge Folder", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.MERGE, None, None + )) + self.addAction(mnuItem) + + def _buildMenuMoveUp(self, vActs): + mnuItem = QAction("Move Up", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.MOVE_UP, None, None + )) + self.addAction(mnuItem) + + def _buildMenuMoveDown(self, vActs): + mnuItem = QAction("Move Down", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.MOVE_DOWN, None, None + )) + self.addAction(mnuItem) + + def _buildMenuMoveTrash(self, vActs): + mnuItem = QAction("Move to Trash", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.MOVE_TRASH, None, None + )) + self.addAction(mnuItem) + + def _buildMenuMoveTo(self, vActs): + mnuItem = QAction("Move to", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.MOVE_TO, None, None, + None + )) + self.addAction(mnuItem) + + def _buildMenuAddFile(self, vActs): + mnuItem = QAction("Add File", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.ADD_FILE, + vActs[nwItemAction.ADD_FILE]["Class"], + nwItemType.FILE + )) + self.addAction(mnuItem) + + def _buildMenuAddFolder(self, vActs): + mnuItem = QAction("Add Folder", self) + mnuItem.triggered.connect(lambda: self._ctxSignal( + nwItemAction.ADD_FOLDER, + vActs[nwItemAction.ADD_FOLDER]["Class"], + nwItemType.FOLDER + )) + self.addAction(mnuItem) + + def _buildMenuAddRoot(self, vActs): + + vClass = vActs[nwItemAction.ADD_ROOT]["Class"] + mnuItem = QMenu("Add Root Item", self) + + if nwItemClass.NOVEL in vClass: + mnuSub = QAction("Novel", self) + mnuSub.triggered.connect(lambda: self._ctxSignal( + nwItemAction.ADD_ROOT, + nwItemClass.NOVEL, + nwItemType.ROOT + )) + mnuItem.addAction(mnuSub) + + if nwItemClass.CHARACTER in vClass: + mnuSub = QAction("Characters", self) + mnuSub.triggered.connect(lambda: self._ctxSignal( + nwItemAction.ADD_ROOT, + nwItemClass.CHARACTER, + nwItemType.ROOT + )) + mnuItem.addAction(mnuSub) + + if nwItemClass.WORLD in vClass: + mnuSub = QAction("Locations", self) + mnuSub.triggered.connect(lambda: self._ctxSignal( + nwItemAction.ADD_ROOT, + nwItemClass.WORLD, + nwItemType.ROOT + )) + mnuItem.addAction(mnuSub) + + if nwItemClass.TIMELINE in vClass: + mnuSub = QAction("Timeline", self) + mnuSub.triggered.connect(lambda: self._ctxSignal( + nwItemAction.ADD_ROOT, + nwItemClass.TIMELINE, + nwItemType.ROOT + )) + mnuItem.addAction(mnuSub) + + if nwItemClass.OBJECT in vClass: + mnuSub = QAction("Objects", self) + mnuSub.triggered.connect(lambda: self._ctxSignal( + nwItemAction.ADD_ROOT, + nwItemClass.OBJECT, + nwItemType.ROOT + )) + mnuItem.addAction(mnuSub) + + self.addMenu(mnuItem) + return + + ## + # Menu Signal + ## + + def _ctxSignal(self, theAction, theClass, theType, theTarget=None): + self.selAction = theAction + self.selClass = theClass + self.selType = theType + self.selTarget = theTarget + return + +# END Class GuiDocTreeCtx diff --git a/nw/gui/winmain.py b/nw/gui/winmain.py index 1c13be39..faff023f 100644 --- a/nw/gui/winmain.py +++ b/nw/gui/winmain.py @@ -20,6 +20,7 @@ from PyQt5.QtGui import QIcon, QStandardItemModel from nw.enum import nwItemType from nw.gui.doctree import GuiDocTree +from nw.gui.doctreectx import GuiDocTreeCtx from nw.gui.doceditor import GuiDocEditor from nw.gui.projecteditor import GuiProjectEditor from nw.project.project import NWProject @@ -66,6 +67,8 @@ class GuiMain(QMainWindow): self.setCentralWidget(self.splitMain) self._buildMenu() + self.treeView.setContextMenuPolicy(Qt.CustomContextMenu) + self.treeView.customContextMenuRequested.connect(self._openDocTreeContextMenu) self.treeView.itemDoubleClicked.connect(self._treeDoubleClick) self.treeView.buildTree() @@ -184,9 +187,9 @@ class GuiMain(QMainWindow): self.setWindowTitle(winTitle) return True - # + ## # Menu Action - # + ## def _menuExit(self): self._closeMain() @@ -197,6 +200,22 @@ class GuiMain(QMainWindow): self.docTabs.createTab(None,nw.DOCTYPE_ABOUT) return True + ## + # DocTree Context Menu + ## + + def _openDocTreeContextMenu(self, thePosition): + + ctxMenu = GuiDocTreeCtx(self.treeView, self.theProject, thePosition) + + selAction = ctxMenu.selAction + selClass = ctxMenu.selClass + selType = ctxMenu.selType + + print(selAction, selClass, selType) + + return + ## # Events ## diff --git a/nw/project/item.py b/nw/project/item.py index e1ba4bdc..71a13074 100644 --- a/nw/project/item.py +++ b/nw/project/item.py @@ -23,15 +23,19 @@ logger = logging.getLogger(__name__) class NWItem(): + MAXDEPTH = 8 + def __init__(self): - self.itemName = "" - self.itemHandle = None - self.parHandle = None - self.itemOrder = None - self.itemType = nwItemType.NONE - self.itemClass = nwItemClass.NONE - self.isExpanded = False + self.itemName = "" + self.itemHandle = None + self.parHandle = None + self.itemOrder = None + self.itemType = nwItemType.NONE + self.itemClass = nwItemClass.NONE + self.itemDepth = None + self.isExpanded = False + self.hasChildren = False return @@ -84,6 +88,13 @@ class NWItem(): self.itemClass = nwItemClass.NONE return + def setDepth(self, theDepth): + if theDepth >= 0 and theDepth <= self.MAXDEPTH: + self.itemDepth = theDepth + else: + logger.error("Invalid item depth %d" % theDepth) + return + def setExpanded(self, expState): if isinstance(expState, str): self.isExpanded = expState == str(True) @@ -91,4 +102,8 @@ class NWItem(): self.isExpanded = expState return + def setHasChildren(self, hasChildren): + self.hasChildren = hasChildren + return + # END Class NWItem diff --git a/nw/project/project.py b/nw/project/project.py index 4bc37644..e220a322 100644 --- a/nw/project/project.py +++ b/nw/project/project.py @@ -19,7 +19,7 @@ from hashlib import sha256 from datetime import datetime from time import time -from nw.enum import nwItemType, nwItemClass +from nw.enum import nwItemType, nwItemClass, nwItemAction from nw.project.item import NWItem logger = logging.getLogger(__name__) @@ -34,6 +34,7 @@ class NWProject(): # Project Settings self.projTree = {} self.treeOrder = [] + self.treeRoots = [] self.projPath = None self.projFile = "nwProject.nwx" self.projName = "" @@ -191,8 +192,12 @@ class NWProject(): xItemValue.text = str(nwItem.itemType.name) xItemValue = etree.SubElement(xItem,"class") xItemValue.text = str(nwItem.itemClass.name) + xItemValue = etree.SubElement(xItem,"depth") + xItemValue.text = str(nwItem.itemDepth) xItemValue = etree.SubElement(xItem,"expanded") xItemValue.text = str(nwItem.isExpanded) + xItemValue = etree.SubElement(xItem,"children") + xItemValue.text = str(nwItem.hasChildren) # Write the xml tree to file with open(path.join(self.projPath,self.projFile),"wb") as outFile: @@ -248,18 +253,122 @@ class NWProject(): logger.error("No tree item with handle %s" % str(tHandle)) return None + def getActionList(self, tHandle): + """Returns a dictionary of possible actions to perform on a give handle. + """ + validActions = {} + + # If we're at root, or didn't select an utem, all we can add is root stuff + if tHandle is None: + validActions[nwItemAction.ADD_ROOT] = { + "Type" : nwItemType.ROOT, + "Class" : [x for x in nwItemClass if self._checkRootUnique(x)] + } + return validActions + + # Otherwise, the options depend on the item we had selected + tItem = self.getItem(tHandle) + tType = tItem.itemType + tClass = tItem.itemClass + tDepth = tItem.itemDepth + + # Trash items can only be moved or permanently deleted + if tType == nwItemType.TRASHFOLDER or tType == nwItemType.TRASHFILE: + validActions[nwItemAction.MOVE_TO] = {} + validActions[nwItemAction.DELETE] = {} + validActions[nwItemAction.EMPTY_TRASH] = {} + return validActions + + # Adding folders or files + # - Novels can only have folders under root + # - Files can only be 1 or 2 levels deep + # - Non-novel roots have only have a restriction on max depth + if tClass == nwItemClass.NOVEL: + if tType == nwItemType.ROOT: + validActions[nwItemAction.ADD_FOLDER] = { + "Type" : nwItemType.FOLDER, + "Class" : nwItemClass.NOVEL + } + if tDepth < 2: + validActions[nwItemAction.ADD_FILE] = { + "Type" : nwItemType.FILE, + "Class" : nwItemClass.NOVEL + } + if tType == nwItemType.FOLDER: + validActions[nwItemAction.MERGE] = {} + if tType == nwItemType.FILE: + validActions[nwItemAction.SPLIT] = {} + else: + if tDepth < NWItem.MAXDEPTH-1 and ( + tType == nwItemType.ROOT or tType == nwItemType.FOLDER + ): + validActions[nwItemAction.ADD_FOLDER] = { + "Type" : nwItemType.FOLDER, + "Class" : tClass + } + if tDepth < NWItem.MAXDEPTH: + validActions[nwItemAction.ADD_FILE] = { + "Type" : nwItemType.FILE, + "Class" : tClass + } + + # Other actions available to novel or plot items + validActions[nwItemAction.MOVE_UP] = {} + validActions[nwItemAction.MOVE_DOWN] = {} + if tType == nwItemType.ROOT: + validActions[nwItemAction.DELETE_ROOT] = {} + else: + validActions[nwItemAction.MOVE_TO] = {} + validActions[nwItemAction.MOVE_TRASH] = {} + + return validActions + # # Internal Functions # + def _checkRootUnique(self, theClass): + """Checks if there already is a root entry of class 'theClass' in the + root of the project tree. + """ + for aRoot in self.treeRoots: + if theClass == self.projTree[aRoot].itemClass: + return False + return True + + def _countItemDepth(self, tHandle): + theDepth = 0 + nwItem = self.getItem(tHandle) + while nwItem.parHandle is not None: + theDepth += 1 + nwItem = self.getItem(nwItem.parHandle) + if theDepth > NWItem.MAXDEPTH: + return None + return theDepth + def _appendItem(self, tHandle, pHandle, nwItem): tHandle = self._checkString(tHandle,self._makeHandle(),False) pHandle = self._checkString(pHandle,None,True) logger.verbose("Adding entry %s with parent %s" % (str(tHandle),str(pHandle))) + nwItem.setHandle(tHandle) nwItem.setParent(pHandle) + self.projTree[tHandle] = nwItem self.treeOrder.append(tHandle) + if pHandle is not None: + self.projTree[pHandle].setHasChildren(True) + + if nwItem.itemType == nwItemType.ROOT: + logger.verbose("Entry %s is a root item" % str(tHandle)) + self.treeRoots.append(tHandle) + + itemDepth = self._countItemDepth(tHandle) + if itemDepth is None: + logger.error("The depth of entry %s could not be determined" % tHandle) + else: + nwItem.setDepth(itemDepth) + return def _makeHandle(self, addSeed=""): diff --git a/sample/sampleNovel/nwProject.nwx b/sample/sampleNovel/nwProject.nwx index 1e377bfd..d66541fa 100644 --- a/sample/sampleNovel/nwProject.nwx +++ b/sample/sampleNovel/nwProject.nwx @@ -1,5 +1,5 @@ - + Sample Project Sample Project @@ -11,37 +11,49 @@ Novel ROOT NOVEL + 0 True + True New Chapter FOLDER - CHAPTER + NOVEL + 1 True + True New Scene FILE - SCENE + NOVEL + 2 False + False New File FILE - SCENE + NOVEL + 2 False + False Characters ROOT CHARACTER + 0 False + False World ROOT WORLD + 0 False + False