diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py index dd14c46b..d7e5f987 100644 --- a/novelwriter/core/itemmodel.py +++ b/novelwriter/core/itemmodel.py @@ -69,6 +69,14 @@ class ProjectNode: self.refresh() return + def __repr__(self) -> str: + return ( + f"" + ) + ## # Properties ## @@ -81,6 +89,10 @@ class ProjectNode: def children(self) -> list[ProjectNode]: return self._children + ## + # Data Maintenance + ## + def refresh(self) -> None: cache: dict[int, str | QIcon | Qt.AlignmentFlag] = {} @@ -121,6 +133,10 @@ class ProjectNode: parent.updateCount() return + ## + # Data Access + ## + def row(self) -> int: return self._row @@ -139,6 +155,15 @@ class ProjectNode: return self._children[row] return None + def allChildren(self) -> list[ProjectNode]: + nodes: list[ProjectNode] = [] + self._recursiveAppendChildren(nodes) + return nodes + + ## + # Data Edit + ## + def addChild(self, child: ProjectNode) -> None: child._parent = self child._row = len(self._children) @@ -146,10 +171,18 @@ class ProjectNode: self.refresh() return - def allChildren(self) -> list[ProjectNode]: - nodes: list[ProjectNode] = [] - self._recursiveAppendChildren(nodes) - return nodes + def moveChild(self, source: int, step: int) -> int: + """Move a child internally.""" + count = len(self._children) + if 0 <= source < count: + target = max(min(source + step, count - 1), 0) + if source != target: + node = self._children.pop(source) + self._children.insert(target, node) + for n, child in enumerate(self._children): + child._row = n + return target + 1 if target > source else target + return -1 ## # Internal Functions @@ -167,10 +200,10 @@ class ProjectModel(QAbstractItemModel): __slots__ = ("_tree", "_root") def __init__(self, tree: NWTree) -> None: - super().__init__(None) - logger.debug("Create: ProjectModel") + super().__init__() self._tree = tree self._root = ProjectNode(NWItem(tree._project, "invisibleRoot")) + logger.debug("Ready: ProjectModel") return def __del__(self) -> None: @@ -254,7 +287,26 @@ class ProjectModel(QAbstractItemModel): return self.createIndex(node.row(), 0, node) ## - # Methods + # Model Edit + ## + + def internalMove(self, index: QModelIndex, step: int) -> None: + """Move an item internally among its siblings.""" + if index.isValid(): + node: ProjectNode = index.internalPointer() + if parent := node.parent(): + pos = index.row() + if (new := parent.moveChild(index.row(), step)) > -1: + self.beginMoveRows(index.parent(), pos, pos, index.parent(), new) + self.endMoveRows() + return + + def moveRows(self, indices: list[QModelIndex], destination: QModelIndex, row: int) -> bool: + """Move indices to destination.""" + return False + + ## + # Other Methods ## def allExpanded(self) -> list[QModelIndex]: diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 728a3000..d0b1ad42 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -75,6 +75,11 @@ class NWTree: self._nodes: dict[str, ProjectNode] = {} self._trash = None # The handle of the trash root folder self._changed = False # True if tree structure has changed + logger.debug("Ready: NWTree") + return + + def __del__(self) -> None: + logger.debug("Delete: NWTree") return ## @@ -100,11 +105,14 @@ class NWTree: def clear(self) -> None: """Clear the item tree entirely.""" + oldModel = self._model self._model = ProjectModel(self) self._items = {} self._nodes = {} self._trash = None self._changed = False + oldModel.deleteLater() + del oldModel return def handles(self) -> list[str]: diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index afecb30a..7637893a 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -28,7 +28,7 @@ import logging from pathlib import Path from typing import TYPE_CHECKING -from PyQt5.QtCore import pyqtSignal, pyqtSlot +from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import QAction, QMenuBar from novelwriter import CONFIG, SHARED @@ -337,12 +337,16 @@ class GuiMainMenu(QMenuBar): # View > Go Backward self.aViewPrev = self.viewMenu.addAction(self.tr("Navigate Backward")) self.aViewPrev.setShortcut("Alt+Left") + self.aViewPrev.setShortcutContext(Qt.ShortcutContext.WidgetShortcut) self.aViewPrev.triggered.connect(self.mainGui.docViewer.navBackward) + self.mainGui.docViewer.addAction(self.aViewPrev) # View > Go Forward self.aViewNext = self.viewMenu.addAction(self.tr("Navigate Forward")) self.aViewNext.setShortcut("Alt+Right") + self.aViewNext.setShortcutContext(Qt.ShortcutContext.WidgetShortcut) self.aViewNext.triggered.connect(self.mainGui.docViewer.navForward) + self.mainGui.docViewer.addAction(self.aViewNext) # View > Separator self.viewMenu.addSeparator() diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 7e2643a7..10b37618 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -31,7 +31,7 @@ import logging from enum import Enum from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot -from PyQt5.QtGui import QIcon, QPalette +from PyQt5.QtGui import QIcon, QMouseEvent, QPalette from PyQt5.QtWidgets import ( QAbstractItemView, QAction, QFrame, QHBoxLayout, QHeaderView, QLabel, QMenu, QShortcut, QTreeView, QTreeWidgetItem, QVBoxLayout, QWidget @@ -47,7 +47,10 @@ from novelwriter.dialogs.projectsettings import GuiProjectSettings from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType from novelwriter.extensions.modified import NIconToolButton from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON -from novelwriter.types import QtScrollAlwaysOff, QtScrollAsNeeded, QtSizeExpanding +from novelwriter.types import ( + QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded, + QtSizeExpanding +) logger = logging.getLogger(__name__) @@ -91,32 +94,32 @@ class GuiProjectView(QWidget): self.keyMoveUp = QShortcut(self.projTree) self.keyMoveUp.setKey("Ctrl+Up") self.keyMoveUp.setContext(Qt.ShortcutContext.WidgetShortcut) - self.keyMoveUp.activated.connect(qtLambda(self.projTree.moveTreeItem, -1)) + self.keyMoveUp.activated.connect(self.projTree.moveItemUp) self.keyMoveDn = QShortcut(self.projTree) self.keyMoveDn.setKey("Ctrl+Down") self.keyMoveDn.setContext(Qt.ShortcutContext.WidgetShortcut) - self.keyMoveDn.activated.connect(qtLambda(self.projTree.moveTreeItem, 1)) + self.keyMoveDn.activated.connect(self.projTree.moveItemDown) self.keyGoPrev = QShortcut(self.projTree) self.keyGoPrev.setKey("Alt+Up") self.keyGoPrev.setContext(Qt.ShortcutContext.WidgetShortcut) - self.keyGoPrev.activated.connect(qtLambda(self.projTree.moveToNextItem, -1)) + self.keyGoPrev.activated.connect(self.projTree.moveSiblingUp) self.keyGoNext = QShortcut(self.projTree) self.keyGoNext.setKey("Alt+Down") self.keyGoNext.setContext(Qt.ShortcutContext.WidgetShortcut) - self.keyGoNext.activated.connect(qtLambda(self.projTree.moveToNextItem, 1)) + self.keyGoNext.activated.connect(self.projTree.moveSiblingDown) self.keyGoUp = QShortcut(self.projTree) self.keyGoUp.setKey("Alt+Left") self.keyGoUp.setContext(Qt.ShortcutContext.WidgetShortcut) - self.keyGoUp.activated.connect(qtLambda(self.projTree.moveToLevel, -1)) + self.keyGoUp.activated.connect(self.projTree.moveToParent) self.keyGoDown = QShortcut(self.projTree) self.keyGoDown.setKey("Alt+Right") self.keyGoDown.setContext(Qt.ShortcutContext.WidgetShortcut) - self.keyGoDown.activated.connect(qtLambda(self.projTree.moveToLevel, 1)) + self.keyGoDown.activated.connect(self.projTree.moveToFirstChild) self.keyContext = QShortcut(self.projTree) self.keyContext.setKey("Ctrl+.") @@ -288,11 +291,11 @@ class GuiProjectToolBar(QWidget): # Move Buttons self.tbMoveU = NIconToolButton(self, iSz) self.tbMoveU.setToolTip("%s [Ctrl+Up]" % self.tr("Move Up")) - self.tbMoveU.clicked.connect(qtLambda(self.projTree.moveTreeItem, -1)) + self.tbMoveU.clicked.connect(self.projTree.moveItemUp) self.tbMoveD = NIconToolButton(self, iSz) self.tbMoveD.setToolTip("%s [Ctrl+Down]" % self.tr("Move Down")) - self.tbMoveD.clicked.connect(qtLambda(self.projTree.moveTreeItem, 1)) + self.tbMoveD.clicked.connect(self.projTree.moveItemDown) # Add Item Menu self.mAdd = QMenu(self) @@ -616,7 +619,11 @@ class GuiProjectTree(QTreeView): def loadModel(self) -> None: """Load and prepare a new project model.""" + selModel = self.selectionModel() self.setModel(SHARED.project.tree.model) + if selModel: + selModel.deleteLater() + del selModel # Lock the column sizes iPx = SHARED.theme.baseIconHeight @@ -649,9 +656,81 @@ class GuiProjectTree(QTreeView): return ## - # Private Slots + # Events ## + def mousePressEvent(self, event: QMouseEvent) -> None: + """Overload mousePressEvent to clear selection if clicking the + mouse in a blank area of the tree view, and to load a document + for viewing if the user middle-clicked. + """ + super().mousePressEvent(event) + if event.button() == QtMouseLeft: + if not self.indexAt(event.pos()).isValid(): + self.selectionModel().clearCurrentIndex() + elif event.button() == QtMouseMiddle: + if (node := self._getNode(self.indexAt(event.pos()))) and node.item.isFileType(): + self.projView.openDocumentRequest.emit( + node.item.itemHandle, nwDocMode.VIEW, "", False + ) + return + + ## + # Public Slots + ## + + @pyqtSlot() + def moveItemUp(self) -> None: + """Move an item up in the tree.""" + if model := self._getModel(): + model.internalMove(self.currentIndex(), -1) + return + + @pyqtSlot() + def moveItemDown(self) -> None: + """Move an item down in the tree.""" + if model := self._getModel(): + model.internalMove(self.currentIndex(), 1) + return + + @pyqtSlot() + def moveSiblingUp(self) -> None: + """Skip to the previous sibling.""" + if (node := self._getNode(self.currentIndex())) and (parent := node.parent()): + if (move := parent.child(node.row() - 1)) and (model := self._getModel()): + self.setCurrentIndex(model.indexFromNode(move)) + return + + @pyqtSlot() + def moveSiblingDown(self) -> None: + """Skip to the next sibling.""" + if (node := self._getNode(self.currentIndex())) and (parent := node.parent()): + if (move := parent.child(node.row() + 1)) and (model := self._getModel()): + self.setCurrentIndex(model.indexFromNode(move)) + return + + @pyqtSlot() + def moveToParent(self) -> None: + """Move to parent item.""" + if ( + (model := self._getModel()) + and (node := model.node(self.currentIndex())) + and (parent := node.parent()) + ): + self.setCurrentIndex(model.indexFromNode(parent)) + return + + @pyqtSlot() + def moveToFirstChild(self) -> None: + """Move to first child item.""" + if ( + (model := self._getModel()) + and (node := model.node(self.currentIndex())) + and (child := node.child(0)) + ): + self.setCurrentIndex(model.indexFromNode(child)) + return + ## # Private Slots ## @@ -838,80 +917,6 @@ class GuiProjectTree(QTreeView): return True - def moveTreeItem(self, step: int) -> bool: - """Move an item up or down in the tree.""" - # tHandle = self.getSelectedHandle() - # tItem = self._getTreeItem(tHandle) - # if tItem is None: - # logger.debug("No item selected") - # return False - - # pItem = tItem.parent() - # isExp = tItem.isExpanded() - # if pItem is None: - # tIndex = self.indexOfTopLevelItem(tItem) - # nChild = self.topLevelItemCount() - - # nIndex = tIndex + step - # if nIndex < 0 or nIndex >= nChild: - # return False - - # cItem = self.takeTopLevelItem(tIndex) - # self.insertTopLevelItem(nIndex, cItem) - - # else: - # tIndex = pItem.indexOfChild(tItem) - # nChild = pItem.childCount() - - # nIndex = tIndex + step - # if nIndex < 0 or nIndex >= nChild: - # return False - - # cItem = pItem.takeChild(tIndex) - # pItem.insertChild(nIndex, cItem) - - # self._alertTreeChange(tHandle, flush=True) - # self.setCurrentItem(tItem) - # tItem.setExpanded(isExp) - - return True - - def moveToNextItem(self, step: int) -> None: - """Move to the next item of the same tree level.""" - # tHandle = self.getSelectedHandle() - # tItem = self._getTreeItem(tHandle) if tHandle else None - # if tItem: - # pItem = tItem.parent() or self.invisibleRootItem() - # next = minmax(pItem.indexOfChild(tItem) + step, 0, pItem.childCount() - 1) - # self.setCurrentItem(pItem.child(next)) - return - - def moveToLevel(self, step: int) -> None: - """Move to the next item in the parent/child chain.""" - # tHandle = self.getSelectedHandle() - # tItem = self._getTreeItem(tHandle) if tHandle else None - # if tItem: - # if step < 0 and tItem.parent(): - # self.setCurrentItem(tItem.parent()) - # elif step > 0 and tItem.childCount() > 0: - # self.setCurrentItem(tItem.child(0)) - return - - # def saveTreeOrder(self) -> None: - # """Build a list of the items in the project tree and send them - # to the project class. This syncs up the two versions of the - # project structure, and must be called before any code that - # depends on this order to be up to date. - # """ - # # items = [] - # # for i in range(self.topLevelItemCount()): - # # item = self.topLevelItem(i) - # # if isinstance(item, QTreeWidgetItem): - # # items = self._scanChildren(items, item, i) - # # logger.debug("Saving project tree item order") - # # SHARED.project.setTreeOrder(items) - # return - def getTreeFromHandle(self, tHandle: str) -> list[str]: """Recursively return all the child items starting from a given item handle. @@ -1455,22 +1460,6 @@ class GuiProjectTree(QTreeView): return - def _getTreeItem(self, tHandle: str | None) -> QTreeWidgetItem | None: - """Return the QTreeWidgetItem of a given item handle.""" - # return self._treeMap.get(tHandle, None) if tHandle else None - return None - - def _recursiveSetExpanded(self, trItem: QTreeWidgetItem, isExpanded: bool) -> None: - """Recursive function to set expanded status starting from (and - not including) a given item. - """ - # if isinstance(trItem, QTreeWidgetItem): - # for i in range(trItem.childCount()): - # chItem = trItem.child(i) - # chItem.setExpanded(isExpanded) - # self._recursiveSetExpanded(chItem, isExpanded) - return - def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool: """Merge an item's child documents into a single document.""" # logger.info("Request to merge items under handle '%s'", tHandle)