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/constants.py b/novelwriter/constants.py
index 9d8741104..e25995125 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.TEMPLATE: 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.TEMPLATE: "cls_template",
nwItemClass.TRASH: "cls_trash",
}
LAYOUT_NAME = {
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/core/item.py b/novelwriter/core/item.py
index bf13fa460..a52b8a360 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -334,15 +334,33 @@ 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.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.TRASH)
+ return self._class in (
+ nwItemClass.NOVEL,
+ nwItemClass.ARCHIVE,
+ nwItemClass.TEMPLATE,
+ 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.TEMPLATE,
+ nwItemClass.TRASH,
+ )
def isRootType(self) -> bool:
"""Check if item is a root item."""
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/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/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 f91e6099f..bbfed7753 100644
--- a/novelwriter/enum.py
+++ b/novelwriter/enum.py
@@ -47,7 +47,8 @@ class nwItemClass(Enum):
ENTITY = 7
CUSTOM = 8
ARCHIVE = 9
- TRASH = 10
+ TEMPLATE = 10
+ TRASH = 11
# END Enum nwItemClass
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/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index d3fcbb340..53fc5ce5f 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,9 @@ 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)
+ self.projBar.newDocumentFromTemplate.connect(self.createFileFromTemplate)
# Function Mappings
self.emptyTrash = self.projTree.emptyTrash
@@ -157,7 +161,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 +170,7 @@ def clearProjectView(self) -> None:
def openProjectTasks(self) -> None:
"""Run open project tasks."""
- self.projBar.buildQuickLinkMenu()
+ self.projBar.buildQuickLinksMenu()
self.projBar.setEnabled(True)
return
@@ -212,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."""
@@ -225,8 +236,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 +245,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 +316,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(lambda h: self.newDocumentFromTemplate.emit(h))
+ self.mAdd.addMenu(self.mTemplates)
+
self.mAddRoot = self.mAdd.addMenu(trConst(nwLabels.ITEM_DESCRIPTION["root"]))
self._buildRootMenu()
@@ -383,7 +401,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 +409,48 @@ 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, NWItem, QIcon)
+ def treeItemRefreshed(self, tHandle: str, nwItem: NWItem, icon: QIcon) -> None:
+ """Process change in tree items to update menu content."""
+ if nwItem.isTemplateFile() and nwItem.isActive:
+ self.mTemplates.addUpdate(tHandle, nwItem.itemName, 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
+
##
# Internal Functions
##
@@ -421,7 +466,6 @@ def addClass(itemClass):
self.mAddRoot.clear()
addClass(nwItemClass.NOVEL)
- addClass(nwItemClass.ARCHIVE)
self.mAddRoot.addSeparator()
addClass(nwItemClass.PLOT)
addClass(nwItemClass.CHARACTER)
@@ -430,26 +474,12 @@ def addClass(itemClass):
addClass(nwItemClass.OBJECT)
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
@@ -464,6 +494,8 @@ class GuiProjectTree(QTreeWidget):
D_HANDLE = Qt.ItemDataRole.UserRole
D_WORDS = Qt.ItemDataRole.UserRole + 1
+ itemRefreshed = pyqtSignal(str, NWItem, QIcon)
+
def __init__(self, projView: GuiProjectView) -> None:
super().__init__(parent=projView)
@@ -587,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
@@ -627,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:
@@ -675,7 +710,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
@@ -770,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)
@@ -1029,6 +1065,9 @@ def setTreeItemValues(self, tHandle: str) -> None:
trFont.setUnderline(hLevel == "H1")
trItem.setFont(self.C_NAME, trFont)
+ # Emit Refresh Signal
+ self.itemRefreshed.emit(tHandle, nwItem, itemIcon)
+
return
def propagateCount(self, tHandle: str, newCount: int, countChildren: bool = False) -> None:
@@ -1358,8 +1397,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)
@@ -1633,6 +1671,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(False)
+ 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/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",
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/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"))
diff --git a/sample/content/2a60782759c6f.nwd b/sample/content/2a60782759c6f.nwd
new file mode 100644
index 000000000..ac23227ab
--- /dev/null
+++ b/sample/content/2a60782759c6f.nwd
@@ -0,0 +1,12 @@
+%%~name: Chapter
+%%~path: f4ed1ae756a1f/2a60782759c6f
+%%~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/content/5aec885635c85.nwd b/sample/content/5aec885635c85.nwd
new file mode 100644
index 000000000..376e4f9ac
--- /dev/null
+++ b/sample/content/5aec885635c85.nwd
@@ -0,0 +1,12 @@
+%%~name: Scene
+%%~path: 09f22ae8369f7/5aec885635c85
+%%~kind: TEMPLATE/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..d31c4d96b
--- /dev/null
+++ b/sample/content/5ee8aebcdebc9.nwd
@@ -0,0 +1,12 @@
+%%~name: Character Note
+%%~path: 09f22ae8369f7/5ee8aebcdebc9
+%%~kind: TEMPLATE/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..8f980bad3 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,23 @@
Old File
- -
+
-
+
+ Templates
+
+ -
+
+ Scene
+
+ -
+
+ Chapter
+
+ -
+
+ Character Note
+
+ -
Trash
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
+
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
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_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:
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