Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow documents to have child items #1047

Merged
merged 10 commits into from
Apr 23, 2022
5 changes: 3 additions & 2 deletions novelwriter/core/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,12 @@ def packXML(self, xParent):
itemAttrib["layout"] = str(self._layout.name)

metaAttrib = {}
metaAttrib["expanded"] = str(self._expanded)
if self._type == nwItemType.FILE:
metaAttrib["charCount"] = str(self._charCount)
metaAttrib["wordCount"] = str(self._wordCount)
metaAttrib["paraCount"] = str(self._paraCount)
metaAttrib["cursorPos"] = str(self._cursorPos)
else:
metaAttrib["expanded"] = str(self._expanded)

nameAttrib = {}
nameAttrib["status"] = str(self._status)
Expand Down Expand Up @@ -409,6 +408,8 @@ def setType(self, value):
self._type = value
elif isItemType(value):
self._type = nwItemType[value]
elif value == "TRASH":
self._type = nwItemType.ROOT
else:
logger.error("Unrecognised item type '%s'", value)
self._type = nwItemType.NO_TYPE
Expand Down
2 changes: 1 addition & 1 deletion novelwriter/core/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def trashFolder(self):
if trashHandle is None:
newItem = NWItem(self)
newItem.setName(trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH]))
newItem.setType(nwItemType.TRASH)
newItem.setType(nwItemType.ROOT)
newItem.setClass(nwItemClass.TRASH)
self.projTree.append(None, None, newItem)
self.projTree.updateItemData(newItem.itemHandle)
Expand Down
39 changes: 7 additions & 32 deletions novelwriter/core/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,13 @@ def append(self, tHandle, pHandle, nwItem):
if nwItem.itemClass == nwItemClass.ARCHIVE:
logger.verbose("Item '%s' is the archive folder", str(tHandle))
self._archRoot = tHandle

if nwItem.itemType == nwItemType.TRASH:
if self._trashRoot is None:
logger.verbose("Item '%s' is the trash folder", str(tHandle))
self._trashRoot = tHandle
else:
logger.error("Only one trash folder allowed")
return False
elif nwItem.itemClass == nwItemClass.TRASH:
if self._trashRoot is None:
logger.verbose("Item '%s' is the trash folder", str(tHandle))
self._trashRoot = tHandle
else:
logger.error("Only one trash folder allowed")
return False

self._projTree[tHandle] = nwItem
self._treeOrder.append(tHandle)
Expand Down Expand Up @@ -352,30 +351,6 @@ def setFileItemLayout(self, tHandle, itemLayout):

return True

##
# Getters
##

def countTypes(self):
"""Count the number of files, folders and roots in the project.
"""
nRoot = 0
nFolder = 0
nFile = 0

for tHandle in self._treeOrder:
tItem = self.__getitem__(tHandle)
if tItem is None:
continue
elif tItem.itemType == nwItemType.ROOT:
nRoot += 1
elif tItem.itemType == nwItemType.FOLDER:
nFolder += 1
elif tItem.itemType == nwItemType.FILE:
nFile += 1

return nRoot, nFolder, nFile

##
# Meta Methods
##
Expand Down
1 change: 0 additions & 1 deletion novelwriter/enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ class nwItemType(Enum):
ROOT = 1
FOLDER = 2
FILE = 3
TRASH = 4

# END Enum nwItemType

