diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py index c88bd0da..69578a4d 100644 --- a/novelwriter/core/itemmodel.py +++ b/novelwriter/core/itemmodel.py @@ -178,7 +178,7 @@ class ProjectNode: def takeChild(self, pos: int) -> ProjectNode | None: """Remove a child item and return it.""" if 0 <= pos < len(self._children): - node = self._children.pop() + node = self._children.pop(pos) self._refreshChildrenPos() return node return None @@ -256,32 +256,23 @@ class ProjectModel(QAbstractItemModel): def parent(self, index: QModelIndex) -> QModelIndex: """Get the parent model index of another index.""" - if index.isValid(): - if parent := index.internalPointer().parent(): - return self.createIndex(parent.row(), 0, parent) + if index.isValid() and (parent := index.internalPointer().parent()): + return self.createIndex(parent.row(), 0, parent) return QModelIndex() def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: """get the index of a child item of a parent.""" - if parent.isValid(): - item: ProjectNode = parent.internalPointer() - else: - item = self._root - - if not self.hasIndex(row, column, parent): - return QModelIndex() - - if child := item.child(row): - return self.createIndex(row, column, child) - + if self.hasIndex(row, column, parent): + node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root + if child := node.child(row): + return self.createIndex(row, column, child) return QModelIndex() def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData: """Return display data for a project node.""" - if not index.isValid(): - return None - node: ProjectNode = index.internalPointer() - return node.data(index.column(), role) + if index.isValid(): + return index.internalPointer().data(index.column(), role) + return None ## # Data Access @@ -290,8 +281,7 @@ class ProjectModel(QAbstractItemModel): def row(self, index: QModelIndex) -> int: """Return the row number of the index.""" if index.isValid(): - node: ProjectNode = index.internalPointer() - return node.row() + return index.internalPointer().row() return -1 def node(self, index: QModelIndex) -> ProjectNode | None: @@ -320,10 +310,7 @@ class ProjectModel(QAbstractItemModel): def insertChild(self, child: ProjectNode, parent: QModelIndex, pos: int) -> None: """Insert a node into the model at a given position.""" - if parent.isValid(): - node: ProjectNode = parent.internalPointer() - else: - node = self._root + node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root count = node.childCount() row = minmax(pos, 0, count) if pos >= 0 else count self.beginInsertRows(parent, row, row) @@ -331,6 +318,16 @@ class ProjectModel(QAbstractItemModel): self.endInsertRows() return + def removeChild(self, parent: QModelIndex, pos: int) -> ProjectNode | None: + """Remove a node from the model and return it.""" + node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root + if 0 <= pos < node.childCount(): + self.beginRemoveRows(parent, pos, pos) + child = node.takeChild(pos) + self.endRemoveRows() + return child + return None + def internalMove(self, index: QModelIndex, step: int) -> None: """Move an item internally among its siblings.""" if index.isValid(): @@ -345,6 +342,30 @@ class ProjectModel(QAbstractItemModel): self.endMoveRows() return + def multiMove(self, indices: list[QModelIndex], target: QModelIndex) -> None: + """Move multiple items to a new location.""" + if target.isValid(): + # This is a two pass process. First we only select unique + # non-root items for move, then we do a second pass and only + # move those items that don't have a parent also scheduled + # for moving or have already been moved. Child items are + # moved with the parent. + pruned = [] + handles = set() + for index in indices: + if index.isValid(): + node: ProjectNode = index.internalPointer() + handle = node.item.itemHandle + if node.item.isRootType() is False and handle not in handles: + pruned.append(node) + handles.add(handle) + for node in pruned: + if node.item.itemParent not in handles: + index = self.indexFromNode(node) + if child := self.removeChild(index.parent(), index.row()): + self.insertChild(child, target, -1) + return + ## # Other Methods ## diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 7637893a..0b2d93b9 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -162,19 +162,20 @@ class GuiMainMenu(QMenuBar): self.projMenu.addSeparator() # Project > Edit - self.aEditItem = self.projMenu.addAction(self.tr("Rename Item")) - self.aEditItem.setShortcut("F2") - self.aEditItem.triggered.connect(qtLambda(self.mainGui.projView.renameTreeItem, None)) - self.mainGui.addAction(self.aEditItem) + self.aRenameItem = self.projMenu.addAction(self.tr("Rename Item")) + self.aRenameItem.setShortcut("F2") # Project > Delete self.aDeleteItem = self.projMenu.addAction(self.tr("Delete Item")) - self.aDeleteItem.setShortcut("Ctrl+Shift+Del") # Cannot be Ctrl+Del, see #629 - self.aDeleteItem.triggered.connect(qtLambda(self.mainGui.projView.requestDeleteItem, None)) + self.aDeleteItem.setShortcut("Del") + self.aDeleteItem.setShortcutContext(Qt.ShortcutContext.WidgetShortcut) # Project > Empty Trash self.aEmptyTrash = self.projMenu.addAction(self.tr("Empty Trash")) - self.aEmptyTrash.triggered.connect(qtLambda(self.mainGui.projView.emptyTrash)) + + self.mainGui.projView.connectMenuActions( + self.aRenameItem, self.aDeleteItem, self.aEmptyTrash + ) # Project > Separator self.projMenu.addSeparator() diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 637703bd..0b74a983 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -131,8 +131,6 @@ class GuiProjectView(QWidget): self.projBar.newDocumentFromTemplate.connect(self.createFileFromTemplate) # Function Mappings - self.emptyTrash = self.projTree.emptyTrash - self.requestDeleteItem = self.projTree.requestDeleteItem self.getSelectedHandle = self.projTree.getSelectedHandle return @@ -185,10 +183,20 @@ class GuiProjectView(QWidget): """Check if the project tree has focus.""" return self.projTree.hasFocus() + def connectMenuActions(self, rename: QAction, delete: QAction, trash: QAction) -> None: + """Main menu actions passed to the project tree.""" + self.projTree.addAction(rename) + self.projTree.addAction(delete) + self.projTree.addAction(trash) + rename.triggered.connect(self.renameTreeItem) + delete.triggered.connect(self.projTree.moveItemsToTrash) + return + ## # Public Slots ## + @pyqtSlot() @pyqtSlot(str, str) def renameTreeItem(self, tHandle: str | None = None, name: str = "") -> None: """External request to rename an item or the currently selected @@ -806,6 +814,20 @@ class GuiProjectTree(QTreeView): self.setCurrentIndex(model.indexFromNode(child)) return + @pyqtSlot() + def moveItemsToTrash(self, askFirst: bool = True) -> None: + """Move selected items to Trash.""" + if ( + (items := self._selectedRows()) + and (model := self._getModel()) + and (trashNode := SHARED.project.tree.trash) + ): + if askFirst and not SHARED.question(self.tr("Move selected items to Trash?")): + logger.info("Action cancelled by user") + return + model.multiMove(items, model.indexFromNode(trashNode)) + return + ## # Private Slots ## @@ -835,6 +857,10 @@ class GuiProjectTree(QTreeView): # Internal Functions ## + def _selectedRows(self) -> list[QModelIndex]: + """Return all column 0 indexes.""" + return [i for i in self.selectedIndexes() if i.column() == 0] + def _getModel(self) -> ProjectModel | None: """Return a project node corresponding to a model index.""" if isinstance(model := self.model(), ProjectModel): @@ -943,54 +969,6 @@ class GuiProjectTree(QTreeView): return True - def moveItemToTrash(self, tHandle: str, askFirst: bool = True, flush: bool = True) -> bool: - """Move an item to Trash. Root folders cannot be moved to Trash, - so such a request is cancelled. - """ - # trItemS = self._getTreeItem(tHandle) - # nwItemS = SHARED.project.tree[tHandle] - - # if trItemS is None or nwItemS is None: - # logger.error("Could not find tree item for deletion") - # return False - - # if SHARED.project.tree.isTrash(tHandle): - # logger.error("Item is already in the Trash folder") - # return False - - # if nwItemS.isRootType(): - # logger.error("Root folders cannot be moved to Trash") - # return False - - # logger.debug("User requested file or folder '%s' move to Trash", tHandle) - - # trItemP = trItemS.parent() - # trItemT = self._addTrashRoot() - # if trItemP is None or trItemT is None: - # logger.error("Could not delete item") - # return False - - # if askFirst: - # msgYes = SHARED.question( - # self.tr("Move '{0}' to Trash?").format(nwItemS.itemName) - # ) - # if not msgYes: - # logger.info("Action cancelled by user") - # return False - - # self.propagateCount(tHandle, 0) - - # tIndex = trItemP.indexOfChild(trItemS) - # trItemC = trItemP.takeChild(tIndex) - # trItemT.addChild(trItemC) - - # self._postItemMove(tHandle) - # self._alertTreeChange(tHandle, flush=flush) - - # logger.debug("Moved item '%s' to Trash", tHandle) - - return True - def permDeleteItem(self, tHandle: str, askFirst: bool = True, flush: bool = True) -> bool: """Permanently delete a tree item from the project and the map. Root items are handled a little different than other items.