diff --git a/CHANGELOG.md b/CHANGELOG.md
index e83846e1..4d4d6ba3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1173,7 +1173,7 @@ _These Release Notes also include the changes from the 2.2 Beta 1 and 2.2 RC 1 r
**Usability**
* Use `Ctrl+K, H` for inserting short description comments (alias to synopsis), drop the space
- after the `%` symbol when inserting special comments, add a browse icon to the open open project
+ after the `%` symbol when inserting special comments, add a browse icon to the open project
dialog, and remove the popup warning for Alpha releases. PR #1626.
* Menu entries no longer clear the status bar message when they are hovered. This was caused by a
status tip feature in Qt, which prints a blank message to the status bar. PR #1630.
diff --git a/novelwriter/config.py b/novelwriter/config.py
index 15bce92b..949f49f3 100644
--- a/novelwriter/config.py
+++ b/novelwriter/config.py
@@ -68,23 +68,23 @@ class Config:
"_backupPath",
"appName", "appHandle", "guiLocale", "guiTheme", "guiSyntax", "guiFont", "hideVScroll",
- "hideHScroll", "lastNotes", "nativeFont", "iconTheme", "iconColTree", "iconColDocs",
- "mainWinSize", "welcomeWinSize", "prefsWinSize", "mainPanePos", "viewPanePos",
- "outlinePanePos", "autoSaveProj", "autoSaveDoc", "emphLabels", "backupOnClose",
- "askBeforeBackup", "askBeforeExit", "textFont", "textWidth", "textMargin", "tabWidth",
- "cursorWidth", "focusWidth", "hideFocusFooter", "showFullPath", "autoSelect", "doJustify",
- "showTabsNSpaces", "showLineEndings", "showMultiSpaces", "doReplace", "doReplaceSQuote",
- "doReplaceDQuote", "doReplaceDash", "doReplaceDots", "autoScroll", "autoScrollPos",
- "scrollPastEnd", "dialogStyle", "allowOpenDial", "dialogLine", "narratorBreak",
- "narratorDialog", "altDialogOpen", "altDialogClose", "highlightEmph", "stopWhenIdle",
- "userIdleTime", "incNotesWCount", "fmtApostrophe", "fmtSQuoteOpen", "fmtSQuoteClose",
- "fmtDQuoteOpen", "fmtDQuoteClose", "fmtPadBefore", "fmtPadAfter", "fmtPadThin",
- "spellLanguage", "showViewerPanel", "showEditToolBar", "showSessionTime", "viewComments",
- "viewSynopsis", "searchCase", "searchWord", "searchRegEx", "searchLoop", "searchNextFile",
- "searchMatchCap", "searchProjCase", "searchProjWord", "searchProjRegEx", "verQtString",
- "verQtValue", "verPyQtString", "verPyQtValue", "verPyString", "osType", "osLinux",
- "osWindows", "osDarwin", "osUnknown", "hostName", "kernelVer", "isDebug", "memInfo",
- "hasEnchant",
+ "hideHScroll", "lastNotes", "nativeFont", "useCharCount", "iconTheme", "iconColTree",
+ "iconColDocs", "mainWinSize", "welcomeWinSize", "prefsWinSize", "mainPanePos",
+ "viewPanePos", "outlinePanePos", "autoSaveProj", "autoSaveDoc", "emphLabels",
+ "backupOnClose", "askBeforeBackup", "askBeforeExit", "textFont", "textWidth", "textMargin",
+ "tabWidth", "cursorWidth", "focusWidth", "hideFocusFooter", "showFullPath", "autoSelect",
+ "doJustify", "showTabsNSpaces", "showLineEndings", "showMultiSpaces", "doReplace",
+ "doReplaceSQuote", "doReplaceDQuote", "doReplaceDash", "doReplaceDots", "autoScroll",
+ "autoScrollPos", "scrollPastEnd", "dialogStyle", "allowOpenDial", "dialogLine",
+ "narratorBreak", "narratorDialog", "altDialogOpen", "altDialogClose", "highlightEmph",
+ "stopWhenIdle", "userIdleTime", "incNotesWCount", "fmtApostrophe", "fmtSQuoteOpen",
+ "fmtSQuoteClose", "fmtDQuoteOpen", "fmtDQuoteClose", "fmtPadBefore", "fmtPadAfter",
+ "fmtPadThin", "spellLanguage", "showViewerPanel", "showEditToolBar", "showSessionTime",
+ "viewComments", "viewSynopsis", "searchCase", "searchWord", "searchRegEx", "searchLoop",
+ "searchNextFile", "searchMatchCap", "searchProjCase", "searchProjWord", "searchProjRegEx",
+ "verQtString", "verQtValue", "verPyQtString", "verPyQtValue", "verPyString", "osType",
+ "osLinux", "osWindows", "osDarwin", "osUnknown", "hostName", "kernelVer", "isDebug",
+ "memInfo", "hasEnchant",
)
LANG_NW = 1
@@ -157,6 +157,7 @@ class Config:
self.hideHScroll = False # Hide horizontal scroll bars on main widgets
self.lastNotes = "0x0" # The latest release notes that have been shown
self.nativeFont = True # Use native font dialog
+ self.useCharCount = False # Use character count as primary count
# Icons
self.iconTheme = DEF_ICONS # Icons theme
@@ -591,16 +592,17 @@ class Config:
# Main
sec = "Main"
self.setGuiFont(conf.rdStr(sec, "font", ""))
- self.guiTheme = conf.rdStr(sec, "theme", self.guiTheme)
- self.guiSyntax = conf.rdStr(sec, "syntax", self.guiSyntax)
- self.iconTheme = conf.rdStr(sec, "icons", self.iconTheme)
- self.iconColTree = conf.rdStr(sec, "iconcoltree", self.iconColTree)
- self.iconColDocs = conf.rdBool(sec, "iconcoldocs", self.iconColDocs)
- self.guiLocale = conf.rdStr(sec, "localisation", self.guiLocale)
- self.hideVScroll = conf.rdBool(sec, "hidevscroll", self.hideVScroll)
- self.hideHScroll = conf.rdBool(sec, "hidehscroll", self.hideHScroll)
- self.lastNotes = conf.rdStr(sec, "lastnotes", self.lastNotes)
- self.nativeFont = conf.rdBool(sec, "nativefont", self.nativeFont)
+ self.guiTheme = conf.rdStr(sec, "theme", self.guiTheme)
+ self.guiSyntax = conf.rdStr(sec, "syntax", self.guiSyntax)
+ self.iconTheme = conf.rdStr(sec, "icons", self.iconTheme)
+ self.iconColTree = conf.rdStr(sec, "iconcoltree", self.iconColTree)
+ self.iconColDocs = conf.rdBool(sec, "iconcoldocs", self.iconColDocs)
+ self.guiLocale = conf.rdStr(sec, "localisation", self.guiLocale)
+ self.hideVScroll = conf.rdBool(sec, "hidevscroll", self.hideVScroll)
+ self.hideHScroll = conf.rdBool(sec, "hidehscroll", self.hideHScroll)
+ self.lastNotes = conf.rdStr(sec, "lastnotes", self.lastNotes)
+ self.nativeFont = conf.rdBool(sec, "nativefont", self.nativeFont)
+ self.useCharCount = conf.rdBool(sec, "usecharcount", self.useCharCount)
# Sizes
sec = "Sizes"
@@ -717,6 +719,7 @@ class Config:
"hidehscroll": str(self.hideHScroll),
"lastnotes": str(self.lastNotes),
"nativefont": str(self.nativeFont),
+ "usecharcount": str(self.useCharCount),
}
conf["Sizes"] = {
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 10edf7c0..f3178faf 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -38,7 +38,8 @@ from novelwriter import SHARED
from novelwriter.common import isHandle, isItemClass, isTitleTag, jsonEncode
from novelwriter.constants import nwFiles, nwKeyWords, nwStyles
from novelwriter.core.indexdata import NOTE_TYPES, TT_NONE, IndexHeading, IndexNode, T_NoteTypes
-from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType
+from novelwriter.core.novelmodel import NovelModel
+from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType, nwNovelExtra
from novelwriter.error import logException
from novelwriter.text.comments import processComment
from novelwriter.text.counting import standardCounter
@@ -86,9 +87,13 @@ class Index:
# Storage and State
self._tagsIndex = TagsIndex()
- self._itemIndex = ItemIndex(project)
+ self._itemIndex = ItemIndex(project, self._tagsIndex)
self._indexBroken = False
+ # Models
+ self._novelModels: dict[str, NovelModel] = {}
+ self._novelExtra = nwNovelExtra.HIDDEN
+
# TimeStamps
self._indexChange = 0.0
self._rootChange = {}
@@ -106,6 +111,25 @@ class Index:
def indexBroken(self) -> bool:
return self._indexBroken
+ ##
+ # Getters
+ ##
+
+ def getNovelModel(self, tHandle: str) -> NovelModel | None:
+ """Get the model for a specific novel root."""
+ if tHandle not in self._novelModels:
+ self._generateNovelModel(tHandle)
+ return self._novelModels.get(tHandle)
+
+ ##
+ # Setters
+ ##
+
+ def setNovelModelExtraColumn(self, extra: nwNovelExtra) -> None:
+ """Set the data content type of the novel model extra column."""
+ self._novelExtra = extra
+ return
+
##
# Public Methods
##
@@ -128,6 +152,8 @@ class Index:
self.scanText(nwItem.itemHandle, text, blockSignal=True)
self._indexBroken = False
SHARED.emitIndexAvailable(self._project)
+ for tHandle in self._novelModels:
+ self.refreshNovelModel(tHandle)
return
def deleteHandle(self, tHandle: str) -> None:
@@ -162,6 +188,30 @@ class Index:
return self._rootChange.get(rootHandle, self._indexChange) > float(checkTime)
return False
+ def refreshNovelModel(self, tHandle: str | None) -> None:
+ """Refresh a novel model."""
+ if tHandle and (model := self.getNovelModel(tHandle)):
+ logger.info("Refreshing novel model '%s'", tHandle)
+ model.beginResetModel()
+ model.clear()
+ model.setExtraColumn(self._novelExtra)
+ self._appendSubTreeToModel(tHandle, model)
+ model.endResetModel()
+ return
+
+ def updateNovelModelData(self, nwItem: NWItem) -> bool:
+ """Refresh a novel model."""
+ if (
+ (rHandle := nwItem.itemRoot)
+ and (model := self._novelModels.get(rHandle))
+ and (node := self._itemIndex[nwItem.itemHandle])
+ and node.item.isDocumentLayout()
+ and node.item.isActive
+ ):
+ logger.info("Updating novel model data '%s'", nwItem.itemHandle)
+ return model.refresh(node)
+ return False
+
##
# Load and Save Index to/from File
##
@@ -282,6 +332,10 @@ class Index:
else:
self._scanActive(tHandle, tItem, text, itemTags)
+ if tItem.itemClass == nwItemClass.NOVEL and not blockSignal:
+ if not self.updateNovelModelData(tItem):
+ self.refreshNovelModel(tItem.itemRoot)
+
# Update timestamps for index changes
nowTime = time()
self._indexChange = nowTime
@@ -396,8 +450,9 @@ class Index:
self._itemIndex.setHeadingCounts(tHandle, sTitle, cC, wC, pC)
return
- def _indexKeyword(self, tHandle: str, line: str, sTitle: str,
- itemClass: nwItemClass, tags: dict[str, bool]) -> None:
+ def _indexKeyword(
+ self, tHandle: str, line: str, sTitle: str, itemClass: nwItemClass, tags: dict[str, bool]
+ ) -> None:
"""Validate and save the information about a reference to a tag
in another file, or the setting of a tag in the file. A record
of active tags is updated so that no longer used tags can be
@@ -422,6 +477,26 @@ class Index:
return
+ def _generateNovelModel(self, tHandle: str) -> None:
+ """Generate a novel model for a specific handle."""
+ if (item := self._project.tree[tHandle]) and item.isRootType() and item.isNovelLike():
+ model = NovelModel()
+ model.setExtraColumn(self._novelExtra)
+ self._appendSubTreeToModel(tHandle, model)
+ self._novelModels[tHandle] = model
+ return
+
+ def _appendSubTreeToModel(self, tHandle: str, model: NovelModel) -> None:
+ """Append all active novel documents to a novel model."""
+ for handle in self._project.tree.subTree(tHandle):
+ if (
+ (node := self._itemIndex[handle])
+ and node.item.isDocumentLayout()
+ and node.item.isActive
+ ):
+ model.append(node)
+ return
+
##
# Check @ Lines
##
@@ -562,13 +637,6 @@ class Index:
hCount[iLevel] += 1
return hCount
- def getHandleHeaderCount(self, tHandle: str) -> int:
- """Get the number of headers in an item."""
- tItem = self._itemIndex[tHandle]
- if isinstance(tItem, IndexNode):
- return len(tItem)
- return 0
-
def getTableOfContents(
self, rHandle: str | None, maxDepth: int, activeOnly: bool = True
) -> list[tuple[str, int, str, int]]:
@@ -750,13 +818,13 @@ class TagsIndex:
}
return
- def tagName(self, tagKey: str) -> str:
+ def tagName(self, tagKey: str, default: str = "") -> str:
"""Get the name of a given tag."""
- return self._tags.get(tagKey.lower(), {}).get("name", "")
+ return self._tags.get(tagKey.lower(), {}).get("name", default)
- def tagDisplay(self, tagKey: str) -> str:
+ def tagDisplay(self, tagKey: str, default: str = "") -> str:
"""Get the display name of a given tag."""
- return self._tags.get(tagKey.lower(), {}).get("display", "")
+ return self._tags.get(tagKey.lower(), {}).get("display", default)
def tagHandle(self, tagKey: str) -> str | None:
"""Get the handle of a given tag."""
@@ -838,10 +906,11 @@ class ItemIndex:
IndexHeading object for each heading of the text.
"""
- __slots__ = ("_project", "_items")
+ __slots__ = ("_project", "_tags", "_items")
- def __init__(self, project: NWProject) -> None:
+ def __init__(self, project: NWProject, tagsIndex: TagsIndex) -> None:
self._project = project
+ self._tags = tagsIndex
self._items: dict[str, IndexNode] = {}
return
@@ -868,7 +937,7 @@ class ItemIndex:
"""Add a new item to the index. This will overwrite the item if
it already exists.
"""
- self._items[tHandle] = IndexNode(tHandle, nwItem)
+ self._items[tHandle] = IndexNode(self._tags, tHandle, nwItem)
return
def allItemTags(self, tHandle: str) -> list[str]:
@@ -926,7 +995,7 @@ class ItemIndex:
if tHandle in self._items:
tItem = self._items[tHandle]
sTitle = tItem.nextHeading()
- tItem.addHeading(IndexHeading(sTitle, lineNo, level, text))
+ tItem.addHeading(IndexHeading(self._tags, sTitle, lineNo, level, text))
return sTitle
return TT_NONE
@@ -997,7 +1066,7 @@ class ItemIndex:
nwItem = self._project.tree[tHandle]
if nwItem is not None:
- tItem = IndexNode(tHandle, nwItem)
+ tItem = IndexNode(self._tags, tHandle, nwItem)
tItem.unpackData(tData)
self._items[tHandle] = tItem
diff --git a/novelwriter/core/indexdata.py b/novelwriter/core/indexdata.py
index 727b0d6e..9aee57c3 100644
--- a/novelwriter/core/indexdata.py
+++ b/novelwriter/core/indexdata.py
@@ -31,10 +31,12 @@ import logging
from collections.abc import ItemsView, Sequence
from typing import TYPE_CHECKING, Literal
+from novelwriter import CONFIG
from novelwriter.common import checkInt, isListInstance, isTitleTag
from novelwriter.constants import nwKeyWords, nwStyles
if TYPE_CHECKING: # pragma: no cover
+ from novelwriter.core.index import TagsIndex
from novelwriter.core.item import NWItem
logger = logging.getLogger(__name__)
@@ -55,12 +57,13 @@ class IndexNode:
must be reset each time the item is re-indexed.
"""
- __slots__ = ("_handle", "_item", "_headings", "_count", "_notes")
+ __slots__ = ("_tags", "_handle", "_item", "_headings", "_notes", "_count")
- def __init__(self, tHandle: str, nwItem: NWItem) -> None:
+ def __init__(self, tagsIndex: TagsIndex, tHandle: str, nwItem: NWItem) -> None:
+ self._tags = tagsIndex
self._handle = tHandle
self._item = nwItem
- self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(TT_NONE)}
+ self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(self._tags, TT_NONE)}
self._notes: dict[str, set[str]] = {}
self._count = 0
return
@@ -178,7 +181,7 @@ class IndexNode:
"""Unpack an item entry from the data."""
for key, entry in data.items():
if isTitleTag(key):
- heading = IndexHeading(key)
+ heading = IndexHeading(self._tags, key)
heading.unpackData(entry)
self.addHeading(heading)
elif key == "document":
@@ -201,9 +204,16 @@ class IndexHeading:
of all references made under the heading.
"""
- __slots__ = ("_key", "_line", "_level", "_title", "_counts", "_tag", "_refs", "_comments")
+ __slots__ = (
+ "_tags", "_key", "_line", "_level", "_title",
+ "_counts", "_tag", "_refs", "_comments",
+ )
- def __init__(self, key: str, line: int = 0, level: str = "H0", title: str = "") -> None:
+ def __init__(
+ self, tagsIndex: TagsIndex, key: str, line: int = 0,
+ level: str = "H0", title: str = "",
+ ) -> None:
+ self._tags = tagsIndex
self._key = key
self._line = line
self._level = level
@@ -237,6 +247,10 @@ class IndexHeading:
def title(self) -> str:
return self._title
+ @property
+ def mainCount(self) -> int:
+ return self._counts[0 if CONFIG.useCharCount else 1]
+
@property
def charCount(self) -> int:
return self._counts[0]
@@ -309,6 +323,27 @@ class IndexHeading:
self._refs[tag].add(keyword)
return
+ ##
+ # Getters
+ ##
+
+ def getReferences(self) -> dict[str, list[str]]:
+ """Extract all references for this heading."""
+ refs = {x: [] for x in nwKeyWords.VALID_KEYS}
+ for tag, types in self._refs.items():
+ for keyword in types:
+ if keyword in refs and (name := self._tags.tagName(tag)):
+ refs[keyword].append(name)
+ return refs
+
+ def getReferencesByKeyword(self, keyword: str) -> list[str]:
+ """Extract all references for this heading."""
+ refs = []
+ for tag, types in self._refs.items():
+ if keyword in types and (name := self._tags.tagName(tag)):
+ refs.append(name)
+ return refs
+
##
# Data Methods
##
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index 38a2dbcc..3bbb8cec 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -293,6 +293,12 @@ class NWItem:
self._project.tree.refreshItems([self._handle])
return
+ def notifyNovelStructureChange(self) -> None:
+ """Notify that the structure of a novel has changed."""
+ if self._root and self._class == nwItemClass.NOVEL:
+ self._project.tree.novelStructureChanged(self._root)
+ return
+
##
# Lookup Methods
##
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index cab012e0..4d52aabd 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -193,7 +193,7 @@ class ProjectNode:
return self._parent
def child(self, row: int) -> ProjectNode | None:
- """Return a child ofg the node."""
+ """Return a child of the node."""
if 0 <= row < len(self._children):
return self._children[row]
return None
@@ -218,6 +218,7 @@ class ProjectNode:
child._row = len(self._children)
self._children.append(child)
self._refreshChildrenPos()
+ self._item.notifyNovelStructureChange()
return
def takeChild(self, pos: int) -> ProjectNode | None:
@@ -226,6 +227,7 @@ class ProjectNode:
node = self._children.pop(pos)
self._refreshChildrenPos()
self.updateCount()
+ self._item.notifyNovelStructureChange()
return node
return None
@@ -236,6 +238,7 @@ class ProjectNode:
node = self._children.pop(source)
self._children.insert(target, node)
self._refreshChildrenPos()
+ self._item.notifyNovelStructureChange()
return
def setExpanded(self, state: bool) -> None:
@@ -331,7 +334,7 @@ class ProjectModel(QAbstractItemModel):
return QModelIndex()
def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex:
- """get the index of a child item of a parent."""
+ """Get the index of a child item of a parent."""
if self.hasIndex(row, column, parent):
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
if child := node.child(row):
diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py
new file mode 100644
index 00000000..b55fa615
--- /dev/null
+++ b/novelwriter/core/novelmodel.py
@@ -0,0 +1,219 @@
+"""
+novelWriter – Novel Model
+=========================
+
+File History:
+Created: 2025-02-22 [2.7b1] NovelModel
+
+This file is a part of novelWriter
+Copyright (C) 2025 Veronica Berglyd Olsen and novelWriter contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+from __future__ import annotations
+
+import logging
+
+from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt
+from PyQt6.QtGui import QIcon, QPixmap
+
+from novelwriter import SHARED
+from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst
+from novelwriter.core.indexdata import IndexHeading, IndexNode
+from novelwriter.enum import nwNovelExtra
+from novelwriter.types import QtAlignRight
+
+logger = logging.getLogger(__name__)
+
+C_FACTOR = 0x0100
+
+R_TEXT = Qt.ItemDataRole.DisplayRole
+R_ICON = Qt.ItemDataRole.DecorationRole
+R_ALIGN = Qt.ItemDataRole.TextAlignmentRole
+R_TIP = Qt.ItemDataRole.ToolTipRole
+R_HANDLE = 0xff01
+R_KEY = 0xff02
+
+T_NodeData = str | QIcon | QPixmap | Qt.AlignmentFlag | None
+
+
+class NovelModel(QAbstractTableModel):
+
+ __slots__ = ("_rows", "_more", "_columns", "_extraKey", "_extraLabel")
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._rows: list[dict[int, T_NodeData]] = []
+ self._more = SHARED.theme.getIcon("more_arrow")
+ self._columns = 3
+ self._extraKey = ""
+ self._extraLabel = ""
+ return
+
+ def __del__(self) -> None: # pragma: no cover
+ logger.debug("Delete: NovelModel")
+ return
+
+ ##
+ # Properties
+ ##
+
+ @property
+ def columns(self) -> int:
+ """Return the number of columns."""
+ return self._columns
+
+ ##
+ # Setters
+ ##
+
+ def setExtraColumn(self, extra: nwNovelExtra) -> None:
+ """Set extra data column settings."""
+ match extra:
+ case nwNovelExtra.HIDDEN:
+ self._columns = 3
+ self._extraKey = ""
+ self._extraLabel = ""
+ case nwNovelExtra.POV:
+ self._columns = 4
+ self._extraKey = nwKeyWords.POV_KEY
+ self._extraLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])
+ case nwNovelExtra.FOCUS:
+ self._columns = 4
+ self._extraKey = nwKeyWords.FOCUS_KEY
+ self._extraLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])
+ case nwNovelExtra.PLOT:
+ self._columns = 4
+ self._extraKey = nwKeyWords.PLOT_KEY
+ self._extraLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])
+ return
+
+ ##
+ # Model Interface
+ ##
+
+ def rowCount(self, index: QModelIndex) -> int:
+ """Return the number of rows."""
+ return len(self._rows)
+
+ def columnCount(self, index: QModelIndex) -> int:
+ """Return the number of columns."""
+ return self._columns
+
+ def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData:
+ """Return display data for a node."""
+ try:
+ return self._rows[index.row()].get(C_FACTOR*index.column() | role)
+ except Exception:
+ logger.error("Novel model index is inconsistent")
+ return None
+
+ def handle(self, index: QModelIndex) -> str | None:
+ """Return item handle for the row."""
+ try:
+ return self._rows[index.row()].get(R_HANDLE) # type: ignore
+ except Exception:
+ logger.error("Novel model index is inconsistent")
+ return None
+
+ def key(self, index: QModelIndex) -> str | None:
+ """Return item handle for the row."""
+ try:
+ return self._rows[index.row()].get(R_KEY) # type: ignore
+ except Exception:
+ logger.error("Novel model index is inconsistent")
+ return None
+
+ ##
+ # Data Methods
+ ##
+
+ def clear(self) -> None:
+ """Clear the model."""
+ self._rows.clear()
+ return
+
+ def append(self, node: IndexNode) -> None:
+ """Append a node to the model."""
+ handle = node.handle
+ for key, head in node.items():
+ if key != "T0000":
+ self._rows.append(self._generateEntry(handle, key, head))
+ return
+
+ def refresh(self, node: IndexNode) -> bool:
+ """Refresh an index node."""
+ handle = node.handle
+ current = []
+ for i, row in enumerate(self._rows):
+ if row.get(R_HANDLE) == handle:
+ current.append(i)
+
+ if current == []:
+ logger.warning("No novel model entries for '%s'", handle)
+ return False
+
+ cols = self._columns - 1
+ first = current[0]
+ last = current[-1]
+
+ remains = []
+ for key, head in node.items():
+ if key != "T0000":
+ if current:
+ j = current.pop(0)
+ self._rows[j] = self._generateEntry(handle, key, head)
+ else:
+ remains.append((key, head))
+
+ self.dataChanged.emit(self.createIndex(first, 0), self.createIndex(last, cols))
+
+ if remains:
+ # Inserting is safe for out of bounds indices
+ self.beginInsertRows(QModelIndex(), last, last + len(remains) - 1)
+ for k, (key, head) in enumerate(remains, last + 1):
+ self._rows.insert(k, self._generateEntry(handle, key, head))
+ self.endInsertRows()
+ elif current:
+ # Deleting ranges are safe for out of bounds indices
+ self.beginRemoveRows(QModelIndex(), current[0], current[-1])
+ del self._rows[current[0]:current[-1] + 1]
+ self.endRemoveRows()
+
+ return True
+
+ ##
+ # Internal Functions
+ ##
+
+ def _generateEntry(self, handle: str, key: str, head: IndexHeading) -> dict[int, T_NodeData]:
+ """Generate a cache entry."""
+ iLevel = nwStyles.H_LEVEL.get(head.level, 0)
+ data = {}
+ data[C_FACTOR*0 | R_TIP] = head.title
+ data[C_FACTOR*0 | R_TEXT] = head.title
+ data[C_FACTOR*0 | R_ICON] = SHARED.theme.getHeaderDecoration(iLevel)
+ data[C_FACTOR*1 | R_TEXT] = f"{head.mainCount:n}"
+ data[C_FACTOR*1 | R_ALIGN] = QtAlignRight
+ if self._columns == 3:
+ data[C_FACTOR*2 | R_ICON] = self._more
+ else:
+ if self._extraKey and (refs := head.getReferencesByKeyword(self._extraKey)):
+ text = ", ".join(refs)
+ data[C_FACTOR*2 | R_TEXT] = text
+ data[C_FACTOR*2 | R_TIP] = f"{self._extraLabel}: {text}"
+ data[C_FACTOR*3 | R_ICON] = self._more
+ data[R_HANDLE] = handle
+ data[R_KEY] = key
+ return data
diff --git a/novelwriter/core/projectdata.py b/novelwriter/core/projectdata.py
index 60bee01f..ff608874 100644
--- a/novelwriter/core/projectdata.py
+++ b/novelwriter/core/projectdata.py
@@ -69,10 +69,10 @@ class NWProjectData:
self._initCounts = [0, 0]
self._currCounts = [0, 0]
self._lastHandle: dict[str, str | None] = {
- "editor": None,
- "viewer": None,
- "novelTree": None,
- "outline": None,
+ "editor": None,
+ "viewer": None,
+ "novel": None,
+ "outline": None,
}
self._autoReplace: dict[str, str] = {}
self._titleFormat: dict[str, str] = {
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 002ff811..be582526 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -60,7 +60,7 @@ class NWTree:
also used for file names.
"""
- __slots__ = ("_project", "_model", "_items", "_nodes", "_trash")
+ __slots__ = ("_project", "_model", "_items", "_nodes", "_trash", "_ready")
def __init__(self, project: NWProject) -> None:
self._project = project
@@ -68,6 +68,7 @@ class NWTree:
self._items: dict[str, NWItem] = {}
self._nodes: dict[str, ProjectNode] = {}
self._trash = None
+ self._ready = False
logger.debug("Ready: NWTree")
return
@@ -249,6 +250,7 @@ class NWTree:
logger.error("Not all items could be added to project tree")
self._trash = self._getTrashNode()
+ self._ready = True
self._model.endInsertRows()
self._model.layoutChanged.emit()
@@ -278,6 +280,12 @@ class NWTree:
self._model.layoutChanged.emit()
return
+ def novelStructureChanged(self, tHandle: str) -> None:
+ """Emit a novel structure change signal."""
+ if self._ready:
+ SHARED.novelStructureChanged.emit(tHandle)
+ return
+
def checkConsistency(self, prefix: str) -> tuple[int, int]:
"""Check the project tree consistency. Also check the content
folder and add back files that were discovered but were not
diff --git a/novelwriter/enum.py b/novelwriter/enum.py
index c9d871b3..a837d824 100644
--- a/novelwriter/enum.py
+++ b/novelwriter/enum.py
@@ -180,6 +180,14 @@ class nwOutline(Enum):
SYNOP = 19
+class nwNovelExtra(Enum):
+
+ HIDDEN = 0
+ POV = 1
+ FOCUS = 2
+ PLOT = 3
+
+
class nwBuildFmt(Enum):
ODT = 0
diff --git a/novelwriter/extensions/modified.py b/novelwriter/extensions/modified.py
index 67aa7453..8d20912a 100644
--- a/novelwriter/extensions/modified.py
+++ b/novelwriter/extensions/modified.py
@@ -30,15 +30,15 @@ from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
-from PyQt6.QtCore import QSize, Qt, pyqtSignal, pyqtSlot
+from PyQt6.QtCore import QModelIndex, QSize, Qt, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QMouseEvent, QWheelEvent
from PyQt6.QtWidgets import (
QApplication, QComboBox, QDialog, QDoubleSpinBox, QLabel, QSpinBox,
- QToolButton, QWidget
+ QToolButton, QTreeView, QWidget
)
from novelwriter import CONFIG, SHARED
-from novelwriter.types import QtMouseLeft
+from novelwriter.types import QtMouseLeft, QtMouseMiddle
if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain
@@ -99,6 +99,20 @@ class NNonBlockingDialog(NDialog):
return
+class NTreeView(QTreeView):
+
+ middleClicked = pyqtSignal(QModelIndex)
+
+ def mousePressEvent(self, event: QMouseEvent | None) -> None:
+ """Emit a signal on mouse middle click."""
+ if (
+ event and event.button() == QtMouseMiddle
+ and (index := self.indexAt(event.pos())).isValid()
+ ):
+ self.middleClicked.emit(index)
+ return super().mousePressEvent(event)
+
+
class NComboBox(QComboBox):
def __init__(self, parent: QWidget | None = None) -> None:
diff --git a/novelwriter/extensions/novelselector.py b/novelwriter/extensions/novelselector.py
index f568f4ea..ee585a0d 100644
--- a/novelwriter/extensions/novelselector.py
+++ b/novelwriter/extensions/novelselector.py
@@ -26,6 +26,7 @@ from __future__ import annotations
import logging
from PyQt6.QtCore import pyqtSignal, pyqtSlot
+from PyQt6.QtGui import QPalette
from PyQt6.QtWidgets import QComboBox, QWidget
from novelwriter import SHARED
@@ -46,6 +47,7 @@ class NovelSelector(QComboBox):
self._includeAll = False
self._listFormat = None
self.currentIndexChanged.connect(self._indexChanged)
+ self.updateTheme()
return
##
@@ -53,8 +55,11 @@ class NovelSelector(QComboBox):
##
@property
- def handle(self) -> str:
- return self.currentData()
+ def handle(self) -> str | None:
+ """Return the selected handle, if any."""
+ if tHandle := self.currentData():
+ return tHandle
+ return None
@property
def firstHandle(self) -> str | None:
@@ -83,6 +88,14 @@ class NovelSelector(QComboBox):
self._listFormat = value
return
+ def updateTheme(self) -> None:
+ """Update theme colours."""
+ palette = self.palette()
+ palette.setBrush(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, palette.text())
+ self.setPalette(palette)
+ self.refreshNovelList()
+ return
+
##
# Public Slots
##
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index 58230c7d..8638c192 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -115,8 +115,6 @@ class GuiDocEditor(QPlainTextEdit):
editedStatusChanged = pyqtSignal(bool)
itemHandleChanged = pyqtSignal(str)
loadDocumentTagRequest = pyqtSignal(str, Enum)
- novelItemMetaChanged = pyqtSignal(str)
- novelStructureChanged = pyqtSignal()
openDocumentRequest = pyqtSignal(str, Enum, str, bool)
requestNewNoteCreation = pyqtSignal(str, nwItemClass)
requestNextDocument = pyqtSignal(str, bool)
@@ -498,18 +496,8 @@ class GuiDocEditor(QPlainTextEdit):
self.setDocumentChanged(False)
self.docTextChanged.emit(self._docHandle, self._lastEdit)
-
- oldCount = SHARED.project.index.getHandleHeaderCount(tHandle)
SHARED.project.index.scanText(tHandle, text)
- newCount = SHARED.project.index.getHandleHeaderCount(tHandle)
- if self._nwItem.itemClass == nwItemClass.NOVEL:
- if oldCount == newCount:
- self.novelItemMetaChanged.emit(tHandle)
- else:
- self.novelStructureChanged.emit()
-
- # Update the status bar
self.updateStatusMessage.emit(self.tr("Saved Document: {0}").format(self._nwItem.itemName))
return True
diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py
index cb226781..ac98cd83 100644
--- a/novelwriter/gui/noveltree.py
+++ b/novelwriter/gui/noveltree.py
@@ -3,9 +3,11 @@ novelWriter – GUI Novel Tree
============================
File History:
-Created: 2020-12-20 [1.1rc1] GuiNovelTree
-Created: 2022-06-12 [2.0rc1] GuiNovelView
-Created: 2022-06-12 [2.0rc1] GuiNovelToolBar
+Created: 2020-12-20 [1.1rc1] GuiNovelTree
+Created: 2022-06-12 [2.0rc1] GuiNovelView
+Created: 2022-06-12 [2.0rc1] GuiNovelToolBar
+Rewritten: 2025-02-22 [2.7b1] GuiNovelView
+Rewritten: 2025-02-22 [2.7b1] GuiNovelToolBar
This file is a part of novelWriter
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
@@ -28,40 +30,30 @@ from __future__ import annotations
import logging
from enum import Enum
-from time import time
-from PyQt6.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
-from PyQt6.QtGui import QActionGroup, QFocusEvent, QFont, QMouseEvent, QPalette, QResizeEvent
+from PyQt6.QtCore import QModelIndex, QPoint, pyqtSignal, pyqtSlot
+from PyQt6.QtGui import QActionGroup, QFont, QPainter, QPalette, QResizeEvent
from PyQt6.QtWidgets import (
- QAbstractItemView, QFrame, QHBoxLayout, QInputDialog, QMenu, QToolTip,
- QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
+ QAbstractItemView, QFrame, QHBoxLayout, QInputDialog, QMenu,
+ QStyleOptionViewItem, QToolTip, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, qtAddAction, qtAddMenu, qtLambda
-from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst
-from novelwriter.core.indexdata import IndexHeading
-from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwOutline
-from novelwriter.extensions.modified import NIconToolButton
+from novelwriter.constants import nwKeyWords, nwLabels, trConst
+from novelwriter.core.novelmodel import NovelModel
+from novelwriter.enum import nwChange, nwDocMode, nwNovelExtra, nwOutline
+from novelwriter.extensions.modified import NIconToolButton, NTreeView
from novelwriter.extensions.novelselector import NovelSelector
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
from novelwriter.types import (
- QtAlignRight, QtDecoration, QtHeaderStretch, QtHeaderToContents,
- QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded,
- QtSizeExpanding, QtUserRole
+ QtHeaderStretch, QtHeaderToContents, QtScrollAlwaysOff, QtScrollAsNeeded,
+ QtSizeExpanding
)
logger = logging.getLogger(__name__)
-class NovelTreeColumn(Enum):
-
- HIDDEN = 0
- POV = 1
- FOCUS = 2
- PLOT = 3
-
-
class GuiNovelView(QWidget):
# Signals for user interaction with the novel tree
@@ -86,6 +78,7 @@ class GuiNovelView(QWidget):
self.setLayout(self.outerBox)
# Function Mappings
+ self.setActive = self.novelBar.setActive
self.getSelectedHandle = self.novelTree.getSelectedHandle
return
@@ -97,8 +90,6 @@ class GuiNovelView(QWidget):
def updateTheme(self) -> None:
"""Update theme elements."""
self.novelBar.updateTheme()
- self.novelTree.updateTheme()
- self.refreshTree()
return
def initSettings(self) -> None:
@@ -108,21 +99,18 @@ class GuiNovelView(QWidget):
def clearNovelView(self) -> None:
"""Clear project-related GUI content."""
- self.novelTree.clearContent()
self.novelBar.clearContent()
self.novelBar.setEnabled(False)
+ self.novelTree.clearContent()
return
def openProjectTasks(self) -> None:
"""Run open project tasks."""
- lastNovel = SHARED.project.data.getLastHandle("novelTree")
- if lastNovel and lastNovel not in SHARED.project.tree:
- lastNovel = SHARED.project.tree.findRoot(nwItemClass.NOVEL)
-
+ lastNovel = SHARED.project.data.getLastHandle("novel")
logger.debug("Setting novel tree to root item '%s'", lastNovel)
lastCol = SHARED.project.options.getEnum(
- "GuiNovelView", "lastCol", NovelTreeColumn, NovelTreeColumn.HIDDEN
+ "GuiNovelView", "lastCol", nwNovelExtra, nwNovelExtra.HIDDEN
)
lastColSize = SHARED.project.options.getInt(
"GuiNovelView", "lastColSize", 25
@@ -140,13 +128,17 @@ class GuiNovelView(QWidget):
def closeProjectTasks(self) -> None:
"""Run closing project tasks."""
+ logger.debug("Saving State: GuiNovelView")
+
lastColType = self.novelTree.lastColType
lastColSize = self.novelTree.lastColSize
- logger.debug("Saving State: GuiNovelView")
- pOptions = SHARED.project.options
- pOptions.setValue("GuiNovelView", "lastCol", lastColType)
- pOptions.setValue("GuiNovelView", "lastColSize", lastColSize)
+
+ options = SHARED.project.options
+ options.setValue("GuiNovelView", "lastCol", lastColType)
+ options.setValue("GuiNovelView", "lastColSize", lastColSize)
+
self.clearNovelView()
+
return
def setTreeFocus(self) -> None:
@@ -162,32 +154,24 @@ class GuiNovelView(QWidget):
# Public Slots
##
+ @pyqtSlot(str)
+ def setCurrentNovel(self, rootHandle: str | None) -> None:
+ """Set the current novel to display."""
+ self.novelTree.setNovelModel(rootHandle)
+ return
+
@pyqtSlot(str)
def setActiveHandle(self, tHandle: str) -> None:
"""Highlight the rows associated with a given handle."""
self.novelTree.setActiveHandle(tHandle)
return
- @pyqtSlot()
- def refreshTree(self) -> None:
- """Refresh the current tree."""
- self.novelTree.refreshTree(rootHandle=SHARED.project.data.getLastHandle("novelTree"))
- return
-
@pyqtSlot(str, Enum)
def updateRootItem(self, tHandle: str, change: nwChange) -> None:
"""If any root item changes, rebuild the novel root menu."""
self.novelBar.buildNovelRootMenu()
return
- @pyqtSlot(str)
- def updateNovelItemMeta(self, tHandle: str) -> None:
- """The meta data of a novel item has changed, and the tree item
- needs to be refreshed.
- """
- self.novelTree.refreshHandle(tHandle)
- return
-
class GuiNovelToolBar(QWidget):
@@ -198,6 +182,9 @@ class GuiNovelToolBar(QWidget):
self.novelView = novelView
+ self._active = False
+ self._refresh: dict[str, bool] = {}
+
iSz = SHARED.theme.baseIconSize
self.setContentsMargins(0, 0, 0, 0)
@@ -222,7 +209,7 @@ class GuiNovelToolBar(QWidget):
# Refresh Button
self.tbRefresh = NIconToolButton(self, iSz)
self.tbRefresh.setToolTip(self.tr("Refresh"))
- self.tbRefresh.clicked.connect(self._refreshNovelTree)
+ self.tbRefresh.clicked.connect(self._forceRefreshNovelTree)
# More Options Menu
self.mMore = QMenu(self)
@@ -230,10 +217,10 @@ class GuiNovelToolBar(QWidget):
self.mLastCol = qtAddMenu(self.mMore, self.tr("Last Column"))
self.gLastCol = QActionGroup(self.mMore)
self.aLastCol = {}
- self._addLastColAction(NovelTreeColumn.HIDDEN, self.tr("Hidden"))
- self._addLastColAction(NovelTreeColumn.POV, self.tr("Point of View Character"))
- self._addLastColAction(NovelTreeColumn.FOCUS, self.tr("Focus Character"))
- self._addLastColAction(NovelTreeColumn.PLOT, self.tr("Novel Plot"))
+ self._addLastColAction(nwNovelExtra.HIDDEN, self.tr("Hidden"))
+ self._addLastColAction(nwNovelExtra.POV, self.tr("Point of View Character"))
+ self._addLastColAction(nwNovelExtra.FOCUS, self.tr("Focus Character"))
+ self._addLastColAction(nwNovelExtra.PLOT, self.tr("Novel Plot"))
self.mLastCol.addSeparator()
self.aLastColSize = qtAddAction(self.mLastCol, self.tr("Column Size"))
@@ -256,6 +243,9 @@ class GuiNovelToolBar(QWidget):
self.updateTheme()
+ # Connect Signals
+ SHARED.novelStructureChanged.connect(self._refreshNovelTree)
+
logger.debug("Ready: GuiNovelToolBar")
return
@@ -281,9 +271,11 @@ class GuiNovelToolBar(QWidget):
"QComboBox {border-style: none; padding-left: 0;} "
"QComboBox::drop-down {border-style: none}"
)
- self.novelValue.refreshNovelList()
+ self.novelValue.updateTheme()
self.tbNovel.setVisible(self.novelValue.count() > 1)
+ self._forceRefreshNovelTree()
+
return
def clearContent(self) -> None:
@@ -295,19 +287,39 @@ class GuiNovelToolBar(QWidget):
def buildNovelRootMenu(self) -> None:
"""Build the novel root menu."""
self.novelValue.refreshNovelList()
+ self.novelView.setCurrentNovel(self.novelValue.handle)
self.tbNovel.setVisible(self.novelValue.count() > 1)
return
def setCurrentRoot(self, rootHandle: str | None) -> None:
"""Set the current active root handle."""
+ if rootHandle is None or rootHandle not in SHARED.project.tree:
+ rootHandle = self.novelValue.firstHandle
self.novelValue.setHandle(rootHandle)
- self.novelView.novelTree.refreshTree(rootHandle=rootHandle, overRide=True)
+ SHARED.project.data.setLastHandle(rootHandle, "novel")
+ self.novelView.setCurrentNovel(rootHandle)
return
- def setLastColType(self, colType: NovelTreeColumn, doRefresh: bool = True) -> None:
+ def setLastColType(self, colType: nwNovelExtra, doRefresh: bool = True) -> None:
"""Set the last column type."""
self.aLastCol[colType].setChecked(True)
- self.novelView.novelTree.setLastColType(colType, doRefresh=doRefresh)
+ self.novelView.novelTree.setLastColType(colType)
+ if doRefresh:
+ self._forceRefreshNovelTree()
+ self.novelView.novelTree.resizeColumns()
+ return
+
+ def setActive(self, state: bool) -> None:
+ """Set the widget active state, which enables automatic tree
+ refresh when content structure changes.
+ """
+ self._active = state
+ if (
+ self._active
+ and (handle := self.novelValue.handle)
+ and self._refresh.get(handle, False)
+ ):
+ self._refreshNovelTree(self.novelValue.handle)
return
##
@@ -315,10 +327,22 @@ class GuiNovelToolBar(QWidget):
##
@pyqtSlot()
- def _refreshNovelTree(self) -> None:
+ def _forceRefreshNovelTree(self) -> None:
"""Rebuild the current tree."""
- rootHandle = SHARED.project.data.getLastHandle("novelTree")
- self.novelView.novelTree.refreshTree(rootHandle=rootHandle, overRide=True)
+ if tHandle := self.novelValue.handle:
+ self.novelView.setCurrentNovel(tHandle)
+ SHARED.project.index.refreshNovelModel(tHandle)
+ self._refresh[tHandle] = False
+ return
+
+ @pyqtSlot(str)
+ def _refreshNovelTree(self, tHandle: str) -> None:
+ """Refresh or schedule refresh of a novel tree."""
+ if self._active:
+ SHARED.project.index.refreshNovelModel(tHandle)
+ self._refresh[tHandle] = False
+ else:
+ self._refresh[tHandle] = True
return
@pyqtSlot()
@@ -330,14 +354,14 @@ class GuiNovelToolBar(QWidget):
)
if isOk:
self.novelView.novelTree.setLastColSize(newSize)
- self._refreshNovelTree()
+ self.novelView.novelTree.resizeColumns()
return
##
# Internal Functions
##
- def _addLastColAction(self, colType: NovelTreeColumn, actionLabel: str) -> None:
+ def _addLastColAction(self, colType: nwNovelExtra, actionLabel: str) -> None:
"""Add a column selection entry to the last column menu."""
aLast = qtAddAction(self.mLastCol, actionLabel)
aLast.setCheckable(True)
@@ -347,18 +371,7 @@ class GuiNovelToolBar(QWidget):
return
-class GuiNovelTree(QTreeWidget):
-
- C_DATA = 0
- C_TITLE = 0
- C_WORDS = 1
- C_EXTRA = 2
- C_MORE = 3
-
- D_HANDLE = QtUserRole
- D_TITLE = QtUserRole + 1
- D_KEY = QtUserRole + 2
- D_EXTRA = QtUserRole + 3
+class GuiNovelTree(NTreeView):
def __init__(self, novelView: GuiNovelView) -> None:
super().__init__(parent=novelView)
@@ -368,62 +381,30 @@ class GuiNovelTree(QTreeWidget):
self.novelView = novelView
# Internal Variables
- self._lastBuild = 0
- self._lastCol = NovelTreeColumn.POV
- self._lastColSize = 0.25
self._actHandle = None
- self._treeMap: dict[str, QTreeWidgetItem] = {}
+ self._lastColType = nwNovelExtra.POV
+ self._lastColSize = 0.25
- # Cached Strings
- self._povLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])
- self._focLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])
- self._pltLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])
-
- # Build GUI
- # =========
-
- iPx = SHARED.theme.baseIconHeight
- iSz = SHARED.theme.baseIconSize
-
- self.setIconSize(iSz)
+ # Widget Setup
+ self.setIconSize(SHARED.theme.baseIconSize)
self.setFrameStyle(QFrame.Shape.NoFrame)
self.setUniformRowHeights(True)
self.setAllColumnsShowFocus(True)
self.setHeaderHidden(True)
self.setIndentation(2)
- self.setColumnCount(4)
- self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
- self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
- self.setExpandsOnDoubleClick(False)
self.setDragEnabled(False)
- # Lock the column sizes
- if header := self.header():
- header.setStretchLastSection(False)
- header.setMinimumSectionSize(iPx + 6)
- header.setSectionResizeMode(self.C_TITLE, QtHeaderStretch)
- header.setSectionResizeMode(self.C_WORDS, QtHeaderToContents)
- header.setSectionResizeMode(self.C_EXTRA, QtHeaderToContents)
- header.setSectionResizeMode(self.C_MORE, QtHeaderToContents)
-
- # Pre-Generate Tree Formatting
- fH1 = self.font()
- fH1.setBold(True)
- fH1.setUnderline(True)
-
- fH2 = self.font()
- fH2.setBold(True)
-
- self._hFonts = [self.font(), fH1, fH2, self.font(), self.font()]
+ # Set selection options
+ self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
# Connect signals
- self.clicked.connect(self._treeItemClicked)
- self.itemDoubleClicked.connect(self._treeDoubleClick)
- self.itemSelectionChanged.connect(self._treeSelectionChange)
+ self.clicked.connect(self._onSingleClick)
+ self.doubleClicked.connect(self._onDoubleClick)
+ self.middleClicked.connect(self._onMiddleClick)
# Set custom settings
self.initSettings()
- self.updateTheme()
logger.debug("Ready: GuiNovelTree")
@@ -431,23 +412,14 @@ class GuiNovelTree(QTreeWidget):
def initSettings(self) -> None:
"""Set or update tree widget settings."""
- # Scroll bars
if CONFIG.hideVScroll:
self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
else:
self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
-
if CONFIG.hideHScroll:
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
else:
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
-
- return
-
- def updateTheme(self) -> None:
- """Update theme elements."""
- iPx = SHARED.theme.baseIconHeight
- self._pMore = SHARED.theme.getPixmap("more_arrow", (iPx, iPx))
return
##
@@ -455,315 +427,180 @@ class GuiNovelTree(QTreeWidget):
##
@property
- def lastColType(self) -> NovelTreeColumn:
- return self._lastCol
+ def lastColType(self) -> nwNovelExtra:
+ """The data type of the extra column."""
+ return self._lastColType
@property
def lastColSize(self) -> int:
+ """Return the size of the extra column."""
return int(self._lastColSize * 100)
+ ##
+ # Getters
+ ##
+
+ def getSelectedHandle(self) -> tuple[str | None, str | None]:
+ """Get the currently selected or active handle. If multiple
+ items are selected, return the first.
+ """
+ if (model := self._getModel()) and (index := self.currentIndex()).isValid():
+ return model.handle(index), model.key(index)
+ return None, None
+
+ ##
+ # Setters
+ ##
+
+ def setNovelModel(self, tHandle: str | None) -> None:
+ """Set the current novel model."""
+ if tHandle and (model := SHARED.project.index.getNovelModel(tHandle)):
+ if model is not self.model():
+ self.setModel(model)
+ self.resizeColumns()
+ else:
+ self.clearContent()
+ return
+
+ def setActiveHandle(self, tHandle: str | None) -> None:
+ """Set the handle to be highlighted."""
+ self._actHandle = tHandle
+ if viewport := self.viewport():
+ viewport.repaint()
+ return
+
+ def setLastColType(self, colType: nwNovelExtra) -> None:
+ """Set the extra column type."""
+ self._lastColType = colType
+ SHARED.project.index.setNovelModelExtraColumn(colType)
+ return
+
+ def setLastColSize(self, colSize: int) -> None:
+ """Set the extra column size between 15% and 75%."""
+ self._lastColSize = minmax(colSize, 15, 75)/100.0
+ return
+
##
# Class Methods
##
def clearContent(self) -> None:
- """Clear the GUI content and the related maps."""
- self.clear()
- self._treeMap = {}
- self._lastBuild = 0
+ """Clear the tree view."""
+ self.setModel(None)
return
- def refreshTree(self, rootHandle: str | None = None, overRide: bool = False) -> None:
- """Refresh the tree if it has been changed."""
- logger.debug("Requesting refresh of the novel tree")
- if rootHandle is None:
- rootHandle = SHARED.project.tree.findRoot(nwItemClass.NOVEL)
-
- titleKey = None
- if selItems := self.selectedItems():
- titleKey = selItems[0].data(self.C_DATA, self.D_KEY)
-
- self._populateTree(rootHandle)
- SHARED.project.data.setLastHandle(rootHandle, "novelTree")
-
- if titleKey is not None and titleKey in self._treeMap:
- self._treeMap[titleKey].setSelected(True)
-
+ def resizeColumns(self) -> None:
+ """Set the correct column sizes."""
+ if (header := self.header()) and (model := self._getModel()) and (vp := self.viewport()):
+ header.setStretchLastSection(False)
+ header.setMinimumSectionSize(SHARED.theme.baseIconHeight + 6)
+ header.setSectionResizeMode(0, QtHeaderStretch)
+ header.setSectionResizeMode(1, QtHeaderToContents)
+ header.setSectionResizeMode(2, QtHeaderToContents)
+ if model.columns == 4:
+ header.setSectionResizeMode(3, QtHeaderToContents)
+ header.setMaximumSectionSize(int(self._lastColSize * vp.width()))
return
- def refreshHandle(self, tHandle: str) -> None:
- """Refresh the data for a given handle."""
- if idxData := SHARED.project.index.getItemData(tHandle):
- logger.debug("Refreshing meta data for item '%s'", tHandle)
- for sTitle, tHeading in idxData.items():
- sKey = f"{tHandle}:{sTitle}"
- if trItem := self._treeMap.get(sKey, None):
- self._updateTreeItemValues(trItem, tHeading, tHandle, sTitle)
- else:
- logger.debug("Heading '%s' not in novel tree", sKey)
- self.refreshTree()
- return
- return
+ ##
+ # Overloads
+ ##
- def getSelectedHandle(self) -> tuple[str | None, str | None]:
- """Get the currently selected or active handle. If multiple
- items are selected, return the first.
- """
- selList = self.selectedItems()
- trItem = selList[0] if selList else self.currentItem()
- if isinstance(trItem, QTreeWidgetItem):
- tHandle = trItem.data(self.C_DATA, self.D_HANDLE)
- sTitle = trItem.data(self.C_DATA, self.D_TITLE)
- return tHandle, sTitle
- return None, None
-
- def setLastColType(self, colType: NovelTreeColumn, doRefresh: bool = True) -> None:
- """Change the content type of the last column and rebuild."""
- if self._lastCol != colType:
- logger.debug("Changing last column to %s", colType.name)
- self._lastCol = colType
- self.setColumnHidden(self.C_EXTRA, colType == NovelTreeColumn.HIDDEN)
- if doRefresh:
- lastNovel = SHARED.project.data.getLastHandle("novelTree")
- self.refreshTree(rootHandle=lastNovel, overRide=True)
- return
-
- def setLastColSize(self, colSize: int) -> None:
- """Set the column size in integer values between 15 and 75."""
- self._lastColSize = minmax(colSize, 15, 75)/100.0
- return
-
- def setActiveHandle(self, tHandle: str | None) -> None:
- """Highlight the rows associated with a given handle."""
- didScroll = False
- brushOn = self.palette().alternateBase()
- brushOff = self.palette().base()
- if pHandle := self._actHandle:
- for key, item in self._treeMap.items():
- if key.startswith(pHandle):
- for i in range(self.columnCount()):
- item.setBackground(i, brushOff)
- if tHandle:
- for key, item in self._treeMap.items():
- if key.startswith(tHandle):
- for i in range(self.columnCount()):
- item.setBackground(i, brushOn)
- if not didScroll:
- self.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter)
- didScroll = True
- self._actHandle = tHandle or None
+ def drawRow(self, painter: QPainter, opt: QStyleOptionViewItem, index: QModelIndex) -> None:
+ """Draw a box on the active row."""
+ if (model := self._getModel()) and model.handle(index) == self._actHandle:
+ painter.fillRect(opt.rect, self.palette().alternateBase())
+ super().drawRow(painter, opt, index)
return
##
# Events
##
- def mousePressEvent(self, event: QMouseEvent) -> None:
- """Overload mousePressEvent to clear selection if clicking the
- mouse in a blank area of the tree view, and to load a document
- for viewing if the user middle-clicked.
- """
- super().mousePressEvent(event)
-
- if event.button() == QtMouseLeft:
- selItem = self.indexAt(event.pos())
- if not selItem.isValid():
- self.clearSelection()
-
- elif event.button() == QtMouseMiddle:
- selItem = self.itemAt(event.pos())
- if not isinstance(selItem, QTreeWidgetItem):
- return
-
- tHandle, sTitle = self.getSelectedHandle()
- if tHandle is None:
- return
-
- self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, sTitle or "", False)
-
- return
-
- def focusOutEvent(self, event: QFocusEvent) -> None:
- """Clear the selection when the tree no longer has focus."""
- super().focusOutEvent(event)
- self.clearSelection()
- return
-
def resizeEvent(self, event: QResizeEvent) -> None:
- """Elide labels in the extra column."""
+ """Process size changed."""
super().resizeEvent(event)
- newW = event.size().width()
- oldW = event.oldSize().width()
- if newW != oldW:
- eliW = int(self._lastColSize * newW)
- fMetric = self.fontMetrics()
- for i in range(self.topLevelItemCount()):
- trItem = self.topLevelItem(i)
- if isinstance(trItem, QTreeWidgetItem):
- lastText = trItem.data(self.C_DATA, self.D_EXTRA)
- trItem.setText(
- self.C_EXTRA,
- fMetric.elidedText(lastText, Qt.TextElideMode.ElideRight, eliW)
- )
+ self.resizeColumns()
return
##
# Private Slots
##
- @pyqtSlot("QModelIndex")
- def _treeItemClicked(self, index: QModelIndex) -> None:
- """The user clicked on an item in the tree."""
- if index.column() == self.C_MORE:
- tHandle = index.siblingAtColumn(self.C_DATA).data(self.D_HANDLE)
- sTitle = index.siblingAtColumn(self.C_DATA).data(self.D_TITLE)
- tipPos = self.mapToGlobal(self.visualRect(index).topRight())
- self._popMetaBox(tipPos, tHandle, sTitle)
+ @pyqtSlot(QModelIndex)
+ def _onSingleClick(self, index: QModelIndex) -> None:
+ """The user single-clicked an index."""
+ if index.isValid() and (model := self._getModel()):
+ if (tHandle := model.handle(index)) and (sTitle := model.key(index)):
+ self.novelView.selectedItemChanged.emit(tHandle)
+ if index.column() == model.columnCount(index) - 1:
+ pos = self.mapToGlobal(self.visualRect(index).topRight())
+ self._popMetaBox(pos, tHandle, sTitle)
return
- @pyqtSlot()
- def _treeSelectionChange(self) -> None:
- """Extract the handle and line number of the currently selected
- title, and send it to the tree meta panel.
- """
- tHandle, _ = self.getSelectedHandle()
- if tHandle is not None:
- self.novelView.selectedItemChanged.emit(tHandle)
+ @pyqtSlot(QModelIndex)
+ def _onDoubleClick(self, index: QModelIndex) -> None:
+ """The user double-clicked an index."""
+ if (
+ (model := self._getModel())
+ and (tHandle := model.handle(index))
+ and (sTitle := model.key(index))
+ ):
+ self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle, False)
return
- @pyqtSlot("QTreeWidgetItem*", int)
- def _treeDoubleClick(self, item: QTreeWidgetItem, column: int) -> None:
- """Extract the handle and line number of the title double-
- clicked, and send it to the main gui class for opening in the
- document editor.
- """
- tHandle, sTitle = self.getSelectedHandle()
- self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "", True)
+ @pyqtSlot(QModelIndex)
+ def _onMiddleClick(self, index: QModelIndex) -> None:
+ """The user middle-clicked an index."""
+ if (
+ (model := self._getModel())
+ and (tHandle := model.handle(index))
+ and (sTitle := model.key(index))
+ ):
+ self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, sTitle, False)
return
##
# Internal Functions
##
- def _populateTree(self, rootHandle: str | None) -> None:
- """Build the tree based on the project index."""
- self.clearContent()
- tStart = time()
- logger.debug("Building novel tree for root item '%s'", rootHandle)
-
- novStruct = SHARED.project.index.novelStructure(rootHandle=rootHandle, activeOnly=True)
- for tKey, tHandle, sTitle, novIdx in novStruct:
- if novIdx.level == "H0":
- continue
-
- newItem = QTreeWidgetItem()
- newItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
- newItem.setData(self.C_DATA, self.D_TITLE, sTitle)
- newItem.setData(self.C_DATA, self.D_KEY, tKey)
- newItem.setTextAlignment(self.C_WORDS, QtAlignRight)
-
- self._updateTreeItemValues(newItem, novIdx, tHandle, sTitle)
- self._treeMap[tKey] = newItem
- self.addTopLevelItem(newItem)
-
- self.setActiveHandle(self._actHandle)
-
- logger.debug("Novel Tree built in %.3f ms", (time() - tStart)*1000)
- self._lastBuild = time()
-
- return
-
- def _updateTreeItemValues(
- self, trItem: QTreeWidgetItem, idxItem: IndexHeading, tHandle: str, sTitle: str
- ) -> None:
- """Set the tree item values from the index entry."""
- iLevel = nwStyles.H_LEVEL.get(idxItem.level, 0)
- hDec = SHARED.theme.getHeaderDecoration(iLevel)
-
- trItem.setData(self.C_TITLE, QtDecoration, hDec)
- trItem.setText(self.C_TITLE, idxItem.title)
- trItem.setFont(self.C_TITLE, self._hFonts[iLevel])
- trItem.setText(self.C_WORDS, f"{idxItem.wordCount:n}")
- trItem.setData(self.C_MORE, QtDecoration, self._pMore)
-
- # Custom column
- viewport = self.viewport()
- mW = int(self._lastColSize * (viewport.width() if viewport else 100))
- lastText, toolTip = self._getLastColumnText(tHandle, sTitle)
- elideText = self.fontMetrics().elidedText(lastText, Qt.TextElideMode.ElideRight, mW)
- trItem.setText(self.C_EXTRA, elideText)
- trItem.setData(self.C_DATA, self.D_EXTRA, lastText)
- trItem.setToolTip(self.C_EXTRA, toolTip)
-
- return
-
- def _getLastColumnText(self, tHandle: str, sTitle: str) -> tuple[str, str]:
- """Generate text for the last column based on user settings."""
- if self._lastCol == NovelTreeColumn.HIDDEN:
- return "", ""
-
- refData = []
- refName = ""
- refs = SHARED.project.index.getReferences(tHandle, sTitle)
- if self._lastCol == NovelTreeColumn.POV:
- refData = refs[nwKeyWords.POV_KEY]
- refName = self._povLabel
-
- elif self._lastCol == NovelTreeColumn.FOCUS:
- refData = refs[nwKeyWords.FOCUS_KEY]
- refName = self._focLabel
-
- elif self._lastCol == NovelTreeColumn.PLOT:
- refData = refs[nwKeyWords.PLOT_KEY]
- refName = self._pltLabel
-
- if refData:
- toolText = ", ".join(refData)
- return toolText, f"{refName}: {toolText}"
-
- return "", ""
+ def _getModel(self) -> NovelModel | None:
+ """Return the model, if it exists."""
+ if isinstance(model := self.model(), NovelModel):
+ return model
+ return None
def _popMetaBox(self, qPos: QPoint, tHandle: str, sTitle: str) -> None:
"""Show the novel meta data box."""
- logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle)
-
- pIndex = SHARED.project.index
- novIdx = pIndex.getItemHeading(tHandle, sTitle)
- refTags = pIndex.getReferences(tHandle, sTitle)
- if not novIdx:
+ def appendTags(refs: dict, key: str, lines: list[str]) -> None:
+ """Generate a reference list for a given reference key."""
+ if tags := ", ".join(refs.get(key, [])):
+ lines.append(f"{trConst(nwLabels.KEY_NAME[key])}: {tags}")
return
- synopText = novIdx.synopsis
- if synopText:
- synopLabel = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP])
- synopText = f"
{synopLabel}: {synopText}
"
+ if head := SHARED.project.index.getItemHeading(tHandle, sTitle):
+ logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle)
+ if synopsis := head.synopsis:
+ label = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP])
+ synopsis = f"{label}: {synopsis}
"
- refLines = []
- refLines = self._appendMetaTag(refTags, nwKeyWords.POV_KEY, refLines)
- refLines = self._appendMetaTag(refTags, nwKeyWords.FOCUS_KEY, refLines)
- refLines = self._appendMetaTag(refTags, nwKeyWords.CHAR_KEY, refLines)
- refLines = self._appendMetaTag(refTags, nwKeyWords.PLOT_KEY, refLines)
- refLines = self._appendMetaTag(refTags, nwKeyWords.TIME_KEY, refLines)
- refLines = self._appendMetaTag(refTags, nwKeyWords.WORLD_KEY, refLines)
- refLines = self._appendMetaTag(refTags, nwKeyWords.OBJECT_KEY, refLines)
- refLines = self._appendMetaTag(refTags, nwKeyWords.ENTITY_KEY, refLines)
- refLines = self._appendMetaTag(refTags, nwKeyWords.CUSTOM_KEY, refLines)
-
- refText = ""
- if refLines:
- refList = "
".join(refLines)
- refText = f"{refList}
"
-
- ttText = refText + synopText or self.tr("No meta data")
- if ttText:
- QToolTip.showText(qPos, ttText)
+ lines = []
+ if head := SHARED.project.index.getItemHeading(tHandle, sTitle):
+ tags = head.getReferences()
+ appendTags(tags, nwKeyWords.POV_KEY, lines)
+ appendTags(tags, nwKeyWords.FOCUS_KEY, lines)
+ appendTags(tags, nwKeyWords.CHAR_KEY, lines)
+ appendTags(tags, nwKeyWords.PLOT_KEY, lines)
+ appendTags(tags, nwKeyWords.TIME_KEY, lines)
+ appendTags(tags, nwKeyWords.WORLD_KEY, lines)
+ appendTags(tags, nwKeyWords.OBJECT_KEY, lines)
+ appendTags(tags, nwKeyWords.ENTITY_KEY, lines)
+ appendTags(tags, nwKeyWords.CUSTOM_KEY, lines)
+ text = ""
+ if lines:
+ refs = "
".join(lines)
+ text = f"{refs}
"
+ if tooltip := (text + synopsis or self.tr("No meta data")):
+ QToolTip.showText(qPos, tooltip)
return
-
- @staticmethod
- def _appendMetaTag(refs: dict, key: str, lines: list[str]) -> list[str]:
- """Generate a reference list for a given reference key."""
- tags = ", ".join(refs.get(key, []))
- if tags:
- lines.append(f"{trConst(nwLabels.KEY_NAME[key])}: {tags}")
- return lines
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 46ae15f5..7e15c2fa 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -517,7 +517,6 @@ class GuiProjectTree(QTreeView):
def initSettings(self) -> None:
"""Set or update tree widget settings."""
- # Scroll bars
if CONFIG.hideVScroll:
self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
else:
@@ -1022,7 +1021,7 @@ class GuiProjectTree(QTreeView):
return [i for i in self.selectedIndexes() if i.column() == 0]
def _getModel(self) -> ProjectModel | None:
- """Return a project node corresponding to a model index."""
+ """Return the model, if it exists."""
if isinstance(model := self.model(), ProjectModel):
return model
return None
@@ -1400,6 +1399,7 @@ class _TreeContextMenu(QMenu):
if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
self._item.setLayout(nwItemLayout.DOCUMENT)
self._item.notifyToRefresh()
+ self._item.notifyNovelStructureChange()
elif itemLayout == nwItemLayout.NOTE:
self._item.setLayout(nwItemLayout.NOTE)
self._item.notifyToRefresh()
@@ -1416,6 +1416,7 @@ class _TreeContextMenu(QMenu):
self._item.setType(nwItemType.FILE)
self._item.setLayout(nwItemLayout.DOCUMENT)
self._item.notifyToRefresh()
+ self._item.notifyNovelStructureChange()
elif msgYes and itemLayout == nwItemLayout.NOTE:
self._item.setType(nwItemType.FILE)
self._item.setLayout(nwItemLayout.NOTE)
diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py
index d7578389..729db7ee 100644
--- a/novelwriter/gui/theme.py
+++ b/novelwriter/gui/theme.py
@@ -694,7 +694,7 @@ class GuiIcons:
else:
icon = self._loadIcon(name, color, w, h)
self._qIcons[key] = icon
- logger.info("Icon: %s", key)
+ logger.debug("Icon: %s", key)
return icon
def getToggleIcon(self, name: str, size: tuple[int, int], color: str | None = None) -> QIcon:
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 607bec50..3f5ca87d 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -247,8 +247,6 @@ class GuiMain(QMainWindow):
self.docEditor.itemHandleChanged.connect(self.novelView.setActiveHandle)
self.docEditor.itemHandleChanged.connect(self.projView.setActiveHandle)
self.docEditor.loadDocumentTagRequest.connect(self._followTag)
- self.docEditor.novelItemMetaChanged.connect(self.novelView.updateNovelItemMeta)
- self.docEditor.novelStructureChanged.connect(self.novelView.refreshTree)
self.docEditor.openDocumentRequest.connect(self._openDocument)
self.docEditor.requestNewNoteCreation.connect(SHARED.createNewNote)
self.docEditor.requestNextDocument.connect(self.openNextDocument)
@@ -532,7 +530,7 @@ class GuiMain(QMainWindow):
) -> bool:
"""Open a specific document, optionally at a given line."""
if not (SHARED.hasProject and tHandle):
- logger.error("Nothing to open open")
+ logger.error("Nothing to open")
return False
if sTitle and tLine is None:
@@ -735,7 +733,6 @@ class GuiMain(QMainWindow):
SHARED.project.index.rebuild()
SHARED.project.tree.refreshAllItems()
- self.novelView.refreshTree()
tEnd = time()
self.mainStatus.setStatusMessage(
@@ -1174,6 +1171,12 @@ class GuiMain(QMainWindow):
)
elif view == nwView.OUTLINE:
self.mainStack.setCurrentWidget(self.outlineView)
+
+ # Set active status
+ isMain = self.mainStack.currentWidget() == self.splitMain
+ isNovel = self.projStack.currentWidget() == self.novelView
+ self.novelView.setActive(isMain and isNovel)
+
return
@pyqtSlot(nwDocAction)
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index 588252d9..17b0a58f 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -63,10 +63,11 @@ class SharedData(QObject):
indexChangedTags = pyqtSignal(list, list)
indexCleared = pyqtSignal()
mainClockTick = pyqtSignal()
+ novelStructureChanged = pyqtSignal(str)
projectItemChanged = pyqtSignal(str, Enum)
- rootFolderChanged = pyqtSignal(str, Enum)
projectStatusChanged = pyqtSignal(bool)
projectStatusMessage = pyqtSignal(str)
+ rootFolderChanged = pyqtSignal(str, Enum)
spellLanguageChanged = pyqtSignal(str, str)
statusLabelsChanged = pyqtSignal(str)
diff --git a/novelwriter/tools/noveldetails.py b/novelwriter/tools/noveldetails.py
index 84692407..9f4bfb26 100644
--- a/novelwriter/tools/noveldetails.py
+++ b/novelwriter/tools/noveldetails.py
@@ -137,9 +137,10 @@ class GuiNovelDetails(NNonBlockingDialog):
def updateValues(self) -> None:
"""Load the dialogs initial values."""
- self.overviewPage.updateProjectData()
- self.overviewPage.novelValueChanged(self.novelSelector.handle)
- self.contentsPage.novelValueChanged(self.novelSelector.handle)
+ if handle := self.novelSelector.handle:
+ self.overviewPage.updateProjectData()
+ self.overviewPage.novelValueChanged(handle)
+ self.contentsPage.novelValueChanged(handle)
return
##
diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx
index cf19d5e5..6b36208e 100644
--- a/sample/nwProject.nwx
+++ b/sample/nwProject.nwx
@@ -1,6 +1,6 @@
-
-
+
+
Sample Project
Jane Smith
@@ -11,7 +11,7 @@
636b6aa9b697b
636b6aa9b697b
- 7031beac91f75
+ 7031beac91f75
7031beac91f75
@@ -66,7 +66,7 @@
Another Scene
-
-
+
Interlude
-
diff --git a/tests/files/nwProject-1.5.nwx b/tests/files/nwProject-1.5.nwx
index 35eab583..41f19af7 100644
--- a/tests/files/nwProject-1.5.nwx
+++ b/tests/files/nwProject-1.5.nwx
@@ -11,7 +11,7 @@
636b6aa9b697b
636b6aa9b697b
- 7031beac91f75
+ 7031beac91f75
7031beac91f75
diff --git a/tests/lipsum/nwProject.nwx b/tests/lipsum/nwProject.nwx
index 8287a1e5..1a3731af 100644
--- a/tests/lipsum/nwProject.nwx
+++ b/tests/lipsum/nwProject.nwx
@@ -11,7 +11,7 @@
7a992350f3eb6
None
- b3643d0f92e32
+ b3643d0f92e32
None
diff --git a/tests/mocked.py b/tests/mocked.py
index b7edeaf9..d28cdbba 100644
--- a/tests/mocked.py
+++ b/tests/mocked.py
@@ -65,7 +65,10 @@ class MockTheme:
self.guiFontBU = QFont()
return
- def getPixmap(self, *a):
+ def getPixmap(self, *a) -> QPixmap:
+ return QPixmap()
+
+ def getHeaderDecoration(self, *a) -> QPixmap:
return QPixmap()
def getIcon(self, *a) -> QIcon:
diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf
index 1a8dd31b..fb701405 100644
--- a/tests/reference/baseConfig_novelwriter.conf
+++ b/tests/reference/baseConfig_novelwriter.conf
@@ -1,5 +1,5 @@
[Meta]
-timestamp = 2025-02-06 11:05:16
+timestamp = 2025-02-22 19:57:47
[Main]
font =
@@ -13,6 +13,7 @@ hidevscroll = False
hidehscroll = False
lastnotes = 0x0
nativefont = True
+usecharcount = False
[Sizes]
mainwindow = 1200, 650
diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
index a99d8333..80142d13 100644
--- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx
+++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
@@ -11,7 +11,7 @@
None
None
- None
+ None
None
diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx
index a83eb731..5d583362 100644
--- a/tests/reference/coreProject_NewRoot_nwProject.nwx
+++ b/tests/reference/coreProject_NewRoot_nwProject.nwx
@@ -11,7 +11,7 @@
None
None
- None
+ None
None
diff --git a/tests/reference/coreTools_DocDuplicator_nwProject.nwx b/tests/reference/coreTools_DocDuplicator_nwProject.nwx
index 30c62652..2f571386 100644
--- a/tests/reference/coreTools_DocDuplicator_nwProject.nwx
+++ b/tests/reference/coreTools_DocDuplicator_nwProject.nwx
@@ -11,7 +11,7 @@
None
None
- None
+ None
None
diff --git a/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx b/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx
index 387cf8da..a35421d0 100644
--- a/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx
+++ b/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx
@@ -11,7 +11,7 @@
None
None
- None
+ None
None
diff --git a/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx b/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx
index cb0a4843..0185bc6a 100644
--- a/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx
+++ b/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx
@@ -11,7 +11,7 @@
None
None
- None
+ None
None
diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx
index d3051769..d6a5ecae 100644
--- a/tests/reference/guiEditor_Main_Final_nwProject.nwx
+++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx
@@ -11,7 +11,7 @@
000000000000f
000000000000f
- 0000000000008
+ 0000000000008
None
diff --git a/tests/reference/guiEditor_Main_Initial_nwProject.nwx b/tests/reference/guiEditor_Main_Initial_nwProject.nwx
index c265fa51..27e64dac 100644
--- a/tests/reference/guiEditor_Main_Initial_nwProject.nwx
+++ b/tests/reference/guiEditor_Main_Initial_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
Jane Doe
@@ -11,7 +11,7 @@
None
None
- None
+ 0000000000008
None
diff --git a/tests/reference/projectXML_ReadLegacy10.nwx b/tests/reference/projectXML_ReadLegacy10.nwx
index 70885cfd..b94b21a1 100644
--- a/tests/reference/projectXML_ReadLegacy10.nwx
+++ b/tests/reference/projectXML_ReadLegacy10.nwx
@@ -11,7 +11,7 @@
None
None
- None
+ None
None
diff --git a/tests/reference/projectXML_ReadLegacy11.nwx b/tests/reference/projectXML_ReadLegacy11.nwx
index 1d93bd02..9697b28a 100644
--- a/tests/reference/projectXML_ReadLegacy11.nwx
+++ b/tests/reference/projectXML_ReadLegacy11.nwx
@@ -11,7 +11,7 @@
None
None
- None
+ None
None
diff --git a/tests/reference/projectXML_ReadLegacy12.nwx b/tests/reference/projectXML_ReadLegacy12.nwx
index d344f378..c87a5b38 100644
--- a/tests/reference/projectXML_ReadLegacy12.nwx
+++ b/tests/reference/projectXML_ReadLegacy12.nwx
@@ -11,7 +11,7 @@
None
None
- None
+ None
None
diff --git a/tests/reference/projectXML_ReadLegacy13.nwx b/tests/reference/projectXML_ReadLegacy13.nwx
index 05a4dfa5..35036927 100644
--- a/tests/reference/projectXML_ReadLegacy13.nwx
+++ b/tests/reference/projectXML_ReadLegacy13.nwx
@@ -11,7 +11,7 @@
None
None
- None
+ None
None
diff --git a/tests/reference/projectXML_ReadLegacy14.nwx b/tests/reference/projectXML_ReadLegacy14.nwx
index 2a7e24d0..d25ee3aa 100644
--- a/tests/reference/projectXML_ReadLegacy14.nwx
+++ b/tests/reference/projectXML_ReadLegacy14.nwx
@@ -11,7 +11,7 @@
None
None
- None
+ None
None
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index 0b24a788..22d06c11 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -28,17 +28,18 @@ import pytest
from novelwriter import SHARED
from novelwriter.constants import nwFiles
-from novelwriter.core.index import Index, IndexNode, TagsIndex
+from novelwriter.core.index import Index, TagsIndex
from novelwriter.core.item import NWItem
+from novelwriter.core.novelmodel import NovelModel
from novelwriter.core.project import NWProject
-from novelwriter.enum import nwComment, nwItemClass, nwItemLayout
+from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwNovelExtra
from tests.mocked import causeException
from tests.tools import C, buildTestProject, cmpFiles
@pytest.mark.core
-def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
+def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, nwGUI, tstPaths):
"""Test core functionality of scanning, saving, loading and checking
the index cache file.
"""
@@ -52,6 +53,18 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
index = Index(project)
assert repr(index) == ""
+ # Check Novel Model
+ model = index.getNovelModel("b3643d0f92e32")
+ assert isinstance(model, NovelModel)
+ assert model.columns == 3
+
+ index.setNovelModelExtraColumn(nwNovelExtra.POV)
+ index.refreshNovelModel("b3643d0f92e32")
+ model = index.getNovelModel("b3643d0f92e32")
+ assert isinstance(model, NovelModel)
+ assert model.columns == 4
+
+ # Re-index
notIndexable = {
"b3643d0f92e32": False, # Novel ROOT
"45e6b01ca35c1": False, # Chapter One FOLDER
@@ -216,7 +229,7 @@ def testCoreIndex_ScanThis(mockGUI):
@pytest.mark.core
-def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd):
+def testCoreIndex_CheckThese(nwGUI, fncPath, mockRnd):
"""Test the tag checker function checkThese."""
project = NWProject()
mockRnd.reset()
@@ -338,7 +351,7 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd):
@pytest.mark.core
-def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
+def testCoreIndex_ScanText(monkeypatch, nwGUI, fncPath, mockRnd):
"""Check the index text scanner."""
project = NWProject()
mockRnd.reset()
@@ -586,7 +599,7 @@ def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
@pytest.mark.core
-def testCoreIndex_CommentKeys(monkeypatch, mockGUI, fncPath, mockRnd):
+def testCoreIndex_CommentKeys(monkeypatch, nwGUI, fncPath, mockRnd):
"""Check the index comment key generator."""
project = NWProject()
mockRnd.reset()
@@ -623,7 +636,7 @@ def testCoreIndex_CommentKeys(monkeypatch, mockGUI, fncPath, mockRnd):
@pytest.mark.core
-def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
+def testCoreIndex_ExtractData(nwGUI, fncPath, mockRnd):
"""Check the index data extraction functions."""
project = NWProject()
mockRnd.reset()
@@ -708,15 +721,6 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
assert wC == 12 # Words in text and title only
assert pC == 2 # Paragraphs in text only
- # getItemData + getHandleHeaderCount
- # ==================================
-
- item = index.getItemData(nHandle)
- assert isinstance(item, IndexNode)
- assert item.headings() == ["T0001"]
- assert index.getHandleHeaderCount(nHandle) == 1
- assert index.getHandleHeaderCount("foo") == 0
-
# getReferences
# =============
@@ -764,7 +768,9 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
# getClassTags
# ============
+ assert index.getClassTags(None) == ["Jane", "John"]
assert index.getClassTags(nwItemClass.CHARACTER) == ["Jane", "John"]
+ assert index.getClassTags(nwItemClass.PLOT) == []
# getTagsData
# ===========
@@ -1151,7 +1157,7 @@ def testCoreIndex_TagsIndex():
@pytest.mark.core
-def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
+def testCoreIndex_ItemIndex(nwGUI, fncPath, mockRnd):
"""Check the ItemIndex class."""
project = NWProject()
mockRnd.reset()
diff --git a/tests/test_core/test_core_indexdata.py b/tests/test_core/test_core_indexdata.py
index e8436825..c4fbb3f9 100644
--- a/tests/test_core/test_core_indexdata.py
+++ b/tests/test_core/test_core_indexdata.py
@@ -22,6 +22,8 @@ from __future__ import annotations
import pytest
+from novelwriter import CONFIG
+from novelwriter.core.index import TagsIndex
from novelwriter.core.indexdata import IndexHeading, IndexNode
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
@@ -33,9 +35,10 @@ def testCoreIndexData_IndexNode(mockGUI):
handle = "0123456789abc"
project = NWProject()
item = NWItem(project, handle)
+ tags = TagsIndex()
# Defaults
- node = IndexNode(handle, item)
+ node = IndexNode(tags, handle, item)
assert node.handle == handle
assert node.item is item
assert str(node) == f""
@@ -44,8 +47,8 @@ def testCoreIndexData_IndexNode(mockGUI):
assert "T0000" in node # Placeholder heading
# Add a heading
- head1 = IndexHeading(node.nextHeading(), line=1, level="H1", title="Heading 1")
- head2 = IndexHeading(node.nextHeading(), line=10, level="H2", title="Heading 2")
+ head1 = IndexHeading(tags, node.nextHeading(), line=1, level="H1", title="Heading 1")
+ head2 = IndexHeading(tags, node.nextHeading(), line=10, level="H2", title="Heading 2")
node.addHeading(head1)
node.addHeading(head2)
assert len(node) == 2
@@ -105,11 +108,12 @@ def testCoreIndexData_IndexNodePackUnpack(mockGUI):
handle = "0123456789abc"
project = NWProject()
item = NWItem(project, handle)
- node = IndexNode(handle, item)
+ tags = TagsIndex()
+ node = IndexNode(tags, handle, item)
# Add some headings and notes
- head1 = IndexHeading(node.nextHeading(), line=1, level="H1", title="Heading 1")
- head2 = IndexHeading(node.nextHeading(), line=10, level="H2", title="Heading 2")
+ head1 = IndexHeading(tags, node.nextHeading(), line=1, level="H1", title="Heading 1")
+ head2 = IndexHeading(tags, node.nextHeading(), line=10, level="H2", title="Heading 2")
node.addHeading(head1)
node.addHeading(head2)
node.setHeadingCounts("T0001", 42, 13, 3)
@@ -128,7 +132,7 @@ def testCoreIndexData_IndexNodePackUnpack(mockGUI):
assert set(data["document"]["footnotes"]) == {"key1", "key2"}
# Create a new node
- new = IndexNode(handle, item)
+ new = IndexNode(tags, handle, item)
# Unpack heading one
data = {"T0001": {"meta": {
@@ -165,7 +169,8 @@ def testCoreIndexData_IndexNodePackUnpack(mockGUI):
def testCoreIndexData_IndexHeading():
"""Test the IndexHeading class."""
# Defaults
- head = IndexHeading("T0001")
+ tags = TagsIndex()
+ head = IndexHeading(tags, "T0001")
assert str(head) == ""
assert repr(head) == ""
assert head.key == "T0001"
@@ -175,6 +180,7 @@ def testCoreIndexData_IndexHeading():
assert head.charCount == 0
assert head.wordCount == 0
assert head.paraCount == 0
+ assert head.mainCount == 0
assert head.synopsis == ""
assert head.tag == ""
assert head.references == {}
@@ -201,6 +207,11 @@ def testCoreIndexData_IndexHeading():
assert head.wordCount == 4
assert head.paraCount == 2
+ # Check Main Count
+ assert head.mainCount == 4
+ CONFIG.useCharCount = True
+ assert head.mainCount == 42
+
# Set Summary
head.setSynopsis("In the beginning ...")
assert head.synopsis == "In the beginning ..."
@@ -231,14 +242,82 @@ def testCoreIndexData_IndexHeading():
assert head.synopsis == "How it started ..."
+@pytest.mark.core
+def testCoreIndexData_IndexHeadingReferences():
+ """Test the IndexHeading references handling."""
+ tags = TagsIndex()
+ head = IndexHeading(tags, "T0001")
+
+ # Add some references
+ head.addReference("Jane", "@pov")
+ head.addReference("Jane", "@char")
+ head.addReference("John", "@char")
+ head.addReference("Main", "@plot")
+ head.addReference("Gun", "@object")
+
+ # With no tagsIndex name set, these should be empty
+ assert head.getReferences() == {
+ "@entity": [],
+ "@plot": [],
+ "@object": [],
+ "@story": [],
+ "@tag": [],
+ "@focus": [],
+ "@custom": [],
+ "@time": [],
+ "@pov": [],
+ "@mention": [],
+ "@char": [],
+ "@location": [],
+ }
+
+ # Set names
+ tags.add("Jane", "Jane", "0000000000000", "T00001", "CHARACTER")
+ tags.add("John", "John", "0000000000000", "T00001", "CHARACTER")
+ tags.add("Main", "Main", "0000000000000", "T00001", "PLOT")
+ tags.add("Gun", "Gun", "0000000000000", "T00001", "OBJECT")
+
+ # Now they should be populated
+ assert head.getReferences() == {
+ "@entity": [],
+ "@plot": ["Main"],
+ "@object": ["Gun"],
+ "@story": [],
+ "@tag": [],
+ "@focus": [],
+ "@custom": [],
+ "@time": [],
+ "@pov": ["Jane"],
+ "@mention": [],
+ "@char": ["Jane", "John"],
+ "@location": [],
+ }
+
+ # Check them individually
+ assert head.getReferencesByKeyword("@entity") == []
+ assert head.getReferencesByKeyword("@plot") == ["Main"]
+ assert head.getReferencesByKeyword("@object") == ["Gun"]
+ assert head.getReferencesByKeyword("@story") == []
+ assert head.getReferencesByKeyword("@tag") == []
+ assert head.getReferencesByKeyword("@focus") == []
+ assert head.getReferencesByKeyword("@custom") == []
+ assert head.getReferencesByKeyword("@time") == []
+ assert head.getReferencesByKeyword("@pov") == ["Jane"]
+ assert head.getReferencesByKeyword("@mention") == []
+ assert head.getReferencesByKeyword("@char") == ["Jane", "John"]
+ assert head.getReferencesByKeyword("@location") == []
+
+
@pytest.mark.core
def testCoreIndexData_IndexHeadingUnpackMeta():
"""Test IndexHeading class meta unpacking."""
+ tags = TagsIndex()
+
# Valid
data = {"meta": {
"level": "H1", "title": "So it Begins", "line": 1, "tag": "begins", "counts": [95, 18, 1]
}}
- head = IndexHeading("T0001")
+ head = IndexHeading(tags, "T0001")
head.unpackData(data)
assert head.level == "H1"
assert head.title == "So it Begins"
@@ -252,7 +331,7 @@ def testCoreIndexData_IndexHeadingUnpackMeta():
data = {"meta": {
"level": "H9", "title": None, "line": None, "tag": None, "counts": [42]
}}
- head = IndexHeading("T0001")
+ head = IndexHeading(tags, "T0001")
head.unpackData(data)
assert head.level == "H0"
assert head.title == "None"
@@ -264,7 +343,7 @@ def testCoreIndexData_IndexHeadingUnpackMeta():
# Empty
data = {"meta": {}}
- head = IndexHeading("T0001")
+ head = IndexHeading(tags, "T0001")
head.unpackData(data)
assert head.level == "H0"
assert head.title == ""
@@ -278,11 +357,13 @@ def testCoreIndexData_IndexHeadingUnpackMeta():
@pytest.mark.core
def testCoreIndexData_IndexHeadingUnpackRefs():
"""Test IndexHeading class refs unpacking."""
+ tags = TagsIndex()
+
# Valid
data = {"refs": {
"jane": "@char,@pov", "john": "@char", "earth": "@location", "space": "@mention,@location"
}}
- head = IndexHeading("T0001")
+ head = IndexHeading(tags, "T0001")
head.unpackData(data)
assert head.references["jane"] == {"@char", "@pov"}
assert head.references["john"] == {"@char"}
@@ -291,18 +372,18 @@ def testCoreIndexData_IndexHeadingUnpackRefs():
# Invalid key
data = {"refs": {0: "@char,@pov"}}
- head = IndexHeading("T0001")
+ head = IndexHeading(tags, "T0001")
with pytest.raises(ValueError, match="Heading reference key must be a string"):
head.unpackData(data)
# Invalid value
data = {"refs": {"jane": None}}
- head = IndexHeading("T0001")
+ head = IndexHeading(tags, "T0001")
with pytest.raises(ValueError, match="Heading reference value must be a string"):
head.unpackData(data)
# Invalid keyword
data = {"refs": {"jane": "@char,@pov,@stuff"}}
- head = IndexHeading("T0001")
+ head = IndexHeading(tags, "T0001")
with pytest.raises(ValueError, match="Heading reference contains an invalid keyword"):
head.unpackData(data)
diff --git a/tests/test_core/test_core_novelmodel.py b/tests/test_core/test_core_novelmodel.py
new file mode 100644
index 00000000..8fae210d
--- /dev/null
+++ b/tests/test_core/test_core_novelmodel.py
@@ -0,0 +1,178 @@
+"""
+novelWriter – Novel Model Tester
+================================
+
+This file is a part of novelWriter
+Copyright (C) 2025 Veronica Berglyd Olsen and novelWriter contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+from __future__ import annotations
+
+import pytest
+
+from PyQt6.QtCore import QModelIndex, Qt
+
+from novelwriter.core.indexdata import IndexHeading
+from novelwriter.core.novelmodel import NovelModel
+from novelwriter.core.project import NWProject
+from novelwriter.enum import nwNovelExtra
+
+from tests.tools import C, buildTestProject
+
+
+@pytest.mark.core
+def testCoreNovelModel_Interface(nwGUI, fncPath, mockRnd):
+ """Test the novel model interface."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+
+ model = project.index.getNovelModel(C.hNovelRoot)
+ assert isinstance(model, NovelModel)
+
+ root = QModelIndex()
+ assert root.row() == -1
+
+ # Initial structure
+ assert model.rowCount(root) == 3
+ assert model.columnCount(root) == 3
+ assert model.data(model.createIndex(0, 0), Qt.ItemDataRole.DisplayRole) == "New Novel"
+ assert model.handle(model.createIndex(0, 0)) == C.hTitlePage
+ assert model.key(model.createIndex(0, 0)) == "T0001"
+
+ # Clear the model and check error handling
+ model.clear()
+
+ assert model.data(root, Qt.ItemDataRole.DisplayRole) is None
+ assert model.handle(root) is None
+ assert model.key(root) is None
+
+
+@pytest.mark.core
+def testCoreNovelModel_Extra(nwGUI, fncPath, mockRnd):
+ """Test the novel model extra column."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+
+ root = QModelIndex()
+ model = project.index.getNovelModel(C.hNovelRoot)
+ assert isinstance(model, NovelModel)
+ assert model.rowCount(root) == 3
+
+ project.index._tagsIndex.add("Jane", "Jane", "0000000000000", "T0001", "CHARACTER")
+ project.index._tagsIndex.add("John", "John", "0000000000000", "T0001", "CHARACTER")
+ project.index._tagsIndex.add("Main", "Main", "0000000000000", "T0001", "PLOT")
+ project.index._tagsIndex.add("Side", "Side", "0000000000000", "T0001", "PLOT")
+
+ scene = project.index._itemIndex[C.hSceneDoc]
+ assert scene is not None
+ scene.addHeadingRef("T0001", ["Jane"], "@pov")
+ scene.addHeadingRef("T0001", ["John"], "@focus")
+ scene.addHeadingRef("T0001", ["Main"], "@plot")
+ scene.addHeadingRef("T0001", ["Side"], "@plot")
+
+ # No extra by default
+ assert model.columns == 3
+ assert model._extraKey == ""
+ assert model._extraLabel == ""
+
+ # Point of view
+ model.setExtraColumn(nwNovelExtra.POV)
+ model.refresh(scene)
+ assert model.columns == 4
+ assert model._extraKey == "@pov"
+ assert model._extraLabel == "Point of View"
+ assert model.data(model.createIndex(2, 2), Qt.ItemDataRole.DisplayRole) == "Jane"
+
+ model.setExtraColumn(nwNovelExtra.HIDDEN)
+ assert model.columns == 3
+
+ # Focus
+ model.setExtraColumn(nwNovelExtra.FOCUS)
+ model.refresh(scene)
+ assert model.columns == 4
+ assert model._extraKey == "@focus"
+ assert model._extraLabel == "Focus"
+ assert model.data(model.createIndex(2, 2), Qt.ItemDataRole.DisplayRole) == "John"
+
+ model.setExtraColumn(nwNovelExtra.HIDDEN)
+ assert model.columns == 3
+
+ # Plot
+ model.setExtraColumn(nwNovelExtra.PLOT)
+ model.refresh(scene)
+ assert model.columns == 4
+ assert model._extraKey == "@plot"
+ assert model._extraLabel == "Plot"
+ assert model.data(model.createIndex(2, 2), Qt.ItemDataRole.DisplayRole) == "Main, Side"
+
+ model.setExtraColumn(nwNovelExtra.HIDDEN)
+ assert model.columns == 3
+
+
+@pytest.mark.core
+def testCoreNovelModel_Data(nwGUI, fncPath, mockRnd):
+ """Test the novel model data methods."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+
+ root = QModelIndex()
+ model = project.index.getNovelModel(C.hNovelRoot)
+ assert isinstance(model, NovelModel)
+ assert model.rowCount(root) == 3
+
+ title = project.index._itemIndex[C.hTitlePage]
+ chapter = project.index._itemIndex[C.hChapterDoc]
+ scene = project.index._itemIndex[C.hSceneDoc]
+
+ assert title is not None
+ assert chapter is not None
+ assert scene is not None
+
+ # Clear the model and try to refresh
+ model.clear()
+ assert model.rowCount(root) == 0
+
+ # Cannot refresh an empty model
+ assert model.refresh(title) is False
+
+ # Add all back
+ model.append(title)
+ model.append(chapter)
+ model.append(scene)
+
+ # Add headings to scene
+ scene.addHeading(IndexHeading(scene._tags, "T0002", 10, "H4", "A Section"))
+ scene.addHeading(IndexHeading(scene._tags, "T0003", 10, "H4", "Another Section"))
+ assert model.refresh(scene) is True
+ assert [
+ model.data(model.createIndex(i, 0), Qt.ItemDataRole.DisplayRole)
+ for i in range(model.rowCount(root))
+ ] == [
+ "New Novel", "New Chapter", "New Scene", "A Section", "Another Section",
+ ]
+
+ # Remove new headings
+ del scene._headings["T0002"]
+ del scene._headings["T0003"]
+ assert model.refresh(scene) is True
+ assert [
+ model.data(model.createIndex(i, 0), Qt.ItemDataRole.DisplayRole)
+ for i in range(model.rowCount(root))
+ ] == [
+ "New Novel", "New Chapter", "New Scene",
+ ]
diff --git a/tests/test_core/test_core_options.py b/tests/test_core/test_core_options.py
index 1db14e13..51a45c8c 100644
--- a/tests/test_core/test_core_options.py
+++ b/tests/test_core/test_core_options.py
@@ -27,7 +27,7 @@ import pytest
from novelwriter.constants import nwFiles
from novelwriter.core.options import OptionState
from novelwriter.core.project import NWProject
-from novelwriter.gui.noveltree import NovelTreeColumn
+from novelwriter.enum import nwNovelExtra
from tests.mocked import causeOSError
@@ -110,7 +110,7 @@ def testCoreOptions_SetGet(mockGUI):
project = NWProject()
options = OptionState(project)
- nwColHidden = NovelTreeColumn.HIDDEN
+ nwColHidden = nwNovelExtra.HIDDEN
# Set invalid values
assert options.setValue("MockGroup", "mockItem", None) is False
@@ -141,7 +141,7 @@ def testCoreOptions_SetGet(mockGUI):
assert options.getFloat("GuiNovelDetails", "mockItem", None) is None # type: ignore
assert options.getBool("GuiNovelDetails", "clearDouble", None) is True # type: ignore
assert options.getBool("GuiNovelDetails", "mockItem", None) is None # type: ignore
- assert options.getEnum("GuiNovelView", "lastCol", NovelTreeColumn, nwColHidden) == nwColHidden
+ assert options.getEnum("GuiNovelView", "lastCol", nwNovelExtra, nwColHidden) == nwColHidden
# Get from non-existent groups
assert options.getValue("SomeGroup", "mockItem", None) is None
@@ -149,4 +149,4 @@ def testCoreOptions_SetGet(mockGUI):
assert options.getInt("SomeGroup", "mockItem", None) is None # type: ignore
assert options.getFloat("SomeGroup", "mockItem", None) is None # type: ignore
assert options.getBool("SomeGroup", "mockItem", None) is None # type: ignore
- assert options.getEnum("SomeGroup", "mockItem", NovelTreeColumn, None) is None # type: ignore
+ assert options.getEnum("SomeGroup", "mockItem", nwNovelExtra, None) is None # type: ignore
diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py
index 20f6965d..39fa9297 100644
--- a/tests/test_core/test_core_projectxml.py
+++ b/tests/test_core/test_core_projectxml.py
@@ -155,7 +155,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, mockGUI, tstPaths, fncPath):
assert data.getLastHandle("editor") == "636b6aa9b697b"
assert data.getLastHandle("viewer") == "636b6aa9b697b"
- assert data.getLastHandle("novelTree") == "7031beac91f75"
+ assert data.getLastHandle("novel") == "7031beac91f75"
assert data.getLastHandle("outline") == "7031beac91f75"
assert data.itemStatus["sf12341"].name == "New"
@@ -284,7 +284,7 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockGUI, mockRnd):
assert data.getLastHandle("editor") is None # Dropped by conversion
assert data.getLastHandle("viewer") is None # Dropped by conversion
- assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.0
+ assert data.getLastHandle("novel") is None # Doesn't exist in 1.0
assert data.getLastHandle("outline") is None # Doesn't exist in 1.0
assert data.itemStatus["s000000"].name == "New"
@@ -429,7 +429,7 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockGUI, mockRnd):
assert data.getLastHandle("editor") is None # Dropped by conversion
assert data.getLastHandle("viewer") is None # Dropped by conversion
- assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.1
+ assert data.getLastHandle("novel") is None # Doesn't exist in 1.1
assert data.getLastHandle("outline") is None # Doesn't exist in 1.1
assert data.itemStatus["s000000"].name == "New"
@@ -574,7 +574,7 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockGUI, mockRnd):
assert data.getLastHandle("editor") is None # Dropped by conversion
assert data.getLastHandle("viewer") is None # Dropped by conversion
- assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.2
+ assert data.getLastHandle("novel") is None # Doesn't exist in 1.2
assert data.getLastHandle("outline") is None # Doesn't exist in 1.2
assert data.itemStatus["s000000"].name == "New"
@@ -722,7 +722,7 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockGUI, mockRnd):
assert data.getLastHandle("editor") is None # Dropped by conversion
assert data.getLastHandle("viewer") is None # Dropped by conversion
- assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.3
+ assert data.getLastHandle("novel") is None # Doesn't exist in 1.3
assert data.getLastHandle("outline") is None # Doesn't exist in 1.3
assert data.itemStatus["s000000"].name == "New"
@@ -870,7 +870,7 @@ def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockGUI, mockRnd):
assert data.getLastHandle("editor") is None # Dropped by conversion
assert data.getLastHandle("viewer") is None # Dropped by conversion
- assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.3
+ assert data.getLastHandle("novel") is None # Doesn't exist in 1.3
assert data.getLastHandle("outline") is None # Doesn't exist in 1.3
assert data.itemStatus["sf12341"].name == "New"
diff --git a/tests/test_ext/test_ext_modified.py b/tests/test_ext/test_ext_modified.py
index 161531da..844d47a6 100644
--- a/tests/test_ext/test_ext_modified.py
+++ b/tests/test_ext/test_ext_modified.py
@@ -23,13 +23,13 @@ from __future__ import annotations
import pytest
from PyQt6.QtCore import QEvent, QPoint, QPointF, Qt
-from PyQt6.QtGui import QKeyEvent, QMouseEvent, QWheelEvent
+from PyQt6.QtGui import QKeyEvent, QMouseEvent, QStandardItem, QStandardItemModel, QWheelEvent
from PyQt6.QtWidgets import QWidget
from novelwriter.extensions.modified import (
- NClickableLabel, NComboBox, NDialog, NDoubleSpinBox, NSpinBox
+ NClickableLabel, NComboBox, NDialog, NDoubleSpinBox, NSpinBox, NTreeView
)
-from novelwriter.types import QtModNone, QtMouseLeft, QtRejected
+from novelwriter.types import QtModNone, QtMouseLeft, QtMouseMiddle, QtRejected
from tests.tools import SimpleDialog
@@ -66,6 +66,32 @@ def testExtModified_NDialog(qtbot, monkeypatch):
assert dialog.result() == QtRejected
+@pytest.mark.gui
+def testExtModified_NTreeView(qtbot, monkeypatch):
+ """Test the NTreeView class."""
+ model = QStandardItemModel(1, 1)
+ model.insertRow(0, QStandardItem("Hello World"))
+
+ widget = NTreeView()
+ widget.setModel(model)
+ dialog = SimpleDialog(widget)
+ dialog.show()
+
+ vPort = widget.viewport()
+ item = model.item(0, 0)
+ assert vPort is not None
+ assert item is not None
+
+ position = QPointF(widget.visualRect(model.createIndex(0, 0)).center())
+ event = QMouseEvent(
+ QEvent.Type.MouseButtonPress, position, QtMouseMiddle, QtMouseMiddle, QtModNone
+ )
+ with qtbot.waitSignal(widget.middleClicked):
+ widget.mousePressEvent(event)
+
+ # qtbot.stop()
+
+
@pytest.mark.gui
def testExtModified_NComboBox(qtbot, monkeypatch):
"""Test the NComboBox class."""
diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py
index 42cf1cb1..c0b5e517 100644
--- a/tests/test_gui/test_gui_guimain.py
+++ b/tests/test_gui/test_gui_guimain.py
@@ -151,12 +151,12 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Novel Tree has focus
nwGUI._changeView(nwView.NOVEL)
- nwGUI.novelView.novelTree.refreshTree(rootHandle=None, overRide=True)
with monkeypatch.context() as mp:
mp.setattr(GuiNovelView, "treeHasFocus", lambda *a: True)
assert nwGUI.docEditor.docHandle is None
- selItem = nwGUI.novelView.novelTree.topLevelItem(2)
- nwGUI.novelView.novelTree.setCurrentItem(selItem)
+ model = nwGUI.novelView.novelTree._getModel()
+ assert model is not None
+ nwGUI.novelView.novelTree.setCurrentIndex(model.createIndex(2, 0))
nwGUI._keyPressReturn()
assert nwGUI.docEditor.docHandle == sHandle
nwGUI.closeDocument()
diff --git a/tests/test_gui/test_gui_noveltree.py b/tests/test_gui/test_gui_noveltree.py
index 9ec5f352..2da937bd 100644
--- a/tests/test_gui/test_gui_noveltree.py
+++ b/tests/test_gui/test_gui_noveltree.py
@@ -24,21 +24,19 @@ from pathlib import Path
import pytest
-from PyQt6.QtCore import QEvent, QPoint, Qt
-from PyQt6.QtGui import QFocusEvent
+from PyQt6.QtCore import QModelIndex, QPoint, Qt
from PyQt6.QtWidgets import QInputDialog, QToolTip
from novelwriter import CONFIG, SHARED
+from novelwriter.core.novelmodel import NovelModel
from novelwriter.dialogs.editlabel import GuiEditLabel
-from novelwriter.enum import nwFocus, nwItemType
-from novelwriter.gui.noveltree import GuiNovelTree, NovelTreeColumn
-from novelwriter.types import QtMouseLeft, QtMouseMiddle
+from novelwriter.enum import nwFocus, nwItemType, nwNovelExtra, nwView
from tests.tools import C, buildTestProject
@pytest.mark.gui
-def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
+def testGuiNovelView_Content(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test navigating the novel tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
@@ -51,20 +49,22 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
contentPath = SHARED.project.storage.contentPath
assert isinstance(contentPath, Path)
+ cHandle = "0000000000010"
- (contentPath / "0000000000010.nwd").write_text(
+ (contentPath / f"{cHandle}.nwd").write_text(
"# Jane Doe\n\n@tag: Jane\n\n", encoding="utf-8"
)
- (contentPath / "000000000000f.nwd").write_text((
+ (contentPath / f"{C.hSceneDoc}.nwd").write_text((
"### Scene One\n\n"
"@pov: Jane\n"
"@focus: Jane\n\n"
- "% Synopsis: This is a scene."
+ "% Synopsis: This is a scene.\n\n"
+ "This is some text in the edited scene."
), encoding="utf-8")
novelView = nwGUI.novelView
- novelTree = novelView.novelTree
- novelBar = novelView.novelBar
+ novelTree = nwGUI.novelView.novelTree
+ novelBar = nwGUI.novelView.novelBar
# Show/Hide Scrollbars
# ====================
@@ -83,153 +83,101 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Populate Tree
# =============
+ root = QModelIndex()
novelView.setTreeFocus()
+ nwGUI._changeView(nwView.NOVEL)
- nwGUI.projStack.setCurrentWidget(nwGUI.novelView)
- nwGUI.rebuildIndex()
- novelTree._populateTree(rootHandle=None)
- assert novelTree.topLevelItemCount() == 3
+ # Clear tree
+ novelView.setCurrentNovel(None)
+ assert novelTree._getModel() is None
- # Rebuild should preserve selection
- topItem = novelTree.topLevelItem(0)
- assert not topItem.isSelected()
- topItem.setSelected(True)
- assert novelTree.selectedItems()[0] == topItem
- assert novelView.getSelectedHandle() == (C.hTitlePage, "T0001")
+ # Reload
+ novelBar._forceRefreshNovelTree()
+ model = novelTree._getModel()
+ assert isinstance(model, NovelModel)
- # Refresh using the slot for the button
- novelBar._refreshNovelTree()
- assert novelTree.topLevelItem(0).isSelected()
+ # Check the items
+ assert model.rowCount(root) == 3
+ assert model.columnCount(root) == 3
+ assert model.data(model.createIndex(2, 1), Qt.ItemDataRole.DisplayRole) == "2" # Word Count
+
+ nwGUI.rebuildIndex() # This should update the word count to the edited scene
+ assert model.data(model.createIndex(2, 1), Qt.ItemDataRole.DisplayRole) == "10" # Word Count
+
+ # Extra Column
+ # ============
+ novelBar.setLastColType(nwNovelExtra.POV)
+ assert model.rowCount(root) == 3
+ assert model.columnCount(root) == 4
+
+ # Scene column should contain the POV character
+ assert model.data(model.createIndex(2, 2), Qt.ItemDataRole.DisplayRole) == "Jane"
+
+ # Resize the last column
+ assert novelTree.lastColSize == 25
+ with monkeypatch.context() as mp:
+ mp.setattr(QInputDialog, "getInt", lambda *a, **k: (40, True))
+ novelBar._selectLastColumnSize()
+ assert novelTree.lastColSize == 40
# Open Items
# ==========
# Clear selection
novelTree.clearSelection()
- scItem = novelTree.topLevelItem(2)
- scItem.setSelected(True)
- assert scItem.isSelected()
+ assert novelView.getSelectedHandle() == (None, None)
- # Clear selection with mouse
- vPort = novelTree.viewport()
- qtbot.mouseClick(vPort, QtMouseLeft, pos=vPort.rect().center(), delay=10)
- assert not scItem.isSelected()
+ # Select scene
+ novelTree.setCurrentIndex(model.createIndex(2, 0))
+ assert novelView.getSelectedHandle() == (C.hSceneDoc, "T0001")
# Double-click item
- scItem.setSelected(True)
- assert scItem.isSelected()
- assert nwGUI.docEditor.docHandle is None
- novelTree._treeDoubleClick(scItem, 0)
+ novelTree._onDoubleClick(model.createIndex(2, 0))
assert nwGUI.docEditor.docHandle == C.hSceneDoc
- # Open item with middle mouse button
- scItem.setSelected(True)
- assert scItem.isSelected()
- assert nwGUI.docViewer.docHandle is None
- qtbot.mouseClick(vPort, QtMouseMiddle, pos=vPort.rect().center(), delay=10)
- assert nwGUI.docViewer.docHandle is None
-
- scRect = novelTree.visualItemRect(scItem)
- oldData = scItem.data(novelTree.C_TITLE, novelTree.D_HANDLE)
- scItem.setData(novelTree.C_TITLE, novelTree.D_HANDLE, None)
- qtbot.mouseClick(vPort, QtMouseMiddle, pos=scRect.center(), delay=10)
- assert nwGUI.docViewer.docHandle is None
-
- scItem.setData(novelTree.C_TITLE, novelTree.D_HANDLE, oldData)
- qtbot.mouseClick(vPort, QtMouseMiddle, pos=scRect.center(), delay=10)
+ # Middle-click item
+ novelTree._onMiddleClick(model.createIndex(2, 0))
assert nwGUI.docViewer.docHandle == C.hSceneDoc
- # Last Column
- # ===========
-
- novelBar.setLastColType(NovelTreeColumn.HIDDEN)
- assert novelTree.isColumnHidden(novelTree.C_EXTRA) is True
- assert novelTree.lastColType == NovelTreeColumn.HIDDEN
- assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == ("", "")
-
- novelBar.setLastColType(NovelTreeColumn.PLOT)
- assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False
- assert novelTree.lastColType == NovelTreeColumn.PLOT
- assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == (
- "", ""
- )
-
- novelBar.setLastColType(NovelTreeColumn.FOCUS)
- assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False
- assert novelTree.lastColType == NovelTreeColumn.FOCUS
- assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == (
- "Jane", "Focus: Jane"
- )
-
- novelBar.setLastColType(NovelTreeColumn.POV)
- assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False
- assert novelTree.lastColType == NovelTreeColumn.POV
- assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == (
- "Jane", "Point of View: Jane"
- )
-
- novelTree._lastCol = None
- assert novelTree._getLastColumnText("0000000000000", "T0000") == ("", "")
-
- # This forces the resizeEvent function to process labels
- spSize = nwGUI.splitMain.sizes()
- nwGUI.splitMain.setSizes([spSize[0] + 10, spSize[1] - 10])
-
- # Resize the last column
- with monkeypatch.context() as mp:
- mp.setattr(QInputDialog, "getInt", lambda *a, **k: (40, True))
- novelBar._selectLastColumnSize()
-
# Item Meta
# =========
- ttText = ""
+ toolTip = ""
def showText(pos, text):
- nonlocal ttText
- ttText = text
+ nonlocal toolTip
+ toolTip = text
- mIndex = novelTree.model().index(2, novelTree.C_MORE)
with monkeypatch.context() as mp:
mp.setattr(QToolTip, "showText", showText)
- ttText = ""
- novelTree._treeItemClicked(mIndex)
- assert ttText == (
- "
Point of View: Jane
Focus: Jane
"
- "Synopsis: This is a scene.
"
+ toolTip = ""
+ novelTree._onSingleClick(model.createIndex(2, 3))
+ assert toolTip == (
+ "Point of View: Jane
Focus: Jane
"
+ "Synopsis: This is a scene.
"
)
- ttText = ""
+ toolTip = ""
novelTree._popMetaBox(QPoint(1, 1), C.hInvalid, "T0001")
- assert ttText == ""
+ assert toolTip == ""
- # Set Default Root
- # ================
- SHARED.project.data.setLastHandle(C.hInvalid, "novelTree")
- novelView.openProjectTasks()
- assert novelBar.novelValue.handle == C.hNovelRoot
+ # Active Status
+ # =============
+ assert novelBar._refresh == {C.hNovelRoot: False}
- # Tree Focus
- # ==========
- with monkeypatch.context() as mp:
- mp.setattr(GuiNovelTree, "hasFocus", lambda *a: False)
- assert novelView.treeHasFocus() is False
- mp.setattr(GuiNovelTree, "hasFocus", lambda *a: True)
- assert novelView.treeHasFocus() is True
+ # Add a document while tree in focus
+ nwGUI._changeView(nwView.PROJECT)
+ assert novelBar._active is False
+ nwGUI.projView.projTree.setSelectedHandle(C.hChapterDir)
+ nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, hLevel=3)
+ assert novelBar._refresh == {C.hNovelRoot: True}
- # Other Checks
- # ============
-
- scItem = novelTree.topLevelItem(2)
- scItem.setSelected(True)
- assert scItem.isSelected()
- novelTree.focusOutEvent(QFocusEvent(QEvent.Type.None_, Qt.FocusReason.MouseFocusReason))
- assert not scItem.isSelected()
+ # Switch back and check that the refresh status is reset
+ nwGUI._changeView(nwView.NOVEL)
+ assert novelBar._refresh == {C.hNovelRoot: False}
# Close
- # =====
-
# qtbot.stop()
nwGUI.closeProject()
diff --git a/tests/test_gui/test_gui_search.py b/tests/test_gui/test_gui_search.py
index 843c1ae9..3110fa3e 100644
--- a/tests/test_gui/test_gui_search.py
+++ b/tests/test_gui/test_gui_search.py
@@ -39,7 +39,6 @@ def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
search = nwGUI.projSearch
def totalCount():
- nonlocal search
res = search.searchResult
return sum(
int(res.topLevelItem(i).text(GuiProjectSearch.C_COUNT).strip("()"))
diff --git a/tests/test_tools/test_tools_manussettings.py b/tests/test_tools/test_tools_manussettings.py
index 13b57464..e5756a18 100644
--- a/tests/test_tools/test_tools_manussettings.py
+++ b/tests/test_tools/test_tools_manussettings.py
@@ -71,7 +71,7 @@ def testToolBuildSettings_Init(qtbot, nwGUI, projPath, mockRnd):
@pyqtSlot(BuildSettings)
def _testNewSettingsReady(new: BuildSettings):
- nonlocal build, triggered
+ nonlocal triggered
assert new is build
triggered = True
diff --git a/tests/test_tools/test_tools_welcome.py b/tests/test_tools/test_tools_welcome.py
index 17047282..42157b2d 100644
--- a/tests/test_tools/test_tools_welcome.py
+++ b/tests/test_tools/test_tools_welcome.py
@@ -131,7 +131,6 @@ def testToolWelcome_Open(qtbot, monkeypatch, nwGUI, fncPath):
# Context Menu
def getMenuForPos(pos: QPoint) -> QMenu | None:
- nonlocal tabOpen
tabOpen._openContextMenu(pos)
for obj in tabOpen.children():
if isinstance(obj, QMenu) and obj.objectName() == "ContextMenu":
diff --git a/tests/tools.py b/tests/tools.py
index 3be25040..7695ae72 100644
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -216,9 +216,11 @@ def buildTestProject(obj: object, projPath: Path) -> None:
project.setProjectChanged(True)
project.saveProject(autoSave=True)
project._valid = True
+ project._tree._ready = True
if nwGUI is not None:
nwGUI.projView.openProjectTasks()
+ nwGUI.novelView.openProjectTasks()
return