Expand Down
140 changes: 70 additions & 70 deletions novelwriter/gui/projtree.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def __init__(self, theParent):
# Tree Settings
iPx = self.theTheme.baseIconSize
self.setIconSize(QSize(iPx, iPx))
self.setExpandsOnDoubleClick(True)
self.setExpandsOnDoubleClick(False)
self.setIndentation(iPx)
self.setColumnCount(4)
self.setHeaderLabels([
Expand Down Expand Up @@ -349,6 +349,14 @@ def getTreeFromHandle(self, tHandle):
theList = self._scanChildren(theList, theItem, 0)
return theList

def toggleExpanded(self, tHandle):
"""Expand an item based on its handle.
"""
trItem = self._getTreeItem(tHandle)
if trItem is not None:
trItem.setExpanded(not trItem.isExpanded())
return

def getColumnSizes(self):
"""Return the column widths for the tree columns.
"""
Expand Down Expand Up @@ -435,7 +443,7 @@ def deleteItem(self, tHandle=None, alreadyAsked=False, bulkAction=False):
logger.error("Could not find tree item for deletion")
return False

wCount = int(trItemS.data(self.C_COUNT, Qt.UserRole))
wCount = self._getItemWordCount(tHandle)
if nwItemS.itemType == nwItemType.FILE:
logger.debug("User requested file '%s' deleted", tHandle)
trItemP = trItemS.parent()
Expand Down Expand Up @@ -576,7 +584,7 @@ def setTreeItemValues(self, tHandle):

return

def propagateCount(self, tHandle, theCount):
def propagateCount(self, tHandle, newCount, countChildren=False):
"""Recursive function setting the word count for a given item,
and propagating that count upwards in the tree until reaching a
root item. This function is more efficient than recalculating
Expand All @@ -588,8 +596,12 @@ def propagateCount(self, tHandle, theCount):
if tItem is None:
return

tItem.setText(self.C_COUNT, f"{theCount:n}")
tItem.setData(self.C_COUNT, Qt.UserRole, int(theCount))
if countChildren:
for i in range(tItem.childCount()):
newCount += int(tItem.child(i).data(self.C_COUNT, Qt.UserRole))

tItem.setText(self.C_COUNT, f"{newCount:n}")
tItem.setData(self.C_COUNT, Qt.UserRole, int(newCount))

pItem = tItem.parent()
if pItem is None:
Expand All @@ -602,7 +614,12 @@ def propagateCount(self, tHandle, theCount):
pHandle = pItem.data(self.C_NAME, Qt.UserRole)

if pHandle:
self.propagateCount(pHandle, pCount)
if self.theProject.projTree.checkType(pHandle, nwItemType.FILE):
# A file has an internal word count we need to account
# for, but a folder always has 0 words on its own.
pCount += self.theIndex.getCounts(pHandle)[1]

self.propagateCount(pHandle, pCount, countChildren=False)

return

Expand Down Expand Up @@ -646,11 +663,11 @@ def undoLastMove(self):
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)

wCount = self._getItemWordCount(sHandle)
self.propagateCount(sHandle, 0)
parItem = srcItem.parent()
srcIndex = parItem.indexOfChild(srcItem)
Expand Down Expand Up @@ -724,7 +741,7 @@ def _rightClickMenu(self, clickPos):
def doUpdateCounts(self, tHandle, cCount, wCount, pCount):
"""Slot for updating the word count of a specific item.
"""
self.propagateCount(tHandle, wCount)
self.propagateCount(tHandle, wCount, countChildren=True)
self.wordCountsChanged.emit()
return

Expand Down Expand Up @@ -765,61 +782,28 @@ def dropEvent(self, theEvent):
"""
sHandle = self.getSelectedHandle()
if sHandle is None:
logger.error("No handle selected")
logger.error("Invalid drag and drop event")
return

dIndex = self.indexAt(theEvent.pos())
if not dIndex.isValid():
logger.error("Invalid drop index")
return
logger.debug("Drag'n'drop of item '%s' accepted", sHandle)

sItem = self._getTreeItem(sHandle)
dItem = self.itemFromIndex(dIndex)
dHandle = dItem.data(self.C_NAME, Qt.UserRole)
snItem = self.theProject.projTree[sHandle]
dnItem = self.theProject.projTree[dHandle]
if dnItem is None:
self.theParent.makeAlert(self.tr(
"The item cannot be moved to that location."
), nwAlert.ERROR)
return
isExpanded = False
if sItem is not None:
isExpanded = sItem.isExpanded()

pItem = sItem.parent()
pIndex = 0
if pItem is not None:
pIndex = pItem.indexOfChild(sItem)

# Determine if the drag and drop is allowed:
# - Files can be moved anywhere
# - Folders can only be moved within the same root folder
# - Root folders cannot be moved at all
# - Items cannot be dropped on top of a file (moved inside)

isFile = snItem.itemType == nwItemType.FILE
isRoot = snItem.itemType == nwItemType.ROOT
onFile = dnItem.itemType == nwItemType.FILE
inSame = snItem.itemRoot == dnItem.itemRoot

allowDrop = inSame or isFile
allowDrop &= not (self.dropIndicatorPosition() == QAbstractItemView.OnItem and onFile)

if allowDrop and not isRoot:
logger.debug("Drag'n'drop of item '%s' accepted", sHandle)

wCount = int(sItem.data(self.C_COUNT, Qt.UserRole))
self.propagateCount(sHandle, 0)

QTreeWidget.dropEvent(self, theEvent)
self._postItemMove(sHandle, wCount)
self._recordLastMove(sItem, pItem, pIndex)

else:
logger.debug("Drag'n'drop of item '%s' not accepted", sHandle)
wCount = self._getItemWordCount(sHandle)
self.propagateCount(sHandle, 0)

theEvent.ignore()
self.theParent.makeAlert(self.tr(
"The item cannot be moved to that location."
), nwAlert.ERROR)
QTreeWidget.dropEvent(self, theEvent)
self._postItemMove(sHandle, wCount)
self._recordLastMove(sItem, pItem, pIndex)
sItem.setExpanded(isExpanded)

return

Expand All @@ -841,28 +825,40 @@ def _postItemMove(self, tHandle, wCount):
# is updated accordingly, and update word count
pHandle = trItemP.data(self.C_NAME, Qt.UserRole)
nwItemS.setParent(pHandle)
self.theProject.projTree.updateItemData(tHandle)
self.setTreeItemValues(tHandle)
self.propagateCount(tHandle, wCount)

trItemP.setExpanded(True)
logger.debug("The parent of item '%s' has been changed to '%s'", tHandle, pHandle)

# 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 nwItemS.isInactive():
self.theIndex.deleteHandle(tHandle)
else:
self.theIndex.reIndexHandle(tHandle)
mHandles = self.getTreeFromHandle(tHandle)
logger.debug("A total of %d item(s) were moved", len(mHandles))
for mHandle in mHandles:
logger.debug("Updating item '%s'", mHandle)
self.theProject.projTree.updateItemData(mHandle)

# Update the index
if nwItemS.isInactive():
self.theIndex.deleteHandle(mHandle)
else:
self.theIndex.reIndexHandle(mHandle)

self.setTreeItemValues(mHandle)

# Trigger dependent updates
self.propagateCount(tHandle, wCount)
self._setTreeChanged(True)
self._emitItemChange(tHandle)

return True

def _getItemWordCount(self, tHandle):
"""Retrun the word count of a given item handle.
"""
tItem = self._getTreeItem(tHandle)
if tItem is None:
return 0
return int(tItem.data(self.C_COUNT, Qt.UserRole))

def _getTreeItem(self, tHandle):
"""Returns the QTreeWidgetItem of a given item handle.
"""Return the QTreeWidgetItem of a given item handle.
"""
return self._treeMap.get(tHandle, None)

Expand All @@ -878,12 +874,17 @@ def _scanChildren(self, theList, tItem, tIndex):
starting at a given QTreeWidgetItem.
"""
tHandle = tItem.data(self.C_NAME, Qt.UserRole)
cCount = tItem.childCount()

# Update tree-related meta data
nwItem = self.theProject.projTree[tHandle]
nwItem.setExpanded(tItem.isExpanded())
nwItem.setExpanded(tItem.isExpanded() and cCount > 0)
nwItem.setOrder(tIndex)

theList.append(tHandle)
for i in range(tItem.childCount()):
for i in range(cCount):
self._scanChildren(theList, tItem.child(i), i)

return theList

def _addTreeItem(self, nwItem, nHandle=None):
Expand All @@ -910,8 +911,7 @@ def _addTreeItem(self, nwItem, nHandle=None):
self._treeMap[tHandle] = newItem
if pHandle is None:
if nwItem.itemType == nwItemType.ROOT:
self.addTopLevelItem(newItem)
elif nwItem.itemType == nwItemType.TRASH:
newItem.setFlags(newItem.flags() ^ Qt.ItemIsDragEnabled)
self.addTopLevelItem(newItem)
else:
self.theParent.makeAlert(self.tr(
Expand All @@ -931,7 +931,7 @@ def _addTreeItem(self, nwItem, nHandle=None):
self._treeMap[pHandle].insertChild(byIndex+1, newItem)
else:
self._treeMap[pHandle].addChild(newItem)
self.propagateCount(tHandle, nwItem.wordCount)
self.propagateCount(tHandle, nwItem.wordCount, countChildren=True)

self.setTreeItemValues(tHandle)
newItem.setExpanded(nwItem.isExpanded)
Expand Down Expand Up @@ -1057,7 +1057,7 @@ def filterActions(self, theItem):

trashHandle = self.theTree.theProject.projTree.trashRoot()

inTrash = theItem.itemParent == trashHandle and trashHandle is not None
inTrash = self.theTree.theProject.projTree.isTrash(theItem.itemHandle)
isTrash = theItem.itemHandle == trashHandle and trashHandle is not None
isFile = theItem.itemType == nwItemType.FILE

Expand Down
3 changes: 0 additions & 3 deletions novelwriter/gui/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,9 +639,6 @@ def getItemIcon(self, tType, tClass, tLayout, hLevel="H0"):
iconName = "proj_scene"
elif tLayout == nwItemLayout.NOTE:
iconName = "proj_note"
elif tType == nwItemType.TRASH:
iconName = nwLabels.CLASS_ICON[tClass]

if iconName is None:
return QIcon()

Expand Down
12 changes: 9 additions & 3 deletions novelwriter/guimain.py
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,7 @@ def rebuildIndex(self, beQuiet=False):
tItem.setCharCount(cC)
tItem.setWordCount(wC)
tItem.setParaCount(pC)
self.treeView.propagateCount(tItem.itemHandle, wC)
self.treeView.propagateCount(tItem.itemHandle, wC, countChildren=True)
self.treeView.setTreeItemValues(tItem.itemHandle)

tEnd = time()
Expand Down Expand Up @@ -1568,11 +1568,17 @@ def _treeSingleClick(self):
@pyqtSlot("QTreeWidgetItem*", int)
def _treeDoubleClick(self, tItem, colNo):
"""The user double-clicked an item in the tree. If it is a file,
we open it. Otherwise, we do nothing.
we open it. Otherwise, we toggle the expanded status.
"""
tHandle = self.treeView.getSelectedHandle()
if tHandle is not None:
self.openDocument(tHandle, changeFocus=False, doScroll=False)
tItem = self.theProject.projTree[tHandle]
if tItem is None:
return
if tItem.itemType == nwItemType.FILE:
self.openDocument(tHandle, changeFocus=False, doScroll=False)
else:
self.treeView.toggleExpanded(tHandle)
return

@pyqtSlot()
Expand Down
Loading