From bec414a405af3d0f2fa64ddd77f3b274f4dae77b Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 18 Apr 2025 17:38:55 +0200 Subject: [PATCH 1/4] Move the decision logic of where to put new items to the NWTree class and improve (#2260) --- novelwriter/core/tree.py | 25 ++++++++++++- novelwriter/gui/projtree.py | 74 +++++++++++++++++++------------------ 2 files changed, 62 insertions(+), 37 deletions(-) diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 2ff74a53..9ee92eb9 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -33,7 +33,7 @@ from typing import TYPE_CHECKING, Literal, overload from PyQt6.QtCore import QModelIndex from novelwriter import SHARED -from novelwriter.constants import nwFiles, nwLabels, trConst +from novelwriter.constants import nwFiles, nwLabels, nwStyles, trConst from novelwriter.core.item import NWItem from novelwriter.core.itemmodel import ProjectModel, ProjectNode from novelwriter.enum import nwChange, nwItemClass, nwItemLayout, nwItemType @@ -262,6 +262,29 @@ class NWTree: return + def pickParent(self, sNode: ProjectNode, hLevel: int, isNote: bool) -> tuple[str | None, int]: + """Pick an appropriate parent handle for adding a new item.""" + if sNode.item.isFolderType() or sNode.item.isRootType(): + # Always add as a direct child of folders + return sNode.item.itemHandle, sNode.childCount() + + pNode = sNode.parent() + pLevel = nwStyles.H_LEVEL.get(pNode.item.mainHeading, 0) if pNode else 0 + sLevel = 0 if isNote else nwStyles.H_LEVEL.get(sNode.item.mainHeading, 0) + + if pNode and pNode.item.isFileType() and pLevel == hLevel and sLevel > hLevel: + # If the selected item is deeper level, but the parent is a document + # of the same level, we make it a sibling of the parent (See #2260) + return pNode.item.itemParent, pNode.row() + 1 + + if sNode.childCount() > 0 and (sLevel < hLevel or isNote): + # If the item already has child nodes and is of a lower level + # or is a note, we make the new item a child + return sNode.item.itemHandle, sNode.childCount() + + # The default behaviour is to make the new item a sibling + return sNode.item.itemParent, sNode.row() + 1 + def refreshItems(self, items: list[str]) -> None: """Refresh these items on the GUI. If they are an ordered range, also set the isRange flag to True. diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index f8b13e9f..8cbe502e 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -275,9 +275,9 @@ class GuiProjectToolBar(QWidget): # Add Item Menu self.mAdd = QMenu(self) - self.aAddEmpty = qtAddAction(self.mAdd, trConst(nwLabels.ITEM_DESCRIPTION["document"])) - self.aAddEmpty.triggered.connect( - qtLambda(self.projTree.newTreeItem, nwItemType.FILE, hLevel=0, isNote=False) + self.aAddScene = qtAddAction(self.mAdd, trConst(nwLabels.ITEM_DESCRIPTION["doc_h3"])) + self.aAddScene.triggered.connect( + qtLambda(self.projTree.newTreeItem, nwItemType.FILE, hLevel=3, isNote=False) ) self.aAddChap = qtAddAction(self.mAdd, trConst(nwLabels.ITEM_DESCRIPTION["doc_h2"])) @@ -285,9 +285,14 @@ class GuiProjectToolBar(QWidget): qtLambda(self.projTree.newTreeItem, nwItemType.FILE, hLevel=2, isNote=False) ) - self.aAddScene = qtAddAction(self.mAdd, trConst(nwLabels.ITEM_DESCRIPTION["doc_h3"])) - self.aAddScene.triggered.connect( - qtLambda(self.projTree.newTreeItem, nwItemType.FILE, hLevel=3, isNote=False) + self.aAddPart = qtAddAction(self.mAdd, trConst(nwLabels.ITEM_DESCRIPTION["doc_h1"])) + self.aAddPart.triggered.connect( + qtLambda(self.projTree.newTreeItem, nwItemType.FILE, hLevel=1, isNote=False) + ) + + self.aAddEmpty = qtAddAction(self.mAdd, trConst(nwLabels.ITEM_DESCRIPTION["document"])) + self.aAddEmpty.triggered.connect( + qtLambda(self.projTree.newTreeItem, nwItemType.FILE, hLevel=0, isNote=False) ) self.aAddNote = qtAddAction(self.mAdd, trConst(nwLabels.ITEM_DESCRIPTION["note"])) @@ -366,9 +371,10 @@ class GuiProjectToolBar(QWidget): self.tbAdd.setThemeIcon("add", "green") self.tbMore.setThemeIcon("more_vertical") - self.aAddEmpty.setIcon(SHARED.theme.getIcon("prj_document", "file")) - self.aAddChap.setIcon(SHARED.theme.getIcon("prj_chapter", "chapter")) self.aAddScene.setIcon(SHARED.theme.getIcon("prj_scene", "scene")) + self.aAddChap.setIcon(SHARED.theme.getIcon("prj_chapter", "chapter")) + self.aAddPart.setIcon(SHARED.theme.getIcon("prj_title", "title")) + self.aAddEmpty.setIcon(SHARED.theme.getIcon("prj_document", "file")) self.aAddNote.setIcon(SHARED.theme.getIcon("prj_note", "note")) self.aAddFolder.setIcon(SHARED.theme.getIcon("prj_folder", "folder")) @@ -425,9 +431,10 @@ class GuiProjectToolBar(QWidget): """ nwItem = SHARED.project.tree[tHandle] allowDoc = isinstance(nwItem, NWItem) and nwItem.documentAllowed() - self.aAddEmpty.setVisible(allowDoc) - self.aAddChap.setVisible(allowDoc) self.aAddScene.setVisible(allowDoc) + self.aAddChap.setVisible(allowDoc) + self.aAddPart.setVisible(allowDoc) + self.aAddEmpty.setVisible(allowDoc) return ## @@ -606,12 +613,12 @@ class GuiProjectTree(QTreeView): tHandle = None if itemType == nwItemType.ROOT and isinstance(itemClass, nwItemClass): - pos = -1 + sPos = -1 if (node := self._getNode(self.currentIndex())) and (itemRoot := node.item.itemRoot): if root := SHARED.project.tree.nodes.get(itemRoot): - pos = root.row() + 1 + sPos = root.row() + 1 - tHandle = SHARED.project.newRoot(itemClass, pos) + tHandle = SHARED.project.newRoot(itemClass, sPos) self.restoreExpandedState() elif itemType in (nwItemType.FILE, nwItemType.FOLDER): @@ -624,52 +631,46 @@ class GuiProjectTree(QTreeView): SHARED.error(self.tr("Cannot add new files or folders to the Trash folder.")) return - # Collect some information about the selected item - sLevel = nwStyles.H_LEVEL.get(node.item.mainHeading, 0) - sIsParent = node.childCount() > 0 - - # Set default label and determine if new item is to be added - # as child or sibling to the selected item + # Set default label and determine where to put the new item + nNote = isNote + nLevel = hLevel if itemType == nwItemType.FILE: if copyDoc and (cItem := SHARED.project.tree[copyDoc]): + nNote = cItem.isNoteLayout() + nLevel = nwStyles.H_LEVEL.get(cItem.mainHeading, 0) newLabel = cItem.itemName - asChild = sIsParent and node.item.isDocumentLayout() elif isNote: newLabel = self.tr("New Note") - asChild = sIsParent + elif hLevel == 1: + newLabel = self.tr("New Part") elif hLevel == 2: newLabel = self.tr("New Chapter") - asChild = sIsParent and node.item.isDocumentLayout() and sLevel < 2 elif hLevel == 3: newLabel = self.tr("New Scene") - asChild = sIsParent and node.item.isDocumentLayout() and sLevel < 3 else: newLabel = self.tr("New Document") - asChild = sIsParent and node.item.isDocumentLayout() else: newLabel = self.tr("New Folder") - asChild = False + nLevel = 0 - pos = -1 - sHandle = None - if not (asChild or node.item.isFolderType() or node.item.isRootType()): - pos = node.row() + 1 - sHandle = node.item.itemParent + sHandle, sPos = SHARED.project.tree.pickParent(node, nLevel, nNote) + if not sHandle: + SHARED.error(self.tr("Did not find anywhere to add the file or folder!")) + return - sHandle = sHandle or node.item.itemHandle newLabel, dlgOk = GuiEditLabel.getLabel(self, text=newLabel) if dlgOk: # Add the file or folder if itemType == nwItemType.FILE: - if tHandle := SHARED.project.newFile(newLabel, sHandle, pos): + if tHandle := SHARED.project.newFile(newLabel, sHandle, sPos): if copyDoc: SHARED.project.copyFileContent(tHandle, copyDoc) elif hLevel > 0: - SHARED.project.writeNewFile(tHandle, hLevel, not isNote) + SHARED.project.writeNewFile(tHandle, hLevel, not nNote) SHARED.project.index.reIndexHandle(tHandle) SHARED.project.tree.refreshItems([tHandle]) else: - tHandle = SHARED.project.newFolder(newLabel, sHandle, pos) + tHandle = SHARED.project.newFolder(newLabel, sHandle, sPos) # Select the new item automatically if tHandle: @@ -1198,9 +1199,10 @@ class _TreeContextMenu(QMenu): def _itemCreation(self) -> None: """Add create item actions.""" menu = qtAddMenu(self, self.tr("Create New ...")) - menu.addAction(self._view.projBar.aAddEmpty) - menu.addAction(self._view.projBar.aAddChap) menu.addAction(self._view.projBar.aAddScene) + menu.addAction(self._view.projBar.aAddChap) + menu.addAction(self._view.projBar.aAddPart) + menu.addAction(self._view.projBar.aAddEmpty) menu.addAction(self._view.projBar.aAddNote) menu.addAction(self._view.projBar.aAddFolder) return From b60ce68fcd1ed0bf5891b6c9c0ede6a0a8c42ee5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 18 Apr 2025 18:20:13 +0200 Subject: [PATCH 2/4] Also record keyboard selections as a selection change --- novelwriter/gui/projtree.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 8cbe502e..38279808 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -510,7 +510,6 @@ class GuiProjectTree(QTreeView): self.customContextMenuRequested.connect(self.openContextMenu) # Connect signals - self.clicked.connect(self._onSingleClick) self.doubleClicked.connect(self._onDoubleClick) self.collapsed.connect(self._onNodeCollapsed) self.expanded.connect(self._onNodeExpanded) @@ -560,6 +559,9 @@ class GuiProjectTree(QTreeView): def loadModel(self) -> None: """Load and prepare a new project model.""" + if selectModelOld := self.selectionModel(): + selectModelOld.disconnect() + self.setModel(SHARED.project.tree.model) # Lock the column sizes @@ -575,6 +577,9 @@ class GuiProjectTree(QTreeView): header.resizeSection(ProjectNode.C_ACTIVE, iPx + 6) header.resizeSection(ProjectNode.C_STATUS, iPx + 6) + if selectModelNew := self.selectionModel(): + selectModelNew.currentChanged.connect(self._onSelectionChange) + self.restoreExpandedState() return @@ -970,10 +975,10 @@ class GuiProjectTree(QTreeView): # Private Slots ## - @pyqtSlot(QModelIndex) - def _onSingleClick(self, index: QModelIndex) -> None: + @pyqtSlot(QModelIndex, QModelIndex) + def _onSelectionChange(self, current: QModelIndex, previous: QModelIndex) -> None: """The user changed which item is selected.""" - if node := self._getNode(index): + if node := self._getNode(current): self.projView.selectedItemChanged.emit(node.item.itemHandle) return From b75ec3677c86564f208263afc7bce3006ea9b019 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 18 Apr 2025 19:20:56 +0200 Subject: [PATCH 3/4] Fix tests and update coverage --- novelwriter/gui/projtree.py | 31 ++++---- tests/test_core/test_core_tree.py | 108 ++++++++++++++++++++++++++++ tests/test_gui/test_gui_projtree.py | 83 +++++++++++++-------- 3 files changed, 174 insertions(+), 48 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 38279808..11c8b909 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -659,23 +659,20 @@ class GuiProjectTree(QTreeView): nLevel = 0 sHandle, sPos = SHARED.project.tree.pickParent(node, nLevel, nNote) - if not sHandle: - SHARED.error(self.tr("Did not find anywhere to add the file or folder!")) - return - - newLabel, dlgOk = GuiEditLabel.getLabel(self, text=newLabel) - if dlgOk: - # Add the file or folder - if itemType == nwItemType.FILE: - if tHandle := SHARED.project.newFile(newLabel, sHandle, sPos): - if copyDoc: - SHARED.project.copyFileContent(tHandle, copyDoc) - elif hLevel > 0: - SHARED.project.writeNewFile(tHandle, hLevel, not nNote) - SHARED.project.index.reIndexHandle(tHandle) - SHARED.project.tree.refreshItems([tHandle]) - else: - tHandle = SHARED.project.newFolder(newLabel, sHandle, sPos) + if sHandle: + newLabel, dlgOk = GuiEditLabel.getLabel(self, text=newLabel) + if dlgOk: + # Add the file or folder + if itemType == nwItemType.FILE: + if tHandle := SHARED.project.newFile(newLabel, sHandle, sPos): + if copyDoc: + SHARED.project.copyFileContent(tHandle, copyDoc) + elif hLevel > 0: + SHARED.project.writeNewFile(tHandle, hLevel, not nNote) + SHARED.project.index.reIndexHandle(tHandle) + SHARED.project.tree.refreshItems([tHandle]) + else: + tHandle = SHARED.project.newFolder(newLabel, sHandle, sPos) # Select the new item automatically if tHandle: diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index b6451771..b33d6dd7 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -211,6 +211,114 @@ def testCoreTree_ManipulateTree(mockGUI, mockItems): ] +@pytest.mark.core +def testCoreTree_PickParent(mockGUI, mockItems): + """Check the parent item picker.""" + project = NWProject() + tree = NWTree(project) + assert len(tree) == 0 + + # Case 1: Root and Folder + # ======================= + + hNovelRoot = tree.create("Novel", None, nwItemType.ROOT, nwItemClass.NOVEL) + nNovelRoot = tree.nodes[hNovelRoot] + + # Add a folder + sHandle, sPos = tree.pickParent(nNovelRoot, 0, False) + assert sHandle == hNovelRoot + assert sPos == 0 + hNovelFolder = tree.create("Folder", sHandle, nwItemType.FOLDER, pos=sPos) + assert hNovelFolder is not None + nNovelFolder = tree.nodes[hNovelFolder] + assert nNovelFolder.item.itemClass == nwItemClass.NOVEL + + # Add a partition + sHandle, sPos = tree.pickParent(nNovelRoot, 1, False) + assert sHandle == hNovelRoot + assert sPos == 1 + hPartOne = tree.create("Part One", sHandle, nwItemType.FILE, pos=sPos) + assert hPartOne is not None + nPartOne = tree.nodes[hPartOne] + assert nPartOne.item.itemClass == nwItemClass.NOVEL + nPartOne.item.setMainHeading("H1") + + # Folders behave identical to root + sHandle, sPos = tree.pickParent(nNovelFolder, 0, False) + assert sHandle == hNovelFolder + assert sPos == 0 + + # Case 2: Documents of Same Level + # =============================== + + # Add a chapter under Part One + hChapterOne = tree.create("Chapter One", hPartOne, nwItemType.FILE, pos=sPos) + assert hChapterOne is not None + nChapterOne = tree.nodes[hChapterOne] + assert nChapterOne.item.itemParent == hPartOne + nChapterOne.item.setMainHeading("H2") + + # Add a chapter next to Chapter One -> Sibling (default behaviour) + sHandle, sPos = tree.pickParent(nChapterOne, 2, False) + assert sHandle == hPartOne + assert sPos == 1 + hChapterTwo = tree.create("Chapter Two", sHandle, nwItemType.FILE, pos=sPos) + assert hChapterTwo is not None + nChapterTwo = tree.nodes[hChapterTwo] + assert nChapterTwo.item.itemClass == nwItemClass.NOVEL + nChapterTwo.item.setMainHeading("H2") + + # Add a new part next to Chapter Two -> Sibling to parent (second if condition) + sHandle, sPos = tree.pickParent(nChapterTwo, 1, False) + assert sHandle == hNovelRoot + assert sPos == 2 + + # Case 3: Documents of Deeper Level + # ================================= + + # Add chapter directly to Part One with existing chapters -> Added as child (third if) + sHandle, sPos = tree.pickParent(nPartOne, 2, False) + assert sHandle == hPartOne + assert sPos == 2 + + # But adding a new part becomes a sibling + sHandle, sPos = tree.pickParent(nPartOne, 1, False) + assert sHandle == hNovelRoot + assert sPos == 2 + + # Case 4: Notes + # ============= + + hCharRoot = tree.create("Characters", None, nwItemType.ROOT, nwItemClass.CHARACTER) + nCharRoot = tree.nodes[hCharRoot] + + # Add a note at root level + hNoteOne = tree.create("Note One", hCharRoot, nwItemType.FILE, pos=sPos) + assert hNoteOne is not None + nNoteOne = tree.nodes[hNoteOne] + assert nNoteOne.item.itemClass == nwItemClass.CHARACTER + nNoteOne.item.setMainHeading("H1") + assert nCharRoot.childCount() == 1 + + # Adding a new note is a sibling + sHandle, sPos = tree.pickParent(nNoteOne, 1, True) + assert sHandle == hCharRoot + assert sPos == 1 + + # Add a child note to Note One + hNoteTwo = tree.create("Note Two", hNoteOne, nwItemType.FILE, pos=sPos) + assert hNoteTwo is not None + nNoteTwo = tree.nodes[hNoteTwo] + assert nNoteTwo.item.itemClass == nwItemClass.CHARACTER + nNoteTwo.item.setMainHeading("H1") + assert nNoteOne.childCount() == 1 + + # Adding a new note now is a child + sHandle, sPos = tree.pickParent(nNoteOne, 1, True) + assert sHandle == hNoteOne + assert sPos == 1 + + @pytest.mark.core def testCoreTree_ItemMethods(monkeypatch, mockGUI, mockItems): """Check the item methods of the tree.""" diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index fea6c31a..ae8e9af0 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -134,7 +134,7 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc # Add a new file in the new folder hNewFile = "0000000000013" projView.setSelectedHandle(hNewFolder, doScroll=True) - projTree.newTreeItem(nwItemType.FILE) + projTree.newTreeItem(nwItemType.FILE, hLevel=0) assert hNewFile in tree item = tree[hNewFile] assert item is not None @@ -148,9 +148,28 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc "Objects", "Trash", ] - # Add a new chapter next to the other new file - hNewChapter = "0000000000014" + # Add a new partition next to the other new file + hNewPart = "0000000000014" projView.setSelectedHandle(hNewFile, doScroll=True) + projTree.newTreeItem(nwItemType.FILE, hLevel=1) + assert hNewPart in tree + item = tree[hNewPart] + assert item is not None + assert item.itemName == "New Part" + assert item.itemParent == hNewFolder + assert item.itemRoot == C.hNovelRoot + assert item.itemClass == nwItemClass.NOVEL + assert nwGUI.openDocument(hNewPart) + assert nwGUI.docEditor.getText() == "# New Part\n\n" + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "New Folder", "New Document", "New Part", "Plot", "Characters", + "Locations", "Objects", "Trash", + ] + + # Add a new chapter next to the other new file + hNewChapter = "0000000000015" + projView.setSelectedHandle(hNewPart, doScroll=True) projTree.newTreeItem(nwItemType.FILE, hLevel=2) assert hNewChapter in tree item = tree[hNewChapter] @@ -163,12 +182,12 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc assert nwGUI.docEditor.getText() == "## New Chapter\n\n" assert [n.item.itemName for n in tree.model.root.allChildren()] == [ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", - "New Folder", "New Document", "New Chapter", "Plot", "Characters", - "Locations", "Objects", "Trash", + "New Folder", "New Document", "New Part", "New Chapter", "Plot", + "Characters", "Locations", "Objects", "Trash", ] # Add a new scene next to the other new file - hNewScene = "0000000000015" + hNewScene = "0000000000016" projView.setSelectedHandle(hNewChapter, doScroll=True) projTree.newTreeItem(nwItemType.FILE, hLevel=3) assert hNewScene in tree @@ -182,8 +201,8 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc assert nwGUI.docEditor.getText() == "### New Scene\n\n" assert [n.item.itemName for n in tree.model.root.allChildren()] == [ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", - "New Folder", "New Document", "New Chapter", "New Scene", "Plot", - "Characters", "Locations", "Objects", "Trash", + "New Folder", "New Document", "New Part", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Objects", "Trash", ] # Add a new scene with the content copied from the previous @@ -191,7 +210,7 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc nwGUI.docEditor.setPlainText("### New Scene\n\nWith Stuff\n\n") nwGUI.saveDocument() - hNewSceneCopy = "0000000000016" + hNewSceneCopy = "0000000000017" projView.setSelectedHandle(hNewScene, doScroll=True) projTree.newTreeItem(nwItemType.FILE, copyDoc=hNewScene) assert hNewSceneCopy in tree @@ -205,12 +224,12 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc assert nwGUI.docEditor.getText() == "### New Scene\n\nWith Stuff\n\n" assert [n.item.itemName for n in tree.model.root.allChildren()] == [ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", - "New Folder", "New Document", "New Chapter", "New Scene", "New Scene", - "Plot", "Characters", "Locations", "Objects", "Trash", + "New Folder", "New Document", "New Part", "New Chapter", "New Scene", + "New Scene", "Plot", "Characters", "Locations", "Objects", "Trash", ] # Add a new file to the characters folder - hNewCharacter = "0000000000017" + hNewCharacter = "0000000000018" projView.setSelectedHandle(C.hCharRoot, doScroll=True) projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) assert hNewCharacter in tree @@ -224,8 +243,9 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc assert nwGUI.docEditor.getText() == "# New Note\n\n" assert [n.item.itemName for n in tree.model.root.allChildren()] == [ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", - "New Folder", "New Document", "New Chapter", "New Scene", "New Scene", - "Plot", "Characters", "New Note", "Locations", "Objects", "Trash", + "New Folder", "New Document", "New Part", "New Chapter", "New Scene", + "New Scene", "Plot", "Characters", "New Note", "Locations", + "Objects", "Trash", ] # Cancel during creation @@ -235,15 +255,16 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc projTree.newTreeItem(nwItemType.FILE) assert [n.item.itemName for n in tree.model.root.allChildren()] == [ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", - "New Folder", "New Document", "New Chapter", "New Scene", "New Scene", - "Plot", "Characters", "New Note", "Locations", "Objects", "Trash", + "New Folder", "New Document", "New Part", "New Chapter", "New Scene", + "New Scene", "Plot", "Characters", "New Note", "Locations", + "Objects", "Trash", ] # From Template # ============= # Create template folder - hTemplateRoot = "0000000000018" + hTemplateRoot = "0000000000019" projView.setSelectedHandle(hObjectRoot) projTree.newTreeItem(nwItemType.ROOT, nwItemClass.TEMPLATE) assert hTemplateRoot in tree @@ -255,13 +276,13 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc assert item.itemClass == nwItemClass.TEMPLATE assert [n.item.itemName for n in tree.model.root.allChildren()] == [ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", - "New Folder", "New Document", "New Chapter", "New Scene", "New Scene", - "Plot", "Characters", "New Note", "Locations", "Objects", "Templates", - "Trash", + "New Folder", "New Document", "New Part", "New Chapter", "New Scene", + "New Scene", "Plot", "Characters", "New Note", "Locations", + "Objects", "Templates", "Trash", ] # Create scene template - hSceneTemplate = "0000000000019" + hSceneTemplate = "000000000001a" projView.setSelectedHandle(hTemplateRoot, doScroll=True) projTree.newTreeItem(nwItemType.FILE, hLevel=3) assert hSceneTemplate in tree @@ -278,13 +299,13 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc nwGUI.saveDocument() assert [n.item.itemName for n in tree.model.root.allChildren()] == [ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", - "New Folder", "New Document", "New Chapter", "New Scene", "New Scene", - "Plot", "Characters", "New Note", "Locations", "Objects", "Templates", - "New Scene Template", "Trash", + "New Folder", "New Document", "New Part", "New Chapter", "New Scene", + "New Scene", "Plot", "Characters", "New Note", "Locations", + "Objects", "Templates", "New Scene Template", "Trash", ] # Create from template - hNewFromTemplate = "000000000001a" + hNewFromTemplate = "000000000001b" projView.setSelectedHandle(hNewSceneCopy, doScroll=True) projView.createFileFromTemplate(hSceneTemplate) assert hNewFromTemplate in tree @@ -298,9 +319,9 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc assert nwGUI.docEditor.getText() == "### New Scene Template\n\nWith Stuff\n\n" assert [n.item.itemName for n in tree.model.root.allChildren()] == [ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", - "New Folder", "New Document", "New Chapter", "New Scene", "New Scene", - "New Scene Template", "Plot", "Characters", "New Note", "Locations", - "Objects", "Templates", "New Scene Template", "Trash", + "New Folder", "New Document", "New Part", "New Chapter", "New Scene", + "New Scene", "New Scene Template", "Plot", "Characters", "New Note", + "Locations", "Objects", "Templates", "New Scene Template", "Trash", ] # Rename Item @@ -489,7 +510,7 @@ def testGuiProjTree_MouseClicks(qtbot, monkeypatch, nwGUI, projPath, mockRnd): # Single click emits a signal with qtbot.waitSignal(projView.selectedItemChanged) as signal: - projTree._onSingleClick(model.indexFromHandle(C.hNovelRoot)) + projTree._onSelectionChange(model.indexFromHandle(C.hNovelRoot), QModelIndex()) assert signal.args[0] == C.hNovelRoot # Double click on folder expands/collapses it @@ -1065,7 +1086,7 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd): # Handles for new objects hCharNote = "0000000000011" hNovelNote = "0000000000012" - hTrashDoc = "0000000000013" + hTrashDoc = "0000000000013" hSubNote = "0000000000014" hNewFolderOne = "0000000000015" hNewFolderTwo = "0000000000017" @@ -1090,7 +1111,7 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd): assert [n.item.itemName for n in tree.model.root.allChildren()] == [ "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", "New Note", "SubNote", "Plot", "Characters", "New Note", "Locations", - "Trash", "New Document", + "Trash", "New Part", ] # Pop the menu in various positions and check for success From fcb9b6004698538b6a3e218a7691c4a38e2a6627 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 18 Apr 2025 23:27:02 +0200 Subject: [PATCH 4/4] Tweak the rules for selecting a new items parent --- novelwriter/core/tree.py | 16 +++++++------ tests/test_core/test_core_tree.py | 37 ++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 9ee92eb9..a3c93745 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -270,16 +270,18 @@ class NWTree: pNode = sNode.parent() pLevel = nwStyles.H_LEVEL.get(pNode.item.mainHeading, 0) if pNode else 0 - sLevel = 0 if isNote else nwStyles.H_LEVEL.get(sNode.item.mainHeading, 0) - if pNode and pNode.item.isFileType() and pLevel == hLevel and sLevel > hLevel: - # If the selected item is deeper level, but the parent is a document - # of the same level, we make it a sibling of the parent (See #2260) + # Notes are treated as H0, and scenes and sections both as H3 + sLevel = min(0 if isNote else nwStyles.H_LEVEL.get(sNode.item.mainHeading, 0), 3) + + if pNode and pNode.item.isFileType() and pLevel >= hLevel and sLevel > hLevel: + # If the selected item is a smaller heading and the parent heading + # is equal or larger, we make it a sibling of the parent (See #2260) return pNode.item.itemParent, pNode.row() + 1 - if sNode.childCount() > 0 and (sLevel < hLevel or isNote): - # If the item already has child nodes and is of a lower level - # or is a note, we make the new item a child + if sNode.childCount() > 0 and (0 < sLevel < hLevel or isNote): + # If the selected item already has child nodes and has a larger + # heading or is a note, we make the new item a child return sNode.item.itemHandle, sNode.childCount() # The default behaviour is to make the new item a sibling diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index b33d6dd7..1c96f580 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -252,7 +252,7 @@ def testCoreTree_PickParent(mockGUI, mockItems): # =============================== # Add a chapter under Part One - hChapterOne = tree.create("Chapter One", hPartOne, nwItemType.FILE, pos=sPos) + hChapterOne = tree.create("Chapter One", hPartOne, nwItemType.FILE) assert hChapterOne is not None nChapterOne = tree.nodes[hChapterOne] assert nChapterOne.item.itemParent == hPartOne @@ -273,6 +273,18 @@ def testCoreTree_PickParent(mockGUI, mockItems): assert sHandle == hNovelRoot assert sPos == 2 + # Add a scene under Chapter Two + hSceneOne = tree.create("Scene One", hChapterOne, nwItemType.FILE) + assert hSceneOne is not None + nSceneOne = tree.nodes[hSceneOne] + assert nSceneOne.item.itemParent == hChapterOne + nSceneOne.item.setMainHeading("H3") + + # Adding a part next to the scene should also jump a level up + sHandle, sPos = tree.pickParent(nSceneOne, 1, False) + assert sHandle == hPartOne + assert sPos == 1 + # Case 3: Documents of Deeper Level # ================================= @@ -286,6 +298,25 @@ def testCoreTree_PickParent(mockGUI, mockItems): assert sHandle == hNovelRoot assert sPos == 2 + # Add a page without a heading + hPage = tree.create("Page", hNovelRoot, nwItemType.FILE) + assert hPage is not None + nPage = tree.nodes[hPage] + assert nPage.item.itemParent == hNovelRoot + nPage.item.setMainHeading("H0") + + # Add a scene below the page + hSceneTwo = tree.create("Scene Two", hPage, nwItemType.FILE) + assert hSceneTwo is not None + nSceneTwo = tree.nodes[hSceneTwo] + assert nSceneTwo.item.itemParent == hPage + nSceneTwo.item.setMainHeading("H3") + + # A page without a heading should not add anything as a child + sHandle, sPos = tree.pickParent(nPage, 3, False) + assert sHandle == hNovelRoot + assert sPos == 3 + # Case 4: Notes # ============= @@ -293,7 +324,7 @@ def testCoreTree_PickParent(mockGUI, mockItems): nCharRoot = tree.nodes[hCharRoot] # Add a note at root level - hNoteOne = tree.create("Note One", hCharRoot, nwItemType.FILE, pos=sPos) + hNoteOne = tree.create("Note One", hCharRoot, nwItemType.FILE) assert hNoteOne is not None nNoteOne = tree.nodes[hNoteOne] assert nNoteOne.item.itemClass == nwItemClass.CHARACTER @@ -306,7 +337,7 @@ def testCoreTree_PickParent(mockGUI, mockItems): assert sPos == 1 # Add a child note to Note One - hNoteTwo = tree.create("Note Two", hNoteOne, nwItemType.FILE, pos=sPos) + hNoteTwo = tree.create("Note Two", hNoteOne, nwItemType.FILE) assert hNoteTwo is not None nNoteTwo = tree.nodes[hNoteTwo] assert nNoteTwo.item.itemClass == nwItemClass.CHARACTER