From 64889ac5d882b5e8b42dbe38356ea8d5f7db3ca0 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 2 Feb 2024 20:40:57 +0100 Subject: [PATCH 1/9] Add a templates root folder --- novelwriter/constants.py | 2 ++ novelwriter/core/item.py | 20 +++++++++++++++++--- novelwriter/enum.py | 3 ++- novelwriter/gui/projtree.py | 2 ++ sample/content/5aec885635c85.nwd | 13 +++++++++++++ sample/content/5ee8aebcdebc9.nwd | 12 ++++++++++++ sample/nwProject.nwx | 22 +++++++++++++++++----- 7 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 sample/content/5aec885635c85.nwd create mode 100644 sample/content/5ee8aebcdebc9.nwd diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 9d8741104..b21d211cc 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -186,6 +186,7 @@ class nwLabels: nwItemClass.ENTITY: QT_TRANSLATE_NOOP("Constant", "Entities"), nwItemClass.CUSTOM: QT_TRANSLATE_NOOP("Constant", "Custom"), nwItemClass.ARCHIVE: QT_TRANSLATE_NOOP("Constant", "Archive"), + nwItemClass.TEMPLATES: QT_TRANSLATE_NOOP("Constant", "Templates"), nwItemClass.TRASH: QT_TRANSLATE_NOOP("Constant", "Trash"), } CLASS_ICON = { @@ -199,6 +200,7 @@ class nwLabels: nwItemClass.ENTITY: "cls_entity", nwItemClass.CUSTOM: "cls_custom", nwItemClass.ARCHIVE: "cls_archive", + nwItemClass.TEMPLATES: "cls_custom", nwItemClass.TRASH: "cls_trash", } LAYOUT_NAME = { diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index bf13fa460..2c5280ba6 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -334,15 +334,29 @@ def getImportStatus(self, incIcon=True): def isNovelLike(self) -> bool: """Check if the item is of a novel-like class.""" - return self._class in (nwItemClass.NOVEL, nwItemClass.ARCHIVE) + return self._class in ( + nwItemClass.NOVEL, + nwItemClass.ARCHIVE, + nwItemClass.TEMPLATES, + ) def documentAllowed(self) -> bool: """Check if the item is allowed to be of document layout.""" - return self._class in (nwItemClass.NOVEL, nwItemClass.ARCHIVE, nwItemClass.TRASH) + return self._class in ( + nwItemClass.NOVEL, + nwItemClass.ARCHIVE, + nwItemClass.TEMPLATES, + nwItemClass.TRASH, + ) def isInactiveClass(self) -> bool: """Check if the item is in an inactive class.""" - return self._class in (nwItemClass.NO_CLASS, nwItemClass.ARCHIVE, nwItemClass.TRASH) + return self._class in ( + nwItemClass.NO_CLASS, + nwItemClass.ARCHIVE, + nwItemClass.TEMPLATES, + nwItemClass.TRASH, + ) def isRootType(self) -> bool: """Check if item is a root item.""" diff --git a/novelwriter/enum.py b/novelwriter/enum.py index f91e6099f..282a10940 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -47,7 +47,8 @@ class nwItemClass(Enum): ENTITY = 7 CUSTOM = 8 ARCHIVE = 9 - TRASH = 10 + TEMPLATES = 10 + TRASH = 11 # END Enum nwItemClass diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index d3fcbb340..48b6fb596 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -430,6 +430,8 @@ def addClass(itemClass): addClass(nwItemClass.OBJECT) addClass(nwItemClass.ENTITY) addClass(nwItemClass.CUSTOM) + self.mAddRoot.addSeparator() + addClass(nwItemClass.TEMPLATES) return diff --git a/sample/content/5aec885635c85.nwd b/sample/content/5aec885635c85.nwd new file mode 100644 index 000000000..7b181bee9 --- /dev/null +++ b/sample/content/5aec885635c85.nwd @@ -0,0 +1,13 @@ +%%~name: Scene +%%~path: 09f22ae8369f7/5aec885635c85 +%%~kind: TEMPLATES/DOCUMENT +%%~hash: 05c9750b00020f8cf9a7bc7362bf19681022cc03 +%%~date: 2024-02-02 10:58:47/2024-02-02 10:59:32 +### Scene + +@pov: Name + +%Synopsis: A brief note about the scene content. + +Text + diff --git a/sample/content/5ee8aebcdebc9.nwd b/sample/content/5ee8aebcdebc9.nwd new file mode 100644 index 000000000..baf578474 --- /dev/null +++ b/sample/content/5ee8aebcdebc9.nwd @@ -0,0 +1,12 @@ +%%~name: Character Note +%%~path: 09f22ae8369f7/5ee8aebcdebc9 +%%~kind: TEMPLATES/NOTE +%%~hash: 6230bb6f796e708a72f4c29dc79d63dc90a47ba5 +%%~date: 2024-02-02 10:54:52/2024-02-02 10:56:05 +# Character Name + +@tag: Name + +## Motivation + +What motivates the character? diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 54c413849..5b821b508 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Sample Project Jane Smith @@ -20,7 +20,7 @@ D - New + New Notes Started 1st Draft @@ -35,7 +35,7 @@ Main - + Novel @@ -136,7 +136,19 @@ Old File - + + + Templates + + + + Character Note + + + + Scene + + Trash From e78545fcdbbf5b1d8af10555f94a41a64085f26e Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 3 Feb 2024 17:00:27 +0100 Subject: [PATCH 2/9] Don't highlight tags in inactive files --- novelwriter/core/index.py | 5 +++-- novelwriter/gui/dochighlight.py | 17 ++++++++--------- tests/test_core/test_core_index.py | 4 ++++ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 67d790b1e..a5a781a99 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -485,8 +485,9 @@ def checkThese(self, tBits: list[str], tHandle: str) -> list[bool]: # For a tag, only the first value is accepted, the rest are ignored if tBits[0] == nwKeyWords.TAG_KEY and nBits > 1: - if tBits[1] in self._tagsIndex: - isGood[1] = self._tagsIndex.tagHandle(tBits[1]) == tHandle + check, _ = self.parseValue(tBits[1]) + if check in self._tagsIndex: + isGood[1] = self._tagsIndex.tagHandle(check) == tHandle else: isGood[1] = True return isGood diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index f292e2f99..91cf4efd4 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -60,8 +60,8 @@ def __init__(self, document: QTextDocument) -> None: logger.debug("Create: GuiDocHighlighter") - self._tItem = None self._tHandle = None + self._isInactive = False self._spellCheck = False self._spellErr = QTextCharFormat() @@ -238,11 +238,10 @@ def setSpellCheck(self, state: bool) -> None: def setHandle(self, tHandle: str) -> None: """Set the handle of the currently highlighted document.""" self._tHandle = tHandle - self._tItem = SHARED.project.tree[tHandle] - logger.debug( - "Syntax highlighter %s for item '%s'", - "enabled" if self._tItem else "disabled", tHandle + self._isInactive = ( + item.isInactiveClass() if (item := SHARED.project.tree[tHandle]) else False ) + logger.debug("Syntax highlighter enabled for item '%s'", tHandle) return ## @@ -286,16 +285,16 @@ def highlightBlock(self, text: str) -> None: for n, bit in enumerate(bits): xPos = pos[n] xLen = len(bit) - if not isGood[n]: - self.setFormat(xPos, xLen, self._hStyles["codeinval"]) - elif n == 0: + if n == 0 and isGood[n]: self.setFormat(xPos, xLen, self._hStyles["keyword"]) - else: + elif isGood[n] and not self._isInactive: one, two = index.parseValue(bit) self.setFormat(xPos, len(one), self._hStyles["value"]) if two: yPos = xPos + len(bit) - len(two) self.setFormat(yPos, len(two), self._hStyles["optional"]) + elif not self._isInactive: + self.setFormat(xPos, xLen, self._hStyles["codeinval"]) # We never want to run the spell checker on keyword/values, # so we force a return here diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 53ac33bda..f225d0f28 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -294,6 +294,10 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd): assert index.parseValue("Jane | Jane Smith") == ("Jane", "Jane Smith") assert index.parseValue("Jane | Jane Smith") == ("Jane", "Jane Smith") + # Duplicates with Display Name + assert index.checkThese(["@tag", "Jane | Jane Doe"], cHandle) == [True, True] + assert index.checkThese(["@tag", "Jane | Jane Smith"], nHandle) == [True, False] + project.closeProject() # END Test testCoreIndex_CheckThese From c420d8ae38a60da311e334e06567f0c632c45fc6 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 7 Feb 2024 20:36:39 +0100 Subject: [PATCH 3/9] Add template icon --- novelwriter/assets/icons/typicons_dark/icons.conf | 1 + .../assets/icons/typicons_dark/typ_document-add-col.svg | 8 ++++++++ novelwriter/assets/icons/typicons_light/icons.conf | 1 + .../assets/icons/typicons_light/typ_document-add-col.svg | 8 ++++++++ novelwriter/gui/theme.py | 2 +- 5 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 novelwriter/assets/icons/typicons_dark/typ_document-add-col.svg create mode 100644 novelwriter/assets/icons/typicons_light/typ_document-add-col.svg diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index f66874eee..32261d871 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -40,6 +40,7 @@ cls_none = typ_cancel.svg cls_novel = typ_book.svg cls_object = typ_key.svg cls_plot = typ_puzzle.svg +cls_template = typ_document-add-col.svg cls_timeline = typ_calendar.svg cls_trash = typ_trash.svg cls_world = typ_location.svg diff --git a/novelwriter/assets/icons/typicons_dark/typ_document-add-col.svg b/novelwriter/assets/icons/typicons_dark/typ_document-add-col.svg new file mode 100644 index 000000000..404a2653f --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_document-add-col.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 3a6837d93..24474c1c6 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -40,6 +40,7 @@ cls_none = typ_cancel.svg cls_novel = typ_book.svg cls_object = typ_key.svg cls_plot = typ_puzzle.svg +cls_template = typ_document-add-col.svg cls_timeline = typ_calendar.svg cls_trash = typ_trash.svg cls_world = typ_location.svg diff --git a/novelwriter/assets/icons/typicons_light/typ_document-add-col.svg b/novelwriter/assets/icons/typicons_light/typ_document-add-col.svg new file mode 100644 index 000000000..23518846a --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_document-add-col.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index aa6f9d129..69f51292b 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -441,7 +441,7 @@ class GuiIcons: # Class Icons "cls_archive", "cls_character", "cls_custom", "cls_entity", "cls_none", "cls_novel", - "cls_object", "cls_plot", "cls_timeline", "cls_trash", "cls_world", + "cls_object", "cls_plot", "cls_template", "cls_timeline", "cls_trash", "cls_world", # Search Icons "search_cancel", "search_case", "search_loop", "search_preserve", "search_project", From bbb23b4bc9a28e9f697e52ca8f756e5bbb8f1b41 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 7 Feb 2024 20:37:53 +0100 Subject: [PATCH 4/9] Fix some widgets without parent, and update template file record keeping --- novelwriter/constants.py | 4 ++-- novelwriter/core/item.py | 10 +++++++--- novelwriter/dialogs/editlabel.py | 2 +- novelwriter/dialogs/projectsettings.py | 6 +++--- novelwriter/dialogs/wordlist.py | 2 +- novelwriter/enum.py | 2 +- novelwriter/gui/projtree.py | 18 +++++++++++++++--- novelwriter/tools/welcome.py | 6 +++--- 8 files changed, 33 insertions(+), 17 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index b21d211cc..e25995125 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -186,7 +186,7 @@ class nwLabels: nwItemClass.ENTITY: QT_TRANSLATE_NOOP("Constant", "Entities"), nwItemClass.CUSTOM: QT_TRANSLATE_NOOP("Constant", "Custom"), nwItemClass.ARCHIVE: QT_TRANSLATE_NOOP("Constant", "Archive"), - nwItemClass.TEMPLATES: QT_TRANSLATE_NOOP("Constant", "Templates"), + nwItemClass.TEMPLATE: QT_TRANSLATE_NOOP("Constant", "Templates"), nwItemClass.TRASH: QT_TRANSLATE_NOOP("Constant", "Trash"), } CLASS_ICON = { @@ -200,7 +200,7 @@ class nwLabels: nwItemClass.ENTITY: "cls_entity", nwItemClass.CUSTOM: "cls_custom", nwItemClass.ARCHIVE: "cls_archive", - nwItemClass.TEMPLATES: "cls_custom", + nwItemClass.TEMPLATE: "cls_template", nwItemClass.TRASH: "cls_trash", } LAYOUT_NAME = { diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index 2c5280ba6..a52b8a360 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -337,15 +337,19 @@ def isNovelLike(self) -> bool: return self._class in ( nwItemClass.NOVEL, nwItemClass.ARCHIVE, - nwItemClass.TEMPLATES, + nwItemClass.TEMPLATE, ) + def isTemplateFile(self) -> bool: + """Check if the item is a template file.""" + return self._type == nwItemType.FILE and self._class == nwItemClass.TEMPLATE + def documentAllowed(self) -> bool: """Check if the item is allowed to be of document layout.""" return self._class in ( nwItemClass.NOVEL, nwItemClass.ARCHIVE, - nwItemClass.TEMPLATES, + nwItemClass.TEMPLATE, nwItemClass.TRASH, ) @@ -354,7 +358,7 @@ def isInactiveClass(self) -> bool: return self._class in ( nwItemClass.NO_CLASS, nwItemClass.ARCHIVE, - nwItemClass.TEMPLATES, + nwItemClass.TEMPLATE, nwItemClass.TRASH, ) diff --git a/novelwriter/dialogs/editlabel.py b/novelwriter/dialogs/editlabel.py index 6eb44fff2..154dd390b 100644 --- a/novelwriter/dialogs/editlabel.py +++ b/novelwriter/dialogs/editlabel.py @@ -48,7 +48,7 @@ def __init__(self, parent: QWidget, text: str = "") -> None: mSp = CONFIG.pxInt(12) # Item Label - self.labelValue = QLineEdit() + self.labelValue = QLineEdit(self) self.labelValue.setMinimumWidth(mVd) self.labelValue.setMaxLength(200) self.labelValue.setText(text) diff --git a/novelwriter/dialogs/projectsettings.py b/novelwriter/dialogs/projectsettings.py index c9e327cb9..da3146f8f 100644 --- a/novelwriter/dialogs/projectsettings.py +++ b/novelwriter/dialogs/projectsettings.py @@ -361,7 +361,7 @@ def __init__(self, parent: QWidget, isStatus: bool) -> None: self.dnButton.clicked.connect(lambda: self._moveItem(1)) # Edit Form - self.editName = QLineEdit() + self.editName = QLineEdit(self) self.editName.setMaxLength(40) self.editName.setPlaceholderText(self.tr("Select item to edit")) self.editName.setEnabled(False) @@ -621,12 +621,12 @@ def __init__(self, parent: QWidget) -> None: self.delButton.clicked.connect(self._delEntry) # Edit Form - self.editKey = QLineEdit() + self.editKey = QLineEdit(self) self.editKey.setPlaceholderText(self.tr("Select item to edit")) self.editKey.setEnabled(False) self.editKey.setMaxLength(40) - self.editValue = QLineEdit() + self.editValue = QLineEdit(self) self.editValue.setEnabled(False) self.editValue.setMaxLength(80) diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py index d3631e4cb..72e19bb04 100644 --- a/novelwriter/dialogs/wordlist.py +++ b/novelwriter/dialogs/wordlist.py @@ -75,7 +75,7 @@ def __init__(self, mainGui: GuiMain) -> None: self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop) self.listBox.setSortingEnabled(True) - self.newEntry = QLineEdit() + self.newEntry = QLineEdit(self) self.addButton = QPushButton(SHARED.theme.getIcon("add"), "") self.addButton.clicked.connect(self._doAdd) diff --git a/novelwriter/enum.py b/novelwriter/enum.py index 282a10940..bbfed7753 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -47,7 +47,7 @@ class nwItemClass(Enum): ENTITY = 7 CUSTOM = 8 ARCHIVE = 9 - TEMPLATES = 10 + TEMPLATE = 10 TRASH = 11 # END Enum nwItemClass diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 48b6fb596..71cdae098 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -431,7 +431,7 @@ def addClass(itemClass): addClass(nwItemClass.ENTITY) addClass(nwItemClass.CUSTOM) self.mAddRoot.addSeparator() - addClass(nwItemClass.TEMPLATES) + addClass(nwItemClass.TEMPLATE) return @@ -466,6 +466,8 @@ class GuiProjectTree(QTreeWidget): D_HANDLE = Qt.ItemDataRole.UserRole D_WORDS = Qt.ItemDataRole.UserRole + 1 + templatesUpdated = pyqtSignal(str) + def __init__(self, projView: GuiProjectView) -> None: super().__init__(parent=projView) @@ -476,6 +478,7 @@ def __init__(self, projView: GuiProjectView) -> None: # Internal Variables self._treeMap = {} + self._templateItems = set() self._timeChanged = 0.0 self._popAlert = None @@ -1031,6 +1034,16 @@ def setTreeItemValues(self, tHandle: str) -> None: trFont.setUnderline(hLevel == "H1") trItem.setFont(self.C_NAME, trFont) + # Templates + if nwItem.isTemplateFile(): + self._templateItems.add(tHandle) + logger.debug("Updated template '%s'", tHandle) + self.templatesUpdated.emit(tHandle) + elif tHandle in self._templateItems: + self._templateItems.discard(tHandle) + logger.debug("Removed template '%s'", tHandle) + self.templatesUpdated.emit(tHandle) + return def propagateCount(self, tHandle: str, newCount: int, countChildren: bool = False) -> None: @@ -1360,8 +1373,7 @@ def _recursiveSetExpanded(self, trItem: QTreeWidgetItem, isExpanded: bool) -> No not including) a given item. """ if isinstance(trItem, QTreeWidgetItem): - chCount = trItem.childCount() - for i in range(chCount): + for i in range(trItem.childCount()): chItem = trItem.child(i) chItem.setExpanded(isExpanded) self._recursiveSetExpanded(chItem, isExpanded) diff --git a/novelwriter/tools/welcome.py b/novelwriter/tools/welcome.py index 98c3520bf..1fa3ace32 100644 --- a/novelwriter/tools/welcome.py +++ b/novelwriter/tools/welcome.py @@ -99,7 +99,7 @@ def __init__(self, parent: QWidget) -> None: self.tabNew.cancelNewProject.connect(self._showOpenProjectPage) self.tabNew.openProjectRequest.connect(self._openProjectPath) - self.mainStack = QStackedWidget() + self.mainStack = QStackedWidget(self) self.mainStack.addWidget(self.tabOpen) self.mainStack.addWidget(self.tabNew) @@ -524,13 +524,13 @@ def __init__(self, parent: QWidget) -> None: # ================ # Project Name - self.projName = QLineEdit() + self.projName = QLineEdit(self) self.projName.setMaxLength(200) self.projName.setPlaceholderText(self.tr("Required")) self.projName.textChanged.connect(self._updateProjPath) # Author(s) - self.projAuthor = QLineEdit() + self.projAuthor = QLineEdit(self) self.projAuthor.setMaxLength(200) self.projAuthor.setPlaceholderText(self.tr("Optional")) From fe8a112328a264c03d81129a3cfce96a37f9a794 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 8 Feb 2024 00:06:01 +0100 Subject: [PATCH 5/9] Add a custom templates menu to project tree add items --- novelwriter/gui/projtree.py | 180 +++++++++++++++++++++++-------- novelwriter/guimain.py | 2 +- sample/content/2a60782759c6f.nwd | 7 ++ sample/content/5aec885635c85.nwd | 3 +- sample/content/5ee8aebcdebc9.nwd | 2 +- sample/nwProject.nwx | 24 +++-- 6 files changed, 158 insertions(+), 60 deletions(-) create mode 100644 sample/content/2a60782759c6f.nwd diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 71cdae098..e7516ab36 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -32,12 +32,14 @@ from time import time from typing import TYPE_CHECKING -from PyQt5.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QMouseEvent, QPalette +from PyQt5.QtGui import ( + QDragEnterEvent, QDragMoveEvent, QDropEvent, QIcon, QMouseEvent, QPalette +) from PyQt5.QtCore import QPoint, QTimer, Qt, QSize, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractItemView, QDialog, QFrame, QHBoxLayout, QHeaderView, QLabel, - QMenu, QShortcut, QSizePolicy, QToolButton, QTreeWidget, QTreeWidgetItem, - QVBoxLayout, QWidget + QAbstractItemView, QAction, QDialog, QFrame, QHBoxLayout, QHeaderView, + QLabel, QMenu, QShortcut, QSizePolicy, QToolButton, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter import CONFIG, SHARED @@ -131,7 +133,8 @@ def __init__(self, mainGui: GuiMain) -> None: self.keyContext.activated.connect(lambda: self.projTree.openContextOnSelected()) # Signals - self.selectedItemChanged.connect(self.projBar._treeSelectionChanged) + self.selectedItemChanged.connect(self.projBar.treeSelectionChanged) + self.projTree.itemRefreshed.connect(self.projBar.treeItemRefreshed) # Function Mappings self.emptyTrash = self.projTree.emptyTrash @@ -157,7 +160,7 @@ def initSettings(self) -> None: self.projTree.initSettings() return - def clearProjectView(self) -> None: + def closeProjectTasks(self) -> None: """Clear project-related GUI content.""" self.projBar.clearContent() self.projBar.setEnabled(False) @@ -166,7 +169,7 @@ def clearProjectView(self) -> None: def openProjectTasks(self) -> None: """Run open project tasks.""" - self.projBar.buildQuickLinkMenu() + self.projBar.buildQuickLinksMenu() self.projBar.setEnabled(True) return @@ -225,8 +228,8 @@ def updateCounts(self, tHandle: str, cCount: int, wCount: int, pCount: int) -> N @pyqtSlot(str) def updateRootItem(self, tHandle: str) -> None: - """If any root item changes, rebuild the quick link menu.""" - self.projBar.buildQuickLinkMenu() + """Process root item changes.""" + self.projBar.buildQuickLinksMenu() return # END Class GuiProjectView @@ -234,6 +237,8 @@ def updateRootItem(self, tHandle: str) -> None: class GuiProjectToolBar(QWidget): + newDocumentFromTemplate = pyqtSignal(str) + def __init__(self, projView: GuiProjectView) -> None: super().__init__(parent=projView) @@ -303,6 +308,11 @@ def __init__(self, projView: GuiProjectView) -> None: lambda: self.projTree.newTreeItem(nwItemType.FOLDER) ) + self.mTemplates = _UpdatableMenu(self.mAdd) + self.mTemplates.setActionsVisible(False) + self.mTemplates.menuItemTriggered.connect(self._templateSelected) + self.mAdd.addMenu(self.mTemplates) + self.mAddRoot = self.mAdd.addMenu(trConst(nwLabels.ITEM_DESCRIPTION["root"])) self._buildRootMenu() @@ -383,7 +393,7 @@ def updateTheme(self) -> None: self.tbAdd.setIcon(SHARED.theme.getIcon("add")) self.tbMore.setIcon(SHARED.theme.getIcon("menu")) - self.buildQuickLinkMenu() + self.buildQuickLinksMenu() self._buildRootMenu() return @@ -391,21 +401,59 @@ def updateTheme(self) -> None: def clearContent(self) -> None: """Clear dynamic content on the tool bar.""" self.mQuick.clear() + self.mTemplates.clearMenu() return - def buildQuickLinkMenu(self) -> None: + def buildQuickLinksMenu(self) -> None: """Build the quick link menu.""" logger.debug("Rebuilding quick links menu") self.mQuick.clear() - for n, (tHandle, nwItem) in enumerate(SHARED.project.tree.iterRoots(None)): - aRoot = self.mQuick.addAction(nwItem.itemName) - aRoot.setData(tHandle) - aRoot.setIcon(SHARED.theme.getIcon(nwLabels.CLASS_ICON[nwItem.itemClass])) - aRoot.triggered.connect( - lambda n, tHandle=tHandle: self.projView.setSelectedHandle(tHandle, doScroll=True) + for tHandle, nwItem in SHARED.project.tree.iterRoots(None): + action = self.mQuick.addAction(nwItem.itemName) + action.setData(tHandle) + action.setIcon(SHARED.theme.getIcon(nwLabels.CLASS_ICON[nwItem.itemClass])) + action.triggered.connect( + lambda _, tHandle=tHandle: self.projView.setSelectedHandle(tHandle, doScroll=True) ) return + ## + # Public Slots + ## + + @pyqtSlot(str, str, QIcon, bool) + def treeItemRefreshed(self, tHandle: str, name: str, icon: QIcon, isTemplate: bool) -> None: + """Process change in tree items to update menu content.""" + if isTemplate: + self.mTemplates.addUpdate(tHandle, name, icon) + elif tHandle in self.mTemplates: + self.mTemplates.remove(tHandle) + return + + @pyqtSlot(str) + def treeSelectionChanged(self, tHandle: str) -> None: + """Toggle the visibility of the new item entries for novel + documents. They should only be visible if novel documents can + actually be added. + """ + nwItem = SHARED.project.tree[tHandle] + allowDoc = isinstance(nwItem, NWItem) and nwItem.documentAllowed() + self.aAddEmpty.setVisible(allowDoc) + self.aAddChap.setVisible(allowDoc) + self.aAddScene.setVisible(allowDoc) + return + + ## + # Private Slots + ## + + @pyqtSlot(str) + def _templateSelected(self, tHandle: str) -> None: + """Forward the template menu signal.""" + logger.debug("Template selected: '%s'", tHandle) + self.newDocumentFromTemplate.emit(tHandle) + return + ## # Internal Functions ## @@ -421,7 +469,6 @@ def addClass(itemClass): self.mAddRoot.clear() addClass(nwItemClass.NOVEL) - addClass(nwItemClass.ARCHIVE) self.mAddRoot.addSeparator() addClass(nwItemClass.PLOT) addClass(nwItemClass.CHARACTER) @@ -431,27 +478,11 @@ def addClass(itemClass): addClass(nwItemClass.ENTITY) addClass(nwItemClass.CUSTOM) self.mAddRoot.addSeparator() + addClass(nwItemClass.ARCHIVE) addClass(nwItemClass.TEMPLATE) return - ## - # Private Slots - ## - - @pyqtSlot(str) - def _treeSelectionChanged(self, tHandle: str) -> None: - """Toggle the visibility of the new item entries for novel - documents. They should only be visible if novel documents can - actually be added. - """ - nwItem = SHARED.project.tree[tHandle] - allowDoc = isinstance(nwItem, NWItem) and nwItem.documentAllowed() - self.aAddEmpty.setVisible(allowDoc) - self.aAddChap.setVisible(allowDoc) - self.aAddScene.setVisible(allowDoc) - return - # END Class GuiProjectToolBar @@ -466,7 +497,7 @@ class GuiProjectTree(QTreeWidget): D_HANDLE = Qt.ItemDataRole.UserRole D_WORDS = Qt.ItemDataRole.UserRole + 1 - templatesUpdated = pyqtSignal(str) + itemRefreshed = pyqtSignal(str, str, QIcon, bool) def __init__(self, projView: GuiProjectView) -> None: super().__init__(parent=projView) @@ -478,7 +509,6 @@ def __init__(self, projView: GuiProjectView) -> None: # Internal Variables self._treeMap = {} - self._templateItems = set() self._timeChanged = 0.0 self._popAlert = None @@ -1034,15 +1064,8 @@ def setTreeItemValues(self, tHandle: str) -> None: trFont.setUnderline(hLevel == "H1") trItem.setFont(self.C_NAME, trFont) - # Templates - if nwItem.isTemplateFile(): - self._templateItems.add(tHandle) - logger.debug("Updated template '%s'", tHandle) - self.templatesUpdated.emit(tHandle) - elif tHandle in self._templateItems: - self._templateItems.discard(tHandle) - logger.debug("Removed template '%s'", tHandle) - self.templatesUpdated.emit(tHandle) + # Emit Refresh Signal + self.itemRefreshed.emit(tHandle, nwItem.itemName, itemIcon, nwItem.isTemplateFile()) return @@ -1647,6 +1670,71 @@ def _alertTreeChange(self, tHandle: str | None, flush: bool = False) -> None: # END Class GuiProjectTree +class _UpdatableMenu(QMenu): + + menuItemTriggered = pyqtSignal(str) + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + self._map: dict[str, QAction] = {} + self.setTitle(self.tr("From Template")) + self.triggered.connect(self._actionTriggered) + return + + def __contains__(self, tHandle: str) -> bool: + """Look up a handle in the menu.""" + return tHandle in self._map + + ## + # Methods + ## + + def addUpdate(self, tHandle: str, name: str, icon: QIcon) -> None: + """Add or update a template item.""" + if tHandle in self._map: + action = self._map[tHandle] + action.setText(name) + action.setIcon(icon) + else: + action = QAction(icon, name, self) + action.setData(tHandle) + self.addAction(action) + self._map[tHandle] = action + self.setActionsVisible(True) + return + + def remove(self, tHandle: str) -> None: + """Remove a template item.""" + if action := self._map.pop(tHandle, None): + self.removeAction(action) + if not self._map: + self.setActionsVisible(True) + return + + def clearMenu(self) -> None: + """Clear all menu content.""" + self._map.clear() + self.clear() + return + + def setActionsVisible(self, value: bool) -> None: + """Set the visibility of root action.""" + self.menuAction().setVisible(value) + return + + ## + # Private Slots + ## + + @pyqtSlot(QAction) + def _actionTriggered(self, action: QAction) -> None: + """Translate the menu trigger into an item trigger.""" + self.menuItemTriggered.emit(str(action.data())) + return + +# END Class _UpdatableMenu + + class _TreeContextMenu(QMenu): def __init__(self, projTree: GuiProjectTree, nwItem: NWItem) -> None: diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 0978d855d..9480d5dab 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -400,7 +400,7 @@ def closeProject(self, isYes: bool = False) -> bool: self.docViewerPanel.closeProjectTasks() self.outlineView.closeProjectTasks() self.novelView.closeProjectTasks() - self.projView.clearProjectView() + self.projView.closeProjectTasks() self.itemDetails.clearDetails() self.mainStatus.clearStatus() diff --git a/sample/content/2a60782759c6f.nwd b/sample/content/2a60782759c6f.nwd new file mode 100644 index 000000000..677e68d9f --- /dev/null +++ b/sample/content/2a60782759c6f.nwd @@ -0,0 +1,7 @@ +%%~name: Chapter +%%~path: f4ed1ae756a1f/2a60782759c6f +%%~kind: TEMPLATE/NOTE +%%~hash: d09e26b73443ca5592973b954fb8d34b406765cc +%%~date: 2024-02-04 12:13:08/2024-02-08 00:02:03 +## Chapter + diff --git a/sample/content/5aec885635c85.nwd b/sample/content/5aec885635c85.nwd index 7b181bee9..376e4f9ac 100644 --- a/sample/content/5aec885635c85.nwd +++ b/sample/content/5aec885635c85.nwd @@ -1,6 +1,6 @@ %%~name: Scene %%~path: 09f22ae8369f7/5aec885635c85 -%%~kind: TEMPLATES/DOCUMENT +%%~kind: TEMPLATE/DOCUMENT %%~hash: 05c9750b00020f8cf9a7bc7362bf19681022cc03 %%~date: 2024-02-02 10:58:47/2024-02-02 10:59:32 ### Scene @@ -10,4 +10,3 @@ %Synopsis: A brief note about the scene content. Text - diff --git a/sample/content/5ee8aebcdebc9.nwd b/sample/content/5ee8aebcdebc9.nwd index baf578474..d31c4d96b 100644 --- a/sample/content/5ee8aebcdebc9.nwd +++ b/sample/content/5ee8aebcdebc9.nwd @@ -1,6 +1,6 @@ %%~name: Character Note %%~path: 09f22ae8369f7/5ee8aebcdebc9 -%%~kind: TEMPLATES/NOTE +%%~kind: TEMPLATE/NOTE %%~hash: 6230bb6f796e708a72f4c29dc79d63dc90a47ba5 %%~date: 2024-02-02 10:54:52/2024-02-02 10:56:05 # Character Name diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 5b821b508..d3d9cdb57 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Sample Project Jane Smith @@ -20,7 +20,7 @@ D - New + New Notes Started 1st Draft @@ -35,7 +35,7 @@ Main - + Novel @@ -136,18 +136,22 @@ Old File - + Templates - - - Character Note - - + Scene + + + Chapter + + + + Character Note + Trash From d9a5db5884ce9667857f1e74f9eb991dba2e9878 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 8 Feb 2024 21:37:53 +0100 Subject: [PATCH 6/9] Add file copy feature to project class --- novelwriter/core/project.py | 37 +++++++++--- tests/test_core/test_core_project.py | 87 ++++++++++++++++++++-------- 2 files changed, 92 insertions(+), 32 deletions(-) diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 69f23dbb4..a2504fd72 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -175,24 +175,47 @@ def writeNewFile(self, tHandle: str, hLevel: int, isDocument: bool, text: str = will not run if the file exists and is not empty. """ tItem = self._tree[tHandle] - if tItem is None: - return False - if not tItem.isFileType(): + if not (tItem and tItem.isFileType()): return False newDoc = self._storage.getDocument(tHandle) if (newDoc.readDocument() or "").strip(): return False - hshText = "#"*minmax(hLevel, 1, 4) - newText = f"{hshText} {tItem.itemName}\n\n{text}" + indent = "#"*minmax(hLevel, 1, 4) + text = f"{indent} {tItem.itemName}\n\n{text}" + if tItem.isNovelLike() and isDocument: tItem.setLayout(nwItemLayout.DOCUMENT) else: tItem.setLayout(nwItemLayout.NOTE) - newDoc.writeDocument(newText) - self._index.scanText(tHandle, newText) + newDoc.writeDocument(text) + self._index.scanText(tHandle, text) + + return True + + def copyFileContent(self, tHandle: str, sHandle: str) -> bool: + """Copy content to a new document after it is created. This + will not run if the file exists and is not empty. + """ + tItem = self._tree[tHandle] + if not (tItem and tItem.isFileType()): + return False + + sItem = self._tree[sHandle] + if not (sItem and sItem.isFileType()): + return False + + newDoc = self._storage.getDocument(tHandle) + if (newDoc.readDocument() or "").strip(): + return False + + logger.debug("Populating '%s' with text from '%s'", tHandle, sHandle) + text = self._storage.getDocument(sHandle).readDocument() or "" + newDoc.writeDocument(text) + sItem.setLayout(tItem.itemLayout) + self._index.scanText(tHandle, text) return True diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index 56d385909..8a4c0b8f9 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -102,34 +102,61 @@ def testCoreProject_NewFileFolder(monkeypatch, fncPath, tstPaths, mockGUI, mockR mockRnd.reset() buildTestProject(project, fncPath) + aHandle = "0000000000010" + bHandle = "0000000000011" + cHandle = "0000000000012" + dHandle = "0000000000013" + xHandle = "1234567890abc" + # Invalid call - assert project.newFolder("New Folder", "1234567890abc") is None - assert project.newFile("New File", "1234567890abc") is None + assert project.newFolder("New Folder", xHandle) is None + assert project.newFile("New File", xHandle) is None # Add files properly - assert project.newFolder("Stuff", C.hNovelRoot) == "0000000000010" - assert project.newFile("Hello", "0000000000010") == "0000000000011" - assert project.newFile("Jane", C.hCharRoot) == "0000000000012" + assert project.newFolder("Stuff", C.hNovelRoot) == aHandle + assert project.newFile("Hello", aHandle) == bHandle + assert project.newFile("Jane", C.hCharRoot) == cHandle + assert project.newFile("John", C.hCharRoot) == dHandle - assert "0000000000010" in project.tree - assert "0000000000011" in project.tree - assert "0000000000012" in project.tree + assert aHandle in project.tree + assert bHandle in project.tree + assert cHandle in project.tree + assert dHandle in project.tree # Write to file, failed assert project.writeNewFile("blabla", 1, True) is False # Not a handle - assert project.writeNewFile("0000000000010", 1, True) is False # Not a file + assert project.writeNewFile(aHandle, 1, True) is False # Not a file assert project.writeNewFile(C.hTitlePage, 1, True) is False # Already has content # Write to file, success - assert project.writeNewFile("0000000000011", 2, True) is True - assert project.storage.getDocument("0000000000011").readDocument() == "## Hello\n\n" + assert project.writeNewFile(bHandle, 2, True) is True + assert project.storage.getDocument(bHandle).readDocument() == "## Hello\n\n" # Write to file with additional text, success - assert project.writeNewFile("0000000000012", 1, False, "Hi Jane\n\n") is True - assert project.storage.getDocument("0000000000012").readDocument() == ( + assert project.writeNewFile(cHandle, 1, False, "Hi Jane\n\n") is True + assert project.storage.getDocument(cHandle).readDocument() == ( "# Jane\n\nHi Jane\n\n" ) + # Copy file content, invalid target + assert project.copyFileContent(xHandle, cHandle) is False # Unknown handle + assert project.copyFileContent(aHandle, cHandle) is False # Target is a folder + + # Copy file content, invalid source + assert project.copyFileContent(dHandle, xHandle) is False # Unknown handle + assert project.copyFileContent(dHandle, aHandle) is False # Source is a folder + + # Copy file content, target not empty + assert project.copyFileContent(bHandle, cHandle) is False + + # Copy file content, success + assert project.copyFileContent(dHandle, cHandle) is True + + cText = project.storage.getDocument(cHandle).readDocument() + dText = project.storage.getDocument(dHandle).readDocument() + + assert dText == cText + # Save, close and check assert project.projChanged is True assert project.saveProject() is True @@ -141,23 +168,27 @@ def testCoreProject_NewFileFolder(monkeypatch, fncPath, tstPaths, mockGUI, mockR # Delete new file, but block access with monkeypatch.context() as mp: mp.setattr("pathlib.Path.unlink", causeOSError) - assert project.removeItem("0000000000011") is False - assert "0000000000011" in project.tree + assert project.removeItem(bHandle) is False + assert bHandle in project.tree # Delete new files and folders - assert (fncPath / "content" / "0000000000012.nwd").exists() - assert (fncPath / "content" / "0000000000011.nwd").exists() + assert (fncPath / "content" / f"{dHandle}.nwd").exists() + assert (fncPath / "content" / f"{cHandle}.nwd").exists() + assert (fncPath / "content" / f"{bHandle}.nwd").exists() - assert project.removeItem("0000000000012") is True - assert project.removeItem("0000000000011") is True - assert project.removeItem("0000000000010") is True + assert project.removeItem(dHandle) is True + assert project.removeItem(cHandle) is True + assert project.removeItem(bHandle) is True + assert project.removeItem(aHandle) is True - assert not (fncPath / "content" / "0000000000012.nwd").exists() - assert not (fncPath / "content" / "0000000000011.nwd").exists() + assert not (fncPath / "content" / f"{dHandle}.nwd").exists() + assert not (fncPath / "content" / f"{cHandle}.nwd").exists() + assert not (fncPath / "content" / f"{bHandle}.nwd").exists() - assert "0000000000010" not in project.tree - assert "0000000000011" not in project.tree - assert "0000000000012" not in project.tree + assert aHandle not in project.tree + assert bHandle not in project.tree + assert cHandle not in project.tree + assert dHandle not in project.tree project.closeProject() @@ -177,6 +208,8 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): caplog.clear() assert project.openProject(fooBar) is False assert "Not a known project file format." in caplog.text + assert project.isValid is False + assert project.lockStatus is None # No project file caplog.clear() @@ -204,10 +237,14 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): # Open again should fail on lock file assert project.openProject(fncPath) is False assert project.state == NWProjectState.LOCKED + assert project.isValid is True + assert project.lockStatus is not None # Force re-open assert project.openProject(fncPath, clearLock=True) is True assert project.state == NWProjectState.READY + assert project.isValid is True + assert project.lockStatus is None # Fail getting xml reader with monkeypatch.context() as mp: From 0aaf42de21f9e5f01d8a7c3797dd320eeddc5809 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 8 Feb 2024 21:49:07 +0100 Subject: [PATCH 7/9] Connect the bits for the file template feature --- novelwriter/gui/projtree.py | 29 +++++++++---------- sample/content/2a60782759c6f.nwd | 11 +++++-- sample/nwProject.nwx | 10 +++---- .../coreProject_NewFileFolder_nwProject.nwx | 10 +++++-- 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index e7516ab36..08291bafb 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -135,6 +135,7 @@ def __init__(self, mainGui: GuiMain) -> None: # Signals self.selectedItemChanged.connect(self.projBar.treeSelectionChanged) self.projTree.itemRefreshed.connect(self.projBar.treeItemRefreshed) + self.projBar.newDocumentFromTemplate.connect(self.createFileFromTemplate) # Function Mappings self.emptyTrash = self.projTree.emptyTrash @@ -215,10 +216,17 @@ def setSelectedHandle(self, tHandle: str, doScroll: bool = False) -> None: @pyqtSlot(str) def updateItemValues(self, tHandle: str) -> None: - """Update tree item""" + """Update tree item.""" self.projTree.setTreeItemValues(tHandle) return + @pyqtSlot(str) + def createFileFromTemplate(self, tHandle: str) -> None: + """Create a new document from a template.""" + logger.debug("Template selected: '%s'", tHandle) + self.projTree.newTreeItem(nwItemType.FILE, copyDoc=tHandle) + return + @pyqtSlot(str, int, int, int) def updateCounts(self, tHandle: str, cCount: int, wCount: int, pCount: int) -> None: """Slot for updating the word count of a specific item.""" @@ -310,7 +318,7 @@ def __init__(self, projView: GuiProjectView) -> None: self.mTemplates = _UpdatableMenu(self.mAdd) self.mTemplates.setActionsVisible(False) - self.mTemplates.menuItemTriggered.connect(self._templateSelected) + self.mTemplates.menuItemTriggered.connect(lambda h: self.newDocumentFromTemplate.emit(h)) self.mAdd.addMenu(self.mTemplates) self.mAddRoot = self.mAdd.addMenu(trConst(nwLabels.ITEM_DESCRIPTION["root"])) @@ -443,17 +451,6 @@ def treeSelectionChanged(self, tHandle: str) -> None: self.aAddScene.setVisible(allowDoc) return - ## - # Private Slots - ## - - @pyqtSlot(str) - def _templateSelected(self, tHandle: str) -> None: - """Forward the template menu signal.""" - logger.debug("Template selected: '%s'", tHandle) - self.newDocumentFromTemplate.emit(tHandle) - return - ## # Internal Functions ## @@ -622,7 +619,7 @@ def createNewNote(self, tag: str, itemClass: nwItemClass | None) -> bool: return False def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None, - hLevel: int = 1, isNote: bool = False) -> bool: + hLevel: int = 1, isNote: bool = False, copyDoc: str | None = None) -> bool: """Add new item to the tree, with a given itemType (and itemClass if Root), and attach it to the selected handle. Also make sure the item is added in a place it can be added, and that @@ -710,7 +707,9 @@ def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None return True # Handle new file creation - if itemType == nwItemType.FILE and hLevel > 0: + if itemType == nwItemType.FILE and copyDoc: + SHARED.project.copyFileContent(tHandle, copyDoc) + elif itemType == nwItemType.FILE and hLevel > 0: SHARED.project.writeNewFile(tHandle, hLevel, not isNote) # Add the new item to the project tree diff --git a/sample/content/2a60782759c6f.nwd b/sample/content/2a60782759c6f.nwd index 677e68d9f..ac23227ab 100644 --- a/sample/content/2a60782759c6f.nwd +++ b/sample/content/2a60782759c6f.nwd @@ -1,7 +1,12 @@ %%~name: Chapter %%~path: f4ed1ae756a1f/2a60782759c6f -%%~kind: TEMPLATE/NOTE -%%~hash: d09e26b73443ca5592973b954fb8d34b406765cc -%%~date: 2024-02-04 12:13:08/2024-02-08 00:02:03 +%%~kind: TEMPLATE/DOCUMENT +%%~hash: da17bc023f9fcf9d739f19ce4b49a4f95dd7acc2 +%%~date: 2024-02-04 12:13:08/2024-02-08 21:45:26 ## Chapter +@pov: Name + +%Synopsis: A brief note about the chapter content. + +Text diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index d3d9cdb57..8f980bad3 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Sample Project Jane Smith @@ -35,7 +35,7 @@ Main - + Novel @@ -141,11 +141,11 @@ Templates - + Scene - + Chapter diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx index 99b5a57c6..2422fc028 100644 --- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx +++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project Jane Doe @@ -22,13 +22,13 @@ Finished - New + New Minor Major Main - + Novel @@ -73,5 +73,9 @@ Jane + + + John + From 4113be7a4cf4f4ee77ae62b6b3bbeed08cf77628 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 8 Feb 2024 22:12:05 +0100 Subject: [PATCH 8/9] Allow filtering templates by setting them to inactive --- novelwriter/core/projectxml.py | 3 ++- novelwriter/gui/projtree.py | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index c64d290fe..029a9f6c8 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -106,7 +106,8 @@ class ProjectXMLReader: as attributes. The id attribute was also added to the project. Rev 1: Drops the titleFormat node from settings. 2.1 Beta 1. - Rev 2: Drops the title node from project. 2.3 Beta 1. + Rev 2: Drops the title node from project and adds the TEMPLATE + class for items. 2.3 Beta 1. """ def __init__(self, path: str | Path) -> None: diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 08291bafb..7ee936568 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -429,11 +429,11 @@ def buildQuickLinksMenu(self) -> None: # Public Slots ## - @pyqtSlot(str, str, QIcon, bool) - def treeItemRefreshed(self, tHandle: str, name: str, icon: QIcon, isTemplate: bool) -> None: + @pyqtSlot(str, NWItem, QIcon) + def treeItemRefreshed(self, tHandle: str, nwItem: NWItem, icon: QIcon) -> None: """Process change in tree items to update menu content.""" - if isTemplate: - self.mTemplates.addUpdate(tHandle, name, icon) + if nwItem.isTemplateFile() and nwItem.isActive: + self.mTemplates.addUpdate(tHandle, nwItem.itemName, icon) elif tHandle in self.mTemplates: self.mTemplates.remove(tHandle) return @@ -494,7 +494,7 @@ class GuiProjectTree(QTreeWidget): D_HANDLE = Qt.ItemDataRole.UserRole D_WORDS = Qt.ItemDataRole.UserRole + 1 - itemRefreshed = pyqtSignal(str, str, QIcon, bool) + itemRefreshed = pyqtSignal(str, NWItem, QIcon) def __init__(self, projView: GuiProjectView) -> None: super().__init__(parent=projView) @@ -1064,7 +1064,7 @@ def setTreeItemValues(self, tHandle: str) -> None: trItem.setFont(self.C_NAME, trFont) # Emit Refresh Signal - self.itemRefreshed.emit(tHandle, nwItem.itemName, itemIcon, nwItem.isTemplateFile()) + self.itemRefreshed.emit(tHandle, nwItem, itemIcon) return From 8ff292fec8fba8584ad4d3c2fa6bf10bc81cb45d Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 8 Feb 2024 23:10:20 +0100 Subject: [PATCH 9/9] Add test coverage of template feature --- novelwriter/gui/projtree.py | 10 ++- tests/test_core/test_core_item.py | 19 +++++ tests/test_gui/test_gui_projtree.py | 120 ++++++++++++++++++++++++++-- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 7ee936568..53fc5ce5f 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -659,7 +659,10 @@ def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None # Set default label and determine if new item is to be added # as child or sibling to the selected item if itemType == nwItemType.FILE: - if isNote: + if copyDoc and (cItem := SHARED.project.tree[copyDoc]): + newLabel = cItem.itemName + asChild = sIsParent and pItem.isDocumentLayout() + elif isNote: newLabel = self.tr("New Note") asChild = sIsParent elif hLevel == 2: @@ -804,8 +807,7 @@ def moveToLevel(self, step: int) -> None: def renameTreeItem(self, tHandle: str, name: str = "") -> None: """Open a dialog to edit the label of an item.""" - tItem = SHARED.project.tree[tHandle] - if tItem: + if tItem := SHARED.project.tree[tHandle]: newLabel, dlgOk = GuiEditLabel.getLabel(self, text=name or tItem.itemName) if dlgOk: tItem.setName(newLabel) @@ -1707,7 +1709,7 @@ def remove(self, tHandle: str) -> None: if action := self._map.pop(tHandle, None): self.removeAction(action) if not self._map: - self.setActionsVisible(True) + self.setActionsVisible(False) return def clearMenu(self) -> None: diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py index 7f01700d7..151d89dad 100644 --- a/tests/test_core/test_core_item.py +++ b/tests/test_core/test_core_item.py @@ -365,6 +365,7 @@ def testCoreItem_ClassSetter(mockGUI): """ project = NWProject() item = NWItem(project, "0000000000000") + item.setType(nwItemType.FILE) # Class item.setClass(None) @@ -377,66 +378,84 @@ def testCoreItem_ClassSetter(mockGUI): assert item.isNovelLike() is False assert item.documentAllowed() is False assert item.isInactiveClass() is True + assert item.isTemplateFile() is False item.setClass("NOVEL") assert item.itemClass == nwItemClass.NOVEL assert item.isNovelLike() is True assert item.documentAllowed() is True assert item.isInactiveClass() is False + assert item.isTemplateFile() is False item.setClass("PLOT") assert item.itemClass == nwItemClass.PLOT assert item.isNovelLike() is False assert item.documentAllowed() is False assert item.isInactiveClass() is False + assert item.isTemplateFile() is False item.setClass("CHARACTER") assert item.itemClass == nwItemClass.CHARACTER assert item.isNovelLike() is False assert item.documentAllowed() is False assert item.isInactiveClass() is False + assert item.isTemplateFile() is False item.setClass("WORLD") assert item.itemClass == nwItemClass.WORLD assert item.isNovelLike() is False assert item.documentAllowed() is False assert item.isInactiveClass() is False + assert item.isTemplateFile() is False item.setClass("TIMELINE") assert item.itemClass == nwItemClass.TIMELINE assert item.isNovelLike() is False assert item.documentAllowed() is False assert item.isInactiveClass() is False + assert item.isTemplateFile() is False item.setClass("OBJECT") assert item.itemClass == nwItemClass.OBJECT assert item.isNovelLike() is False assert item.documentAllowed() is False assert item.isInactiveClass() is False + assert item.isTemplateFile() is False item.setClass("ENTITY") assert item.itemClass == nwItemClass.ENTITY assert item.isNovelLike() is False assert item.documentAllowed() is False assert item.isInactiveClass() is False + assert item.isTemplateFile() is False item.setClass("CUSTOM") assert item.itemClass == nwItemClass.CUSTOM assert item.isNovelLike() is False assert item.documentAllowed() is False assert item.isInactiveClass() is False + assert item.isTemplateFile() is False item.setClass("ARCHIVE") assert item.itemClass == nwItemClass.ARCHIVE assert item.isNovelLike() is True assert item.documentAllowed() is True assert item.isInactiveClass() is True + assert item.isTemplateFile() is False + + item.setClass("TEMPLATE") + assert item.itemClass == nwItemClass.TEMPLATE + assert item.isNovelLike() is True + assert item.documentAllowed() is True + assert item.isInactiveClass() is True + assert item.isTemplateFile() is True item.setClass("TRASH") assert item.itemClass == nwItemClass.TRASH assert item.isNovelLike() is False assert item.documentAllowed() is True assert item.isInactiveClass() is True + assert item.isTemplateFile() is False # Alternative item.setClass(nwItemClass.NOVEL) diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index 628c42be6..4e2c07522 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -115,15 +115,28 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRn assert nwGUI.docEditor.getText() == "### New Scene\n\n" assert projTree._getItemWordCount("0000000000014") == 2 + # Add a new scene with the content copied from the previous + assert nwGUI.openDocument("0000000000014") + nwGUI.docEditor.setPlainText("### New Scene\n\nWith Stuff\n\n") + nwGUI.saveDocument() + projView.setSelectedHandle("0000000000014") + assert projTree.newTreeItem(nwItemType.FILE, copyDoc="0000000000014") is True + assert project.tree["0000000000015"].itemParent == "0000000000011" # type: ignore + assert project.tree["0000000000015"].itemRoot == C.hNovelRoot # type: ignore + assert project.tree["0000000000015"].itemClass == nwItemClass.NOVEL # type: ignore + assert nwGUI.openDocument("0000000000015") + assert nwGUI.docEditor.getText() == "### New Scene\n\nWith Stuff\n\n" + assert projTree._getItemWordCount("0000000000015") == 4 + # Add a new file to the characters folder projView.setSelectedHandle(C.hCharRoot) assert projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) is True - assert project.tree["0000000000015"].itemParent == C.hCharRoot # type: ignore - assert project.tree["0000000000015"].itemRoot == C.hCharRoot # type: ignore - assert project.tree["0000000000015"].itemClass == nwItemClass.CHARACTER # type: ignore - assert nwGUI.openDocument("0000000000015") + assert project.tree["0000000000016"].itemParent == C.hCharRoot # type: ignore + assert project.tree["0000000000016"].itemRoot == C.hCharRoot # type: ignore + assert project.tree["0000000000016"].itemClass == nwItemClass.CHARACTER # type: ignore + assert nwGUI.openDocument("0000000000016") assert nwGUI.docEditor.getText() == "# New Note\n\n" - assert projTree._getItemWordCount("0000000000015") == 2 + assert projTree._getItemWordCount("0000000000016") == 2 # Make sure the sibling folder bug trap works projView.setSelectedHandle("0000000000013") @@ -1112,7 +1125,7 @@ def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd) @pytest.mark.gui def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd): """Test the building of the project tree context menu. All this does - is test that the menu builds. It doesn't open the actual menu, + is test that the menu builds. It doesn't open the actual menu. """ monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) @@ -1392,3 +1405,98 @@ def getTransformSubMenu(menu: QMenu) -> list[str]: # qtbot.stop() # END Test testGuiProjTree_ContextMenu + + +@pytest.mark.gui +def testGuiProjTree_Templates(qtbot, monkeypatch, nwGUI, projPath, mockRnd): + """Test the templates feature of the project tree.""" + monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) + + # Create a project + buildTestProject(nwGUI, projPath) + nwGUI.openProject(projPath) + nwGUI.switchFocus(nwWidget.TREE) + nwGUI.show() + + project = SHARED.project + + projView = nwGUI.projView + projTree = projView.projTree + projBar = projView.projBar + + # Handles for new objects + hTemplatesRoot = "0000000000010" + hSceneTemplate = "0000000000011" + hNoteTemplate = "0000000000012" + hNewScene = "0000000000013" + hNewCharacter = "0000000000014" + + # Add template folder + projTree.newTreeItem(nwItemType.ROOT, nwItemClass.TEMPLATE) + nwTemplateRoot = project.tree[hTemplatesRoot] + assert nwTemplateRoot is not None + assert nwTemplateRoot.itemName == "Templates" + + # Add a scene template + projTree.setSelectedHandle(hTemplatesRoot) + projTree.newTreeItem(nwItemType.FILE, hLevel=3, isNote=False) + nwSceneTemplate = project.tree[hSceneTemplate] + assert nwSceneTemplate is not None + assert nwSceneTemplate.itemName == "New Scene" + assert projBar.mTemplates.actions()[0].text() == "New Scene" + + # Rename the scene template + with qtbot.waitSignal(projTree.itemRefreshed, timeout=1000) as signal: + projTree.renameTreeItem(hSceneTemplate, name="Scene") + assert signal.args[0] == hSceneTemplate + assert signal.args[1].itemName == "Scene" + assert projBar.mTemplates.actions()[0].text() == "Scene" + + # Add a note template + projTree.setSelectedHandle(hTemplatesRoot) + projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True) + nwNoteTemplate = project.tree[hNoteTemplate] + assert nwNoteTemplate is not None + assert nwNoteTemplate.itemName == "New Note" + assert projBar.mTemplates.actions()[1].text() == "New Note" + + # Rename the note template + with qtbot.waitSignal(projTree.itemRefreshed, timeout=1000) as signal: + projTree.renameTreeItem(hNoteTemplate, name="Note") + assert signal.args[0] == hNoteTemplate + assert signal.args[1].itemName == "Note" + assert projBar.mTemplates.actions()[1].text() == "Note" + + # Add new content to template files + (projPath / "content" / f"{hSceneTemplate}.nwd").write_text("### Scene\n\n@pov: Jane\n\n") + (projPath / "content" / f"{hNoteTemplate}.nwd").write_text("# Jane\n\n@tag: Jane\n\n") + + # Add a new scene using the template + projTree.setSelectedHandle(C.hSceneDoc, doScroll=True) + projBar.mTemplates.actions()[0].trigger() + nwNewScene = project.tree[hNewScene] + assert nwNewScene is not None + assert nwNewScene.itemName == "Scene" + assert project.storage.getDocument(hNewScene).readDocument() == "### Scene\n\n@pov: Jane\n\n" + + # Add a new note using the template + projTree.setSelectedHandle(C.hCharRoot, doScroll=True) + projBar.mTemplates.actions()[1].trigger() + nwNewCharacter = project.tree[hNewCharacter] + assert nwNewCharacter is not None + assert nwNewCharacter.itemName == "Note" + assert project.storage.getDocument(hNewCharacter).readDocument() == "# Jane\n\n@tag: Jane\n\n" + + # Remove the templates + with qtbot.waitSignal(projTree.itemRefreshed, timeout=1000) as signal: + assert projBar.mTemplates.menuAction().isVisible() is True + assert len(projBar.mTemplates.actions()) == 2 + projTree.moveItemToTrash(hNoteTemplate) + assert len(projBar.mTemplates.actions()) == 1 + projTree.moveItemToTrash(hSceneTemplate) + assert len(projBar.mTemplates.actions()) == 0 + assert projBar.mTemplates.menuAction().isVisible() is False + + # qtbot.stop() + +# END Test testGuiProjTree_Templates