Re-implement row highlight, empty trash, and fix recursive permanent delete

This commit is contained in:
Veronica Berglyd Olsen
2024-11-20 23:07:40 +01:00
parent a45ab2f017
commit 2f71ec68d8
4 changed files with 81 additions and 107 deletions
+26 -16
View File
@@ -164,6 +164,22 @@ class NWProject:
"""Add a new file with a given label and parent item.""" """Add a new file with a given label and parent item."""
return self._tree.create(label, parent, nwItemType.FILE, pos=pos) return self._tree.create(label, parent, nwItemType.FILE, pos=pos)
def removeItem(self, tHandle: str) -> bool:
"""Remove an item from the project. This will delete both the
project entry and a document file if it exists.
"""
if self._tree.checkType(tHandle, nwItemType.FILE):
doc = self._storage.getDocument(tHandle)
if not doc.deleteDocument():
SHARED.error(
self.tr("Could not delete document file."),
info=doc.getError()
)
return False
self._index.deleteHandle(tHandle)
self._tree.remove(tHandle)
return True
def writeNewFile(self, tHandle: str, hLevel: int, isDocument: bool, text: str = "") -> bool: def writeNewFile(self, tHandle: str, hLevel: int, isDocument: bool, text: str = "") -> bool:
"""Write content to a new document after it is created. This """Write content to a new document after it is created. This
will not run if the file exists and is not empty. will not run if the file exists and is not empty.
@@ -208,23 +224,17 @@ class NWProject:
return True return True
def removeItem(self, tHandle: str) -> bool: def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Remove an item from the project. This will delete both the """Create a new note. This function is used by the document
project entry and a document file if it exists. editor to create note files for unknown tags.
""" """
if self._tree.checkType(tHandle, nwItemType.FILE): if itemClass != nwItemClass.NO_CLASS:
doc = self._storage.getDocument(tHandle) if not (rHandle := self._tree.findRoot(itemClass)):
if not doc.deleteDocument(): rHandle = self.newRoot(itemClass)
SHARED.error( if rHandle and (tHandle := SHARED.project.newFile(tag.title(), rHandle)):
self.tr("Could not delete document file."), self.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
info=doc.getError() self._tree.refreshItems([tHandle])
) return
return False
self._index.deleteHandle(tHandle)
self._tree.remove(tHandle)
return True
## ##
# Project Methods # Project Methods
+47 -90
View File
@@ -32,10 +32,10 @@ import logging
from enum import Enum from enum import Enum
from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QIcon, QMouseEvent, QPalette from PyQt5.QtGui import QIcon, QMouseEvent, QPainter, QPalette
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QAbstractItemView, QAction, QFrame, QHBoxLayout, QLabel, QMenu, QShortcut, QAbstractItemView, QAction, QFrame, QHBoxLayout, QLabel, QMenu, QShortcut,
QTreeView, QVBoxLayout, QWidget QStyleOptionViewItem, QTreeView, QVBoxLayout, QWidget
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
@@ -254,12 +254,6 @@ class GuiProjectView(QWidget):
self.projBar.buildQuickLinksMenu() self.projBar.buildQuickLinksMenu()
return return
@pyqtSlot(str, nwItemClass)
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Process new not request."""
self.projTree.createNewNote(tag, itemClass)
return
@pyqtSlot(str) @pyqtSlot(str)
def refreshUserLabels(self, kind: str) -> None: def refreshUserLabels(self, kind: str) -> None:
"""Refresh status or importance labels.""" """Refresh status or importance labels."""
@@ -359,7 +353,7 @@ class GuiProjectToolBar(QWidget):
self.aCollapse.triggered.connect(self.projTree.collapseAll) self.aCollapse.triggered.connect(self.projTree.collapseAll)
self.aEmptyTrash = self.mMore.addAction(self.tr("Empty Trash")) self.aEmptyTrash = self.mMore.addAction(self.tr("Empty Trash"))
self.aEmptyTrash.triggered.connect(qtLambda(self.projTree.emptyTrash)) self.aEmptyTrash.triggered.connect(self.projTree.emptyTrash)
self.tbMore = NIconToolButton(self, iSz) self.tbMore = NIconToolButton(self, iSz)
self.tbMore.setToolTip(self.tr("More Options")) self.tbMore.setToolTip(self.tr("More Options"))
@@ -506,7 +500,7 @@ class GuiProjectTree(QTreeView):
self.projView = projView self.projView = projView
# Internal Variables # Internal Variables
# self._actHandle = None self._actHandle = None
# Cached Translations # Cached Translations
self.trActive = trConst(nwLabels.ACTIVE_NAME["checked"]) self.trActive = trConst(nwLabels.ACTIVE_NAME["checked"])
@@ -566,6 +560,11 @@ class GuiProjectTree(QTreeView):
# External Methods # External Methods
## ##
def setActiveHandle(self, tHandle: str | None) -> None:
"""Set the handle to be highlighted."""
self._actHandle = tHandle
return
def getSelectedHandle(self) -> str | None: def getSelectedHandle(self) -> str | None:
"""Get the currently selected handle.""" """Get the currently selected handle."""
if (indexes := self.selectedIndexes()) and (node := self._getNode(indexes[0])): if (indexes := self.selectedIndexes()) and (node := self._getNode(indexes[0])):
@@ -583,11 +582,7 @@ class GuiProjectTree(QTreeView):
def loadModel(self) -> None: def loadModel(self) -> None:
"""Load and prepare a new project model.""" """Load and prepare a new project model."""
# selModel = self.selectionModel()
self.setModel(SHARED.project.tree.model) self.setModel(SHARED.project.tree.model)
# if selModel:
# selModel.deleteLater()
# del selModel
# Lock the column sizes # Lock the column sizes
iPx = SHARED.theme.baseIconHeight iPx = SHARED.theme.baseIconHeight
@@ -820,7 +815,7 @@ class GuiProjectTree(QTreeView):
return return
## ##
# Events # Events and Overloads
## ##
def mousePressEvent(self, event: QMouseEvent) -> None: def mousePressEvent(self, event: QMouseEvent) -> None:
@@ -839,6 +834,13 @@ class GuiProjectTree(QTreeView):
) )
return return
def drawRow(self, painter: QPainter, opt: QStyleOptionViewItem, index: QModelIndex) -> None:
"""Draw a box on the active row."""
if (node := self._getNode(index)) and node.item.itemHandle == self._actHandle:
painter.fillRect(opt.rect, self.palette().alternateBase())
super().drawRow(painter, opt, index)
return
## ##
# Public Slots # Public Slots
## ##
@@ -913,33 +915,55 @@ class GuiProjectTree(QTreeView):
def processDeleteRequest(self, handles: list[str] = [], askFirst: bool = True) -> None: def processDeleteRequest(self, handles: list[str] = [], askFirst: bool = True) -> None:
"""Move selected items to Trash.""" """Move selected items to Trash."""
if handles and (model := self._getModel()): if handles and (model := self._getModel()):
items = [model.indexFromHandle(handle) for handle in handles] indices = [model.indexFromHandle(handle) for handle in handles]
else: else:
items = self._selectedRows() indices = self._selectedRows()
if items and (model := self._getModel()): if indices and (model := self._getModel()):
if len(items) == 1 and (node := model.node(items[0])) and node.item.isRootType(): if len(indices) == 1 and (node := model.node(indices[0])) and node.item.isRootType():
if node.childCount() == 0: if node.childCount() == 0:
SHARED.project.removeItem(node.item.itemHandle) SHARED.project.removeItem(node.item.itemHandle)
else: else:
SHARED.error(self.tr("Root folders can only be deleted when they are empty.")) SHARED.error(self.tr("Root folders can only be deleted when they are empty."))
return return
if model.trashSelection(items): if model.trashSelection(indices):
if not SHARED.question(self.tr("Permanently delete selected item(s)?")): if not SHARED.question(self.tr("Permanently delete selected item(s)?")):
logger.info("Action cancelled by user") logger.info("Action cancelled by user")
return return
for node in model.nodes(items): for index in indices:
SHARED.project.removeItem(node.item.itemHandle) if node := model.node(index):
for child in reversed(node.allChildren()):
SHARED.project.removeItem(child.item.itemHandle)
SHARED.project.removeItem(node.item.itemHandle)
elif trashNode := SHARED.project.tree.trash: elif trashNode := SHARED.project.tree.trash:
if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")): if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")):
logger.info("Action cancelled by user") logger.info("Action cancelled by user")
return return
model.multiMove(items, model.indexFromNode(trashNode)) model.multiMove(indices, model.indexFromNode(trashNode))
return return
@pyqtSlot()
def emptyTrash(self) -> None:
"""Permanently delete all documents in the Trash folder. This
function only asks for confirmation once, and calls the regular
deleteItem function for each document in the Trash folder.
"""
if trash := SHARED.project.tree.trash:
if not (nodes := trash.allChildren()):
SHARED.info(self.tr("The Trash folder is already empty."))
return
if not SHARED.question(
self.tr("Permanently delete {0} file(s) from Trash?").format(len(nodes))
):
logger.info("Action cancelled by user")
return
for node in reversed(nodes):
SHARED.project.removeItem(node.item.itemHandle)
return
@pyqtSlot() @pyqtSlot()
@pyqtSlot("QPoint") @pyqtSlot("QPoint")
def openContextMenu(self, point: QPoint | None = None) -> None: def openContextMenu(self, point: QPoint | None = None) -> None:
@@ -1031,60 +1055,6 @@ class GuiProjectTree(QTreeView):
# Old Code # Old Code
# =========================================================================================== # # =========================================================================================== #
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Create a new note. This function is used by the document
editor to create note files for unknown tags.
"""
# if itemClass != nwItemClass.NO_CLASS:
# if not (rHandle := SHARED.project.tree.findRoot(itemClass)):
# self.newTreeItem(nwItemType.ROOT, itemClass)
# rHandle = SHARED.project.tree.findRoot(itemClass)
# if rHandle and (tHandle := SHARED.project.newFile(tag, rHandle)):
# SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
# self.revealNewTreeItem(tHandle, wordCount=True)
return
@pyqtSlot()
def emptyTrash(self) -> bool:
"""Permanently delete all documents in the Trash folder. This
function only asks for confirmation once, and calls the regular
deleteItem function for each document in the Trash folder.
"""
# if not SHARED.hasProject:
# logger.error("No project open")
# return False
# trashHandle = SHARED.project.tree.trashRoot
# logger.debug("Emptying Trash folder")
# if trashHandle is None:
# SHARED.info(self.tr("There is currently no Trash folder in this project."))
# return False
# trashItems = self.getTreeFromHandle(trashHandle)
# if trashHandle in trashItems:
# trashItems.remove(trashHandle)
# nTrash = len(trashItems)
# if nTrash == 0:
# SHARED.info(self.tr("The Trash folder is already empty."))
# return False
# if not SHARED.question(self.trPermDelete.format(nTrash)):
# logger.info("Action cancelled by user")
# return False
# logger.debug("Deleting %d file(s) from Trash", nTrash)
# for tHandle in reversed(self.getTreeFromHandle(trashHandle)):
# if tHandle == trashHandle:
# continue
# self.permDeleteItem(tHandle, askFirst=False, flush=False)
# if nTrash > 0:
# self._alertTreeChange(trashHandle, flush=True)
return True
def refreshUserLabels(self, kind: str) -> None: def refreshUserLabels(self, kind: str) -> None:
"""Refresh status or importance labels.""" """Refresh status or importance labels."""
# if kind == "s": # if kind == "s":
@@ -1136,19 +1106,6 @@ class GuiProjectTree(QTreeView):
return return
def setActiveHandle(self, tHandle: str | None) -> None:
"""Highlight the rows associated with a given handle."""
# brushOn = self.palette().alternateBase()
# brushOff = self.palette().base()
# if (pHandle := self._actHandle) and (item := self._treeMap.get(pHandle)):
# for i in range(self.columnCount()):
# item.setBackground(i, brushOff)
# if tHandle and (item := self._treeMap.get(tHandle)):
# for i in range(self.columnCount()):
# item.setBackground(i, brushOn)
# self._actHandle = tHandle or None
return
class _UpdatableMenu(QMenu): class _UpdatableMenu(QMenu):
+1 -1
View File
@@ -261,7 +261,7 @@ class GuiMain(QMainWindow):
self.docEditor.novelItemMetaChanged.connect(self.novelView.updateNovelItemMeta) self.docEditor.novelItemMetaChanged.connect(self.novelView.updateNovelItemMeta)
self.docEditor.novelStructureChanged.connect(self.novelView.refreshTree) self.docEditor.novelStructureChanged.connect(self.novelView.refreshTree)
self.docEditor.openDocumentRequest.connect(self._openDocument) self.docEditor.openDocumentRequest.connect(self._openDocument)
self.docEditor.requestNewNoteCreation.connect(self.projView.createNewNote) self.docEditor.requestNewNoteCreation.connect(SHARED.createNewNote)
self.docEditor.requestNextDocument.connect(self.openNextDocument) self.docEditor.requestNextDocument.connect(self.openNextDocument)
self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem) self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem)
self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle) self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle)
+7
View File
@@ -37,6 +37,7 @@ from PyQt5.QtWidgets import QFileDialog, QFontDialog, QMessageBox, QWidget
from novelwriter.common import formatFileFilter from novelwriter.common import formatFileFilter
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.spellcheck import NWSpellEnchant from novelwriter.core.spellcheck import NWSpellEnchant
from novelwriter.enum import nwItemClass
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
@@ -303,6 +304,12 @@ class SharedData(QObject):
QDesktopServices.openUrl(QUrl(url)) QDesktopServices.openUrl(QUrl(url))
return return
@pyqtSlot(str, nwItemClass)
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Process new note request."""
self.project.createNewNote(tag, itemClass)
return
## ##
# Signal Proxy # Signal Proxy
## ##