Re-implement Novel View as an item model (#2272)

This commit is contained in:
Veronica Berglyd Olsen
2025-03-30 23:04:42 +02:00
committed by GitHub
48 changed files with 1128 additions and 676 deletions
+1 -1
View File
@@ -1173,7 +1173,7 @@ _These Release Notes also include the changes from the 2.2 Beta 1 and 2.2 RC 1 r
**Usability** **Usability**
* Use `Ctrl+K, H` for inserting short description comments (alias to synopsis), drop the space * 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. 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 * 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. status tip feature in Qt, which prints a blank message to the status bar. PR #1630.
+30 -27
View File
@@ -68,23 +68,23 @@ class Config:
"_backupPath", "_backupPath",
"appName", "appHandle", "guiLocale", "guiTheme", "guiSyntax", "guiFont", "hideVScroll", "appName", "appHandle", "guiLocale", "guiTheme", "guiSyntax", "guiFont", "hideVScroll",
"hideHScroll", "lastNotes", "nativeFont", "iconTheme", "iconColTree", "iconColDocs", "hideHScroll", "lastNotes", "nativeFont", "useCharCount", "iconTheme", "iconColTree",
"mainWinSize", "welcomeWinSize", "prefsWinSize", "mainPanePos", "viewPanePos", "iconColDocs", "mainWinSize", "welcomeWinSize", "prefsWinSize", "mainPanePos",
"outlinePanePos", "autoSaveProj", "autoSaveDoc", "emphLabels", "backupOnClose", "viewPanePos", "outlinePanePos", "autoSaveProj", "autoSaveDoc", "emphLabels",
"askBeforeBackup", "askBeforeExit", "textFont", "textWidth", "textMargin", "tabWidth", "backupOnClose", "askBeforeBackup", "askBeforeExit", "textFont", "textWidth", "textMargin",
"cursorWidth", "focusWidth", "hideFocusFooter", "showFullPath", "autoSelect", "doJustify", "tabWidth", "cursorWidth", "focusWidth", "hideFocusFooter", "showFullPath", "autoSelect",
"showTabsNSpaces", "showLineEndings", "showMultiSpaces", "doReplace", "doReplaceSQuote", "doJustify", "showTabsNSpaces", "showLineEndings", "showMultiSpaces", "doReplace",
"doReplaceDQuote", "doReplaceDash", "doReplaceDots", "autoScroll", "autoScrollPos", "doReplaceSQuote", "doReplaceDQuote", "doReplaceDash", "doReplaceDots", "autoScroll",
"scrollPastEnd", "dialogStyle", "allowOpenDial", "dialogLine", "narratorBreak", "autoScrollPos", "scrollPastEnd", "dialogStyle", "allowOpenDial", "dialogLine",
"narratorDialog", "altDialogOpen", "altDialogClose", "highlightEmph", "stopWhenIdle", "narratorBreak", "narratorDialog", "altDialogOpen", "altDialogClose", "highlightEmph",
"userIdleTime", "incNotesWCount", "fmtApostrophe", "fmtSQuoteOpen", "fmtSQuoteClose", "stopWhenIdle", "userIdleTime", "incNotesWCount", "fmtApostrophe", "fmtSQuoteOpen",
"fmtDQuoteOpen", "fmtDQuoteClose", "fmtPadBefore", "fmtPadAfter", "fmtPadThin", "fmtSQuoteClose", "fmtDQuoteOpen", "fmtDQuoteClose", "fmtPadBefore", "fmtPadAfter",
"spellLanguage", "showViewerPanel", "showEditToolBar", "showSessionTime", "viewComments", "fmtPadThin", "spellLanguage", "showViewerPanel", "showEditToolBar", "showSessionTime",
"viewSynopsis", "searchCase", "searchWord", "searchRegEx", "searchLoop", "searchNextFile", "viewComments", "viewSynopsis", "searchCase", "searchWord", "searchRegEx", "searchLoop",
"searchMatchCap", "searchProjCase", "searchProjWord", "searchProjRegEx", "verQtString", "searchNextFile", "searchMatchCap", "searchProjCase", "searchProjWord", "searchProjRegEx",
"verQtValue", "verPyQtString", "verPyQtValue", "verPyString", "osType", "osLinux", "verQtString", "verQtValue", "verPyQtString", "verPyQtValue", "verPyString", "osType",
"osWindows", "osDarwin", "osUnknown", "hostName", "kernelVer", "isDebug", "memInfo", "osLinux", "osWindows", "osDarwin", "osUnknown", "hostName", "kernelVer", "isDebug",
"hasEnchant", "memInfo", "hasEnchant",
) )
LANG_NW = 1 LANG_NW = 1
@@ -157,6 +157,7 @@ class Config:
self.hideHScroll = False # Hide horizontal scroll bars on main widgets self.hideHScroll = False # Hide horizontal scroll bars on main widgets
self.lastNotes = "0x0" # The latest release notes that have been shown self.lastNotes = "0x0" # The latest release notes that have been shown
self.nativeFont = True # Use native font dialog self.nativeFont = True # Use native font dialog
self.useCharCount = False # Use character count as primary count
# Icons # Icons
self.iconTheme = DEF_ICONS # Icons theme self.iconTheme = DEF_ICONS # Icons theme
@@ -591,16 +592,17 @@ class Config:
# Main # Main
sec = "Main" sec = "Main"
self.setGuiFont(conf.rdStr(sec, "font", "")) self.setGuiFont(conf.rdStr(sec, "font", ""))
self.guiTheme = conf.rdStr(sec, "theme", self.guiTheme) self.guiTheme = conf.rdStr(sec, "theme", self.guiTheme)
self.guiSyntax = conf.rdStr(sec, "syntax", self.guiSyntax) self.guiSyntax = conf.rdStr(sec, "syntax", self.guiSyntax)
self.iconTheme = conf.rdStr(sec, "icons", self.iconTheme) self.iconTheme = conf.rdStr(sec, "icons", self.iconTheme)
self.iconColTree = conf.rdStr(sec, "iconcoltree", self.iconColTree) self.iconColTree = conf.rdStr(sec, "iconcoltree", self.iconColTree)
self.iconColDocs = conf.rdBool(sec, "iconcoldocs", self.iconColDocs) self.iconColDocs = conf.rdBool(sec, "iconcoldocs", self.iconColDocs)
self.guiLocale = conf.rdStr(sec, "localisation", self.guiLocale) self.guiLocale = conf.rdStr(sec, "localisation", self.guiLocale)
self.hideVScroll = conf.rdBool(sec, "hidevscroll", self.hideVScroll) self.hideVScroll = conf.rdBool(sec, "hidevscroll", self.hideVScroll)
self.hideHScroll = conf.rdBool(sec, "hidehscroll", self.hideHScroll) self.hideHScroll = conf.rdBool(sec, "hidehscroll", self.hideHScroll)
self.lastNotes = conf.rdStr(sec, "lastnotes", self.lastNotes) self.lastNotes = conf.rdStr(sec, "lastnotes", self.lastNotes)
self.nativeFont = conf.rdBool(sec, "nativefont", self.nativeFont) self.nativeFont = conf.rdBool(sec, "nativefont", self.nativeFont)
self.useCharCount = conf.rdBool(sec, "usecharcount", self.useCharCount)
# Sizes # Sizes
sec = "Sizes" sec = "Sizes"
@@ -717,6 +719,7 @@ class Config:
"hidehscroll": str(self.hideHScroll), "hidehscroll": str(self.hideHScroll),
"lastnotes": str(self.lastNotes), "lastnotes": str(self.lastNotes),
"nativefont": str(self.nativeFont), "nativefont": str(self.nativeFont),
"usecharcount": str(self.useCharCount),
} }
conf["Sizes"] = { conf["Sizes"] = {
+89 -20
View File
@@ -38,7 +38,8 @@ from novelwriter import SHARED
from novelwriter.common import isHandle, isItemClass, isTitleTag, jsonEncode from novelwriter.common import isHandle, isItemClass, isTitleTag, jsonEncode
from novelwriter.constants import nwFiles, nwKeyWords, nwStyles from novelwriter.constants import nwFiles, nwKeyWords, nwStyles
from novelwriter.core.indexdata import NOTE_TYPES, TT_NONE, IndexHeading, IndexNode, T_NoteTypes 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.error import logException
from novelwriter.text.comments import processComment from novelwriter.text.comments import processComment
from novelwriter.text.counting import standardCounter from novelwriter.text.counting import standardCounter
@@ -86,9 +87,13 @@ class Index:
# Storage and State # Storage and State
self._tagsIndex = TagsIndex() self._tagsIndex = TagsIndex()
self._itemIndex = ItemIndex(project) self._itemIndex = ItemIndex(project, self._tagsIndex)
self._indexBroken = False self._indexBroken = False
# Models
self._novelModels: dict[str, NovelModel] = {}
self._novelExtra = nwNovelExtra.HIDDEN
# TimeStamps # TimeStamps
self._indexChange = 0.0 self._indexChange = 0.0
self._rootChange = {} self._rootChange = {}
@@ -106,6 +111,25 @@ class Index:
def indexBroken(self) -> bool: def indexBroken(self) -> bool:
return self._indexBroken 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 # Public Methods
## ##
@@ -128,6 +152,8 @@ class Index:
self.scanText(nwItem.itemHandle, text, blockSignal=True) self.scanText(nwItem.itemHandle, text, blockSignal=True)
self._indexBroken = False self._indexBroken = False
SHARED.emitIndexAvailable(self._project) SHARED.emitIndexAvailable(self._project)
for tHandle in self._novelModels:
self.refreshNovelModel(tHandle)
return return
def deleteHandle(self, tHandle: str) -> None: def deleteHandle(self, tHandle: str) -> None:
@@ -162,6 +188,30 @@ class Index:
return self._rootChange.get(rootHandle, self._indexChange) > float(checkTime) return self._rootChange.get(rootHandle, self._indexChange) > float(checkTime)
return False 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 # Load and Save Index to/from File
## ##
@@ -282,6 +332,10 @@ class Index:
else: else:
self._scanActive(tHandle, tItem, text, itemTags) 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 # Update timestamps for index changes
nowTime = time() nowTime = time()
self._indexChange = nowTime self._indexChange = nowTime
@@ -396,8 +450,9 @@ class Index:
self._itemIndex.setHeadingCounts(tHandle, sTitle, cC, wC, pC) self._itemIndex.setHeadingCounts(tHandle, sTitle, cC, wC, pC)
return return
def _indexKeyword(self, tHandle: str, line: str, sTitle: str, def _indexKeyword(
itemClass: nwItemClass, tags: dict[str, bool]) -> None: 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 """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 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 of active tags is updated so that no longer used tags can be
@@ -422,6 +477,26 @@ class Index:
return 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 # Check @ Lines
## ##
@@ -562,13 +637,6 @@ class Index:
hCount[iLevel] += 1 hCount[iLevel] += 1
return hCount 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( def getTableOfContents(
self, rHandle: str | None, maxDepth: int, activeOnly: bool = True self, rHandle: str | None, maxDepth: int, activeOnly: bool = True
) -> list[tuple[str, int, str, int]]: ) -> list[tuple[str, int, str, int]]:
@@ -750,13 +818,13 @@ class TagsIndex:
} }
return return
def tagName(self, tagKey: str) -> str: def tagName(self, tagKey: str, default: str = "") -> str:
"""Get the name of a given tag.""" """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.""" """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: def tagHandle(self, tagKey: str) -> str | None:
"""Get the handle of a given tag.""" """Get the handle of a given tag."""
@@ -838,10 +906,11 @@ class ItemIndex:
IndexHeading object for each heading of the text. 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._project = project
self._tags = tagsIndex
self._items: dict[str, IndexNode] = {} self._items: dict[str, IndexNode] = {}
return return
@@ -868,7 +937,7 @@ class ItemIndex:
"""Add a new item to the index. This will overwrite the item if """Add a new item to the index. This will overwrite the item if
it already exists. it already exists.
""" """
self._items[tHandle] = IndexNode(tHandle, nwItem) self._items[tHandle] = IndexNode(self._tags, tHandle, nwItem)
return return
def allItemTags(self, tHandle: str) -> list[str]: def allItemTags(self, tHandle: str) -> list[str]:
@@ -926,7 +995,7 @@ class ItemIndex:
if tHandle in self._items: if tHandle in self._items:
tItem = self._items[tHandle] tItem = self._items[tHandle]
sTitle = tItem.nextHeading() sTitle = tItem.nextHeading()
tItem.addHeading(IndexHeading(sTitle, lineNo, level, text)) tItem.addHeading(IndexHeading(self._tags, sTitle, lineNo, level, text))
return sTitle return sTitle
return TT_NONE return TT_NONE
@@ -997,7 +1066,7 @@ class ItemIndex:
nwItem = self._project.tree[tHandle] nwItem = self._project.tree[tHandle]
if nwItem is not None: if nwItem is not None:
tItem = IndexNode(tHandle, nwItem) tItem = IndexNode(self._tags, tHandle, nwItem)
tItem.unpackData(tData) tItem.unpackData(tData)
self._items[tHandle] = tItem self._items[tHandle] = tItem
+41 -6
View File
@@ -31,10 +31,12 @@ import logging
from collections.abc import ItemsView, Sequence from collections.abc import ItemsView, Sequence
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from novelwriter import CONFIG
from novelwriter.common import checkInt, isListInstance, isTitleTag from novelwriter.common import checkInt, isListInstance, isTitleTag
from novelwriter.constants import nwKeyWords, nwStyles from novelwriter.constants import nwKeyWords, nwStyles
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.index import TagsIndex
from novelwriter.core.item import NWItem from novelwriter.core.item import NWItem
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -55,12 +57,13 @@ class IndexNode:
must be reset each time the item is re-indexed. 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._handle = tHandle
self._item = nwItem 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._notes: dict[str, set[str]] = {}
self._count = 0 self._count = 0
return return
@@ -178,7 +181,7 @@ class IndexNode:
"""Unpack an item entry from the data.""" """Unpack an item entry from the data."""
for key, entry in data.items(): for key, entry in data.items():
if isTitleTag(key): if isTitleTag(key):
heading = IndexHeading(key) heading = IndexHeading(self._tags, key)
heading.unpackData(entry) heading.unpackData(entry)
self.addHeading(heading) self.addHeading(heading)
elif key == "document": elif key == "document":
@@ -201,9 +204,16 @@ class IndexHeading:
of all references made under the heading. 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._key = key
self._line = line self._line = line
self._level = level self._level = level
@@ -237,6 +247,10 @@ class IndexHeading:
def title(self) -> str: def title(self) -> str:
return self._title return self._title
@property
def mainCount(self) -> int:
return self._counts[0 if CONFIG.useCharCount else 1]
@property @property
def charCount(self) -> int: def charCount(self) -> int:
return self._counts[0] return self._counts[0]
@@ -309,6 +323,27 @@ class IndexHeading:
self._refs[tag].add(keyword) self._refs[tag].add(keyword)
return 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 # Data Methods
## ##
+6
View File
@@ -293,6 +293,12 @@ class NWItem:
self._project.tree.refreshItems([self._handle]) self._project.tree.refreshItems([self._handle])
return 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 # Lookup Methods
## ##
+5 -2
View File
@@ -193,7 +193,7 @@ class ProjectNode:
return self._parent return self._parent
def child(self, row: int) -> ProjectNode | None: 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): if 0 <= row < len(self._children):
return self._children[row] return self._children[row]
return None return None
@@ -218,6 +218,7 @@ class ProjectNode:
child._row = len(self._children) child._row = len(self._children)
self._children.append(child) self._children.append(child)
self._refreshChildrenPos() self._refreshChildrenPos()
self._item.notifyNovelStructureChange()
return return
def takeChild(self, pos: int) -> ProjectNode | None: def takeChild(self, pos: int) -> ProjectNode | None:
@@ -226,6 +227,7 @@ class ProjectNode:
node = self._children.pop(pos) node = self._children.pop(pos)
self._refreshChildrenPos() self._refreshChildrenPos()
self.updateCount() self.updateCount()
self._item.notifyNovelStructureChange()
return node return node
return None return None
@@ -236,6 +238,7 @@ class ProjectNode:
node = self._children.pop(source) node = self._children.pop(source)
self._children.insert(target, node) self._children.insert(target, node)
self._refreshChildrenPos() self._refreshChildrenPos()
self._item.notifyNovelStructureChange()
return return
def setExpanded(self, state: bool) -> None: def setExpanded(self, state: bool) -> None:
@@ -331,7 +334,7 @@ class ProjectModel(QAbstractItemModel):
return QModelIndex() return QModelIndex()
def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> 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): if self.hasIndex(row, column, parent):
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
if child := node.child(row): if child := node.child(row):
+219
View File
@@ -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 <https://www.gnu.org/licenses/>.
"""
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"<b>{self._extraLabel}:</b> {text}"
data[C_FACTOR*3 | R_ICON] = self._more
data[R_HANDLE] = handle
data[R_KEY] = key
return data
+4 -4
View File
@@ -69,10 +69,10 @@ class NWProjectData:
self._initCounts = [0, 0] self._initCounts = [0, 0]
self._currCounts = [0, 0] self._currCounts = [0, 0]
self._lastHandle: dict[str, str | None] = { self._lastHandle: dict[str, str | None] = {
"editor": None, "editor": None,
"viewer": None, "viewer": None,
"novelTree": None, "novel": None,
"outline": None, "outline": None,
} }
self._autoReplace: dict[str, str] = {} self._autoReplace: dict[str, str] = {}
self._titleFormat: dict[str, str] = { self._titleFormat: dict[str, str] = {
+9 -1
View File
@@ -60,7 +60,7 @@ class NWTree:
also used for file names. also used for file names.
""" """
__slots__ = ("_project", "_model", "_items", "_nodes", "_trash") __slots__ = ("_project", "_model", "_items", "_nodes", "_trash", "_ready")
def __init__(self, project: NWProject) -> None: def __init__(self, project: NWProject) -> None:
self._project = project self._project = project
@@ -68,6 +68,7 @@ class NWTree:
self._items: dict[str, NWItem] = {} self._items: dict[str, NWItem] = {}
self._nodes: dict[str, ProjectNode] = {} self._nodes: dict[str, ProjectNode] = {}
self._trash = None self._trash = None
self._ready = False
logger.debug("Ready: NWTree") logger.debug("Ready: NWTree")
return return
@@ -249,6 +250,7 @@ class NWTree:
logger.error("Not all items could be added to project tree") logger.error("Not all items could be added to project tree")
self._trash = self._getTrashNode() self._trash = self._getTrashNode()
self._ready = True
self._model.endInsertRows() self._model.endInsertRows()
self._model.layoutChanged.emit() self._model.layoutChanged.emit()
@@ -278,6 +280,12 @@ class NWTree:
self._model.layoutChanged.emit() self._model.layoutChanged.emit()
return 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]: def checkConsistency(self, prefix: str) -> tuple[int, int]:
"""Check the project tree consistency. Also check the content """Check the project tree consistency. Also check the content
folder and add back files that were discovered but were not folder and add back files that were discovered but were not
+8
View File
@@ -180,6 +180,14 @@ class nwOutline(Enum):
SYNOP = 19 SYNOP = 19
class nwNovelExtra(Enum):
HIDDEN = 0
POV = 1
FOCUS = 2
PLOT = 3
class nwBuildFmt(Enum): class nwBuildFmt(Enum):
ODT = 0 ODT = 0
+17 -3
View File
@@ -30,15 +30,15 @@ from __future__ import annotations
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING 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.QtGui import QMouseEvent, QWheelEvent
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QComboBox, QDialog, QDoubleSpinBox, QLabel, QSpinBox, QApplication, QComboBox, QDialog, QDoubleSpinBox, QLabel, QSpinBox,
QToolButton, QWidget QToolButton, QTreeView, QWidget
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.types import QtMouseLeft from novelwriter.types import QtMouseLeft, QtMouseMiddle
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain from novelwriter.guimain import GuiMain
@@ -99,6 +99,20 @@ class NNonBlockingDialog(NDialog):
return 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): class NComboBox(QComboBox):
def __init__(self, parent: QWidget | None = None) -> None: def __init__(self, parent: QWidget | None = None) -> None:
+15 -2
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import logging import logging
from PyQt6.QtCore import pyqtSignal, pyqtSlot from PyQt6.QtCore import pyqtSignal, pyqtSlot
from PyQt6.QtGui import QPalette
from PyQt6.QtWidgets import QComboBox, QWidget from PyQt6.QtWidgets import QComboBox, QWidget
from novelwriter import SHARED from novelwriter import SHARED
@@ -46,6 +47,7 @@ class NovelSelector(QComboBox):
self._includeAll = False self._includeAll = False
self._listFormat = None self._listFormat = None
self.currentIndexChanged.connect(self._indexChanged) self.currentIndexChanged.connect(self._indexChanged)
self.updateTheme()
return return
## ##
@@ -53,8 +55,11 @@ class NovelSelector(QComboBox):
## ##
@property @property
def handle(self) -> str: def handle(self) -> str | None:
return self.currentData() """Return the selected handle, if any."""
if tHandle := self.currentData():
return tHandle
return None
@property @property
def firstHandle(self) -> str | None: def firstHandle(self) -> str | None:
@@ -83,6 +88,14 @@ class NovelSelector(QComboBox):
self._listFormat = value self._listFormat = value
return 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 # Public Slots
## ##
-12
View File
@@ -115,8 +115,6 @@ class GuiDocEditor(QPlainTextEdit):
editedStatusChanged = pyqtSignal(bool) editedStatusChanged = pyqtSignal(bool)
itemHandleChanged = pyqtSignal(str) itemHandleChanged = pyqtSignal(str)
loadDocumentTagRequest = pyqtSignal(str, Enum) loadDocumentTagRequest = pyqtSignal(str, Enum)
novelItemMetaChanged = pyqtSignal(str)
novelStructureChanged = pyqtSignal()
openDocumentRequest = pyqtSignal(str, Enum, str, bool) openDocumentRequest = pyqtSignal(str, Enum, str, bool)
requestNewNoteCreation = pyqtSignal(str, nwItemClass) requestNewNoteCreation = pyqtSignal(str, nwItemClass)
requestNextDocument = pyqtSignal(str, bool) requestNextDocument = pyqtSignal(str, bool)
@@ -498,18 +496,8 @@ class GuiDocEditor(QPlainTextEdit):
self.setDocumentChanged(False) self.setDocumentChanged(False)
self.docTextChanged.emit(self._docHandle, self._lastEdit) self.docTextChanged.emit(self._docHandle, self._lastEdit)
oldCount = SHARED.project.index.getHandleHeaderCount(tHandle)
SHARED.project.index.scanText(tHandle, text) 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)) self.updateStatusMessage.emit(self.tr("Saved Document: {0}").format(self._nwItem.itemName))
return True return True
+228 -391
View File
@@ -3,9 +3,11 @@ novelWriter GUI Novel Tree
============================ ============================
File History: File History:
Created: 2020-12-20 [1.1rc1] GuiNovelTree Created: 2020-12-20 [1.1rc1] GuiNovelTree
Created: 2022-06-12 [2.0rc1] GuiNovelView Created: 2022-06-12 [2.0rc1] GuiNovelView
Created: 2022-06-12 [2.0rc1] GuiNovelToolBar 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 This file is a part of novelWriter
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
@@ -28,40 +30,30 @@ from __future__ import annotations
import logging import logging
from enum import Enum from enum import Enum
from time import time
from PyQt6.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot from PyQt6.QtCore import QModelIndex, QPoint, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QActionGroup, QFocusEvent, QFont, QMouseEvent, QPalette, QResizeEvent from PyQt6.QtGui import QActionGroup, QFont, QPainter, QPalette, QResizeEvent
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QAbstractItemView, QFrame, QHBoxLayout, QInputDialog, QMenu, QToolTip, QAbstractItemView, QFrame, QHBoxLayout, QInputDialog, QMenu,
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget QStyleOptionViewItem, QToolTip, QVBoxLayout, QWidget
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, qtAddAction, qtAddMenu, qtLambda from novelwriter.common import minmax, qtAddAction, qtAddMenu, qtLambda
from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst from novelwriter.constants import nwKeyWords, nwLabels, trConst
from novelwriter.core.indexdata import IndexHeading from novelwriter.core.novelmodel import NovelModel
from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwOutline from novelwriter.enum import nwChange, nwDocMode, nwNovelExtra, nwOutline
from novelwriter.extensions.modified import NIconToolButton from novelwriter.extensions.modified import NIconToolButton, NTreeView
from novelwriter.extensions.novelselector import NovelSelector from novelwriter.extensions.novelselector import NovelSelector
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
from novelwriter.types import ( from novelwriter.types import (
QtAlignRight, QtDecoration, QtHeaderStretch, QtHeaderToContents, QtHeaderStretch, QtHeaderToContents, QtScrollAlwaysOff, QtScrollAsNeeded,
QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded, QtSizeExpanding
QtSizeExpanding, QtUserRole
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NovelTreeColumn(Enum):
HIDDEN = 0
POV = 1
FOCUS = 2
PLOT = 3
class GuiNovelView(QWidget): class GuiNovelView(QWidget):
# Signals for user interaction with the novel tree # Signals for user interaction with the novel tree
@@ -86,6 +78,7 @@ class GuiNovelView(QWidget):
self.setLayout(self.outerBox) self.setLayout(self.outerBox)
# Function Mappings # Function Mappings
self.setActive = self.novelBar.setActive
self.getSelectedHandle = self.novelTree.getSelectedHandle self.getSelectedHandle = self.novelTree.getSelectedHandle
return return
@@ -97,8 +90,6 @@ class GuiNovelView(QWidget):
def updateTheme(self) -> None: def updateTheme(self) -> None:
"""Update theme elements.""" """Update theme elements."""
self.novelBar.updateTheme() self.novelBar.updateTheme()
self.novelTree.updateTheme()
self.refreshTree()
return return
def initSettings(self) -> None: def initSettings(self) -> None:
@@ -108,21 +99,18 @@ class GuiNovelView(QWidget):
def clearNovelView(self) -> None: def clearNovelView(self) -> None:
"""Clear project-related GUI content.""" """Clear project-related GUI content."""
self.novelTree.clearContent()
self.novelBar.clearContent() self.novelBar.clearContent()
self.novelBar.setEnabled(False) self.novelBar.setEnabled(False)
self.novelTree.clearContent()
return return
def openProjectTasks(self) -> None: def openProjectTasks(self) -> None:
"""Run open project tasks.""" """Run open project tasks."""
lastNovel = SHARED.project.data.getLastHandle("novelTree") lastNovel = SHARED.project.data.getLastHandle("novel")
if lastNovel and lastNovel not in SHARED.project.tree:
lastNovel = SHARED.project.tree.findRoot(nwItemClass.NOVEL)
logger.debug("Setting novel tree to root item '%s'", lastNovel) logger.debug("Setting novel tree to root item '%s'", lastNovel)
lastCol = SHARED.project.options.getEnum( lastCol = SHARED.project.options.getEnum(
"GuiNovelView", "lastCol", NovelTreeColumn, NovelTreeColumn.HIDDEN "GuiNovelView", "lastCol", nwNovelExtra, nwNovelExtra.HIDDEN
) )
lastColSize = SHARED.project.options.getInt( lastColSize = SHARED.project.options.getInt(
"GuiNovelView", "lastColSize", 25 "GuiNovelView", "lastColSize", 25
@@ -140,13 +128,17 @@ class GuiNovelView(QWidget):
def closeProjectTasks(self) -> None: def closeProjectTasks(self) -> None:
"""Run closing project tasks.""" """Run closing project tasks."""
logger.debug("Saving State: GuiNovelView")
lastColType = self.novelTree.lastColType lastColType = self.novelTree.lastColType
lastColSize = self.novelTree.lastColSize lastColSize = self.novelTree.lastColSize
logger.debug("Saving State: GuiNovelView")
pOptions = SHARED.project.options options = SHARED.project.options
pOptions.setValue("GuiNovelView", "lastCol", lastColType) options.setValue("GuiNovelView", "lastCol", lastColType)
pOptions.setValue("GuiNovelView", "lastColSize", lastColSize) options.setValue("GuiNovelView", "lastColSize", lastColSize)
self.clearNovelView() self.clearNovelView()
return return
def setTreeFocus(self) -> None: def setTreeFocus(self) -> None:
@@ -162,32 +154,24 @@ class GuiNovelView(QWidget):
# Public Slots # Public Slots
## ##
@pyqtSlot(str)
def setCurrentNovel(self, rootHandle: str | None) -> None:
"""Set the current novel to display."""
self.novelTree.setNovelModel(rootHandle)
return
@pyqtSlot(str) @pyqtSlot(str)
def setActiveHandle(self, tHandle: str) -> None: def setActiveHandle(self, tHandle: str) -> None:
"""Highlight the rows associated with a given handle.""" """Highlight the rows associated with a given handle."""
self.novelTree.setActiveHandle(tHandle) self.novelTree.setActiveHandle(tHandle)
return return
@pyqtSlot()
def refreshTree(self) -> None:
"""Refresh the current tree."""
self.novelTree.refreshTree(rootHandle=SHARED.project.data.getLastHandle("novelTree"))
return
@pyqtSlot(str, Enum) @pyqtSlot(str, Enum)
def updateRootItem(self, tHandle: str, change: nwChange) -> None: def updateRootItem(self, tHandle: str, change: nwChange) -> None:
"""If any root item changes, rebuild the novel root menu.""" """If any root item changes, rebuild the novel root menu."""
self.novelBar.buildNovelRootMenu() self.novelBar.buildNovelRootMenu()
return 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): class GuiNovelToolBar(QWidget):
@@ -198,6 +182,9 @@ class GuiNovelToolBar(QWidget):
self.novelView = novelView self.novelView = novelView
self._active = False
self._refresh: dict[str, bool] = {}
iSz = SHARED.theme.baseIconSize iSz = SHARED.theme.baseIconSize
self.setContentsMargins(0, 0, 0, 0) self.setContentsMargins(0, 0, 0, 0)
@@ -222,7 +209,7 @@ class GuiNovelToolBar(QWidget):
# Refresh Button # Refresh Button
self.tbRefresh = NIconToolButton(self, iSz) self.tbRefresh = NIconToolButton(self, iSz)
self.tbRefresh.setToolTip(self.tr("Refresh")) self.tbRefresh.setToolTip(self.tr("Refresh"))
self.tbRefresh.clicked.connect(self._refreshNovelTree) self.tbRefresh.clicked.connect(self._forceRefreshNovelTree)
# More Options Menu # More Options Menu
self.mMore = QMenu(self) self.mMore = QMenu(self)
@@ -230,10 +217,10 @@ class GuiNovelToolBar(QWidget):
self.mLastCol = qtAddMenu(self.mMore, self.tr("Last Column")) self.mLastCol = qtAddMenu(self.mMore, self.tr("Last Column"))
self.gLastCol = QActionGroup(self.mMore) self.gLastCol = QActionGroup(self.mMore)
self.aLastCol = {} self.aLastCol = {}
self._addLastColAction(NovelTreeColumn.HIDDEN, self.tr("Hidden")) self._addLastColAction(nwNovelExtra.HIDDEN, self.tr("Hidden"))
self._addLastColAction(NovelTreeColumn.POV, self.tr("Point of View Character")) self._addLastColAction(nwNovelExtra.POV, self.tr("Point of View Character"))
self._addLastColAction(NovelTreeColumn.FOCUS, self.tr("Focus Character")) self._addLastColAction(nwNovelExtra.FOCUS, self.tr("Focus Character"))
self._addLastColAction(NovelTreeColumn.PLOT, self.tr("Novel Plot")) self._addLastColAction(nwNovelExtra.PLOT, self.tr("Novel Plot"))
self.mLastCol.addSeparator() self.mLastCol.addSeparator()
self.aLastColSize = qtAddAction(self.mLastCol, self.tr("Column Size")) self.aLastColSize = qtAddAction(self.mLastCol, self.tr("Column Size"))
@@ -256,6 +243,9 @@ class GuiNovelToolBar(QWidget):
self.updateTheme() self.updateTheme()
# Connect Signals
SHARED.novelStructureChanged.connect(self._refreshNovelTree)
logger.debug("Ready: GuiNovelToolBar") logger.debug("Ready: GuiNovelToolBar")
return return
@@ -281,9 +271,11 @@ class GuiNovelToolBar(QWidget):
"QComboBox {border-style: none; padding-left: 0;} " "QComboBox {border-style: none; padding-left: 0;} "
"QComboBox::drop-down {border-style: none}" "QComboBox::drop-down {border-style: none}"
) )
self.novelValue.refreshNovelList() self.novelValue.updateTheme()
self.tbNovel.setVisible(self.novelValue.count() > 1) self.tbNovel.setVisible(self.novelValue.count() > 1)
self._forceRefreshNovelTree()
return return
def clearContent(self) -> None: def clearContent(self) -> None:
@@ -295,19 +287,39 @@ class GuiNovelToolBar(QWidget):
def buildNovelRootMenu(self) -> None: def buildNovelRootMenu(self) -> None:
"""Build the novel root menu.""" """Build the novel root menu."""
self.novelValue.refreshNovelList() self.novelValue.refreshNovelList()
self.novelView.setCurrentNovel(self.novelValue.handle)
self.tbNovel.setVisible(self.novelValue.count() > 1) self.tbNovel.setVisible(self.novelValue.count() > 1)
return return
def setCurrentRoot(self, rootHandle: str | None) -> None: def setCurrentRoot(self, rootHandle: str | None) -> None:
"""Set the current active root handle.""" """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.novelValue.setHandle(rootHandle)
self.novelView.novelTree.refreshTree(rootHandle=rootHandle, overRide=True) SHARED.project.data.setLastHandle(rootHandle, "novel")
self.novelView.setCurrentNovel(rootHandle)
return return
def setLastColType(self, colType: NovelTreeColumn, doRefresh: bool = True) -> None: def setLastColType(self, colType: nwNovelExtra, doRefresh: bool = True) -> None:
"""Set the last column type.""" """Set the last column type."""
self.aLastCol[colType].setChecked(True) 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 return
## ##
@@ -315,10 +327,22 @@ class GuiNovelToolBar(QWidget):
## ##
@pyqtSlot() @pyqtSlot()
def _refreshNovelTree(self) -> None: def _forceRefreshNovelTree(self) -> None:
"""Rebuild the current tree.""" """Rebuild the current tree."""
rootHandle = SHARED.project.data.getLastHandle("novelTree") if tHandle := self.novelValue.handle:
self.novelView.novelTree.refreshTree(rootHandle=rootHandle, overRide=True) 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 return
@pyqtSlot() @pyqtSlot()
@@ -330,14 +354,14 @@ class GuiNovelToolBar(QWidget):
) )
if isOk: if isOk:
self.novelView.novelTree.setLastColSize(newSize) self.novelView.novelTree.setLastColSize(newSize)
self._refreshNovelTree() self.novelView.novelTree.resizeColumns()
return return
## ##
# Internal Functions # 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.""" """Add a column selection entry to the last column menu."""
aLast = qtAddAction(self.mLastCol, actionLabel) aLast = qtAddAction(self.mLastCol, actionLabel)
aLast.setCheckable(True) aLast.setCheckable(True)
@@ -347,18 +371,7 @@ class GuiNovelToolBar(QWidget):
return return
class GuiNovelTree(QTreeWidget): class GuiNovelTree(NTreeView):
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
def __init__(self, novelView: GuiNovelView) -> None: def __init__(self, novelView: GuiNovelView) -> None:
super().__init__(parent=novelView) super().__init__(parent=novelView)
@@ -368,62 +381,30 @@ class GuiNovelTree(QTreeWidget):
self.novelView = novelView self.novelView = novelView
# Internal Variables # Internal Variables
self._lastBuild = 0
self._lastCol = NovelTreeColumn.POV
self._lastColSize = 0.25
self._actHandle = None self._actHandle = None
self._treeMap: dict[str, QTreeWidgetItem] = {} self._lastColType = nwNovelExtra.POV
self._lastColSize = 0.25
# Cached Strings # Widget Setup
self._povLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY]) self.setIconSize(SHARED.theme.baseIconSize)
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)
self.setFrameStyle(QFrame.Shape.NoFrame) self.setFrameStyle(QFrame.Shape.NoFrame)
self.setUniformRowHeights(True) self.setUniformRowHeights(True)
self.setAllColumnsShowFocus(True) self.setAllColumnsShowFocus(True)
self.setHeaderHidden(True) self.setHeaderHidden(True)
self.setIndentation(2) self.setIndentation(2)
self.setColumnCount(4)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setExpandsOnDoubleClick(False)
self.setDragEnabled(False) self.setDragEnabled(False)
# Lock the column sizes # Set selection options
if header := self.header(): self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
header.setStretchLastSection(False) self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
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()]
# Connect signals # Connect signals
self.clicked.connect(self._treeItemClicked) self.clicked.connect(self._onSingleClick)
self.itemDoubleClicked.connect(self._treeDoubleClick) self.doubleClicked.connect(self._onDoubleClick)
self.itemSelectionChanged.connect(self._treeSelectionChange) self.middleClicked.connect(self._onMiddleClick)
# Set custom settings # Set custom settings
self.initSettings() self.initSettings()
self.updateTheme()
logger.debug("Ready: GuiNovelTree") logger.debug("Ready: GuiNovelTree")
@@ -431,23 +412,14 @@ class GuiNovelTree(QTreeWidget):
def initSettings(self) -> None: def initSettings(self) -> None:
"""Set or update tree widget settings.""" """Set or update tree widget settings."""
# Scroll bars
if CONFIG.hideVScroll: if CONFIG.hideVScroll:
self.setVerticalScrollBarPolicy(QtScrollAlwaysOff) self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
else: else:
self.setVerticalScrollBarPolicy(QtScrollAsNeeded) self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
if CONFIG.hideHScroll: if CONFIG.hideHScroll:
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff) self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
else: else:
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded) 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 return
## ##
@@ -455,315 +427,180 @@ class GuiNovelTree(QTreeWidget):
## ##
@property @property
def lastColType(self) -> NovelTreeColumn: def lastColType(self) -> nwNovelExtra:
return self._lastCol """The data type of the extra column."""
return self._lastColType
@property @property
def lastColSize(self) -> int: def lastColSize(self) -> int:
"""Return the size of the extra column."""
return int(self._lastColSize * 100) 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 # Class Methods
## ##
def clearContent(self) -> None: def clearContent(self) -> None:
"""Clear the GUI content and the related maps.""" """Clear the tree view."""
self.clear() self.setModel(None)
self._treeMap = {}
self._lastBuild = 0
return return
def refreshTree(self, rootHandle: str | None = None, overRide: bool = False) -> None: def resizeColumns(self) -> None:
"""Refresh the tree if it has been changed.""" """Set the correct column sizes."""
logger.debug("Requesting refresh of the novel tree") if (header := self.header()) and (model := self._getModel()) and (vp := self.viewport()):
if rootHandle is None: header.setStretchLastSection(False)
rootHandle = SHARED.project.tree.findRoot(nwItemClass.NOVEL) header.setMinimumSectionSize(SHARED.theme.baseIconHeight + 6)
header.setSectionResizeMode(0, QtHeaderStretch)
titleKey = None header.setSectionResizeMode(1, QtHeaderToContents)
if selItems := self.selectedItems(): header.setSectionResizeMode(2, QtHeaderToContents)
titleKey = selItems[0].data(self.C_DATA, self.D_KEY) if model.columns == 4:
header.setSectionResizeMode(3, QtHeaderToContents)
self._populateTree(rootHandle) header.setMaximumSectionSize(int(self._lastColSize * vp.width()))
SHARED.project.data.setLastHandle(rootHandle, "novelTree")
if titleKey is not None and titleKey in self._treeMap:
self._treeMap[titleKey].setSelected(True)
return return
def refreshHandle(self, tHandle: str) -> None: ##
"""Refresh the data for a given handle.""" # Overloads
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
def getSelectedHandle(self) -> tuple[str | None, str | None]: def drawRow(self, painter: QPainter, opt: QStyleOptionViewItem, index: QModelIndex) -> None:
"""Get the currently selected or active handle. If multiple """Draw a box on the active row."""
items are selected, return the first. if (model := self._getModel()) and model.handle(index) == self._actHandle:
""" painter.fillRect(opt.rect, self.palette().alternateBase())
selList = self.selectedItems() super().drawRow(painter, opt, index)
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
return return
## ##
# Events # 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: def resizeEvent(self, event: QResizeEvent) -> None:
"""Elide labels in the extra column.""" """Process size changed."""
super().resizeEvent(event) super().resizeEvent(event)
newW = event.size().width() self.resizeColumns()
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)
)
return return
## ##
# Private Slots # Private Slots
## ##
@pyqtSlot("QModelIndex") @pyqtSlot(QModelIndex)
def _treeItemClicked(self, index: QModelIndex) -> None: def _onSingleClick(self, index: QModelIndex) -> None:
"""The user clicked on an item in the tree.""" """The user single-clicked an index."""
if index.column() == self.C_MORE: if index.isValid() and (model := self._getModel()):
tHandle = index.siblingAtColumn(self.C_DATA).data(self.D_HANDLE) if (tHandle := model.handle(index)) and (sTitle := model.key(index)):
sTitle = index.siblingAtColumn(self.C_DATA).data(self.D_TITLE) self.novelView.selectedItemChanged.emit(tHandle)
tipPos = self.mapToGlobal(self.visualRect(index).topRight()) if index.column() == model.columnCount(index) - 1:
self._popMetaBox(tipPos, tHandle, sTitle) pos = self.mapToGlobal(self.visualRect(index).topRight())
self._popMetaBox(pos, tHandle, sTitle)
return return
@pyqtSlot() @pyqtSlot(QModelIndex)
def _treeSelectionChange(self) -> None: def _onDoubleClick(self, index: QModelIndex) -> None:
"""Extract the handle and line number of the currently selected """The user double-clicked an index."""
title, and send it to the tree meta panel. if (
""" (model := self._getModel())
tHandle, _ = self.getSelectedHandle() and (tHandle := model.handle(index))
if tHandle is not None: and (sTitle := model.key(index))
self.novelView.selectedItemChanged.emit(tHandle) ):
self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle, False)
return return
@pyqtSlot("QTreeWidgetItem*", int) @pyqtSlot(QModelIndex)
def _treeDoubleClick(self, item: QTreeWidgetItem, column: int) -> None: def _onMiddleClick(self, index: QModelIndex) -> None:
"""Extract the handle and line number of the title double- """The user middle-clicked an index."""
clicked, and send it to the main gui class for opening in the if (
document editor. (model := self._getModel())
""" and (tHandle := model.handle(index))
tHandle, sTitle = self.getSelectedHandle() and (sTitle := model.key(index))
self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "", True) ):
self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, sTitle, False)
return return
## ##
# Internal Functions # Internal Functions
## ##
def _populateTree(self, rootHandle: str | None) -> None: def _getModel(self) -> NovelModel | None:
"""Build the tree based on the project index.""" """Return the model, if it exists."""
self.clearContent() if isinstance(model := self.model(), NovelModel):
tStart = time() return model
logger.debug("Building novel tree for root item '%s'", rootHandle) return None
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 _popMetaBox(self, qPos: QPoint, tHandle: str, sTitle: str) -> None: def _popMetaBox(self, qPos: QPoint, tHandle: str, sTitle: str) -> None:
"""Show the novel meta data box.""" """Show the novel meta data box."""
logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle) def appendTags(refs: dict, key: str, lines: list[str]) -> None:
"""Generate a reference list for a given reference key."""
pIndex = SHARED.project.index if tags := ", ".join(refs.get(key, [])):
novIdx = pIndex.getItemHeading(tHandle, sTitle) lines.append(f"<b>{trConst(nwLabels.KEY_NAME[key])}:</b> {tags}")
refTags = pIndex.getReferences(tHandle, sTitle)
if not novIdx:
return return
synopText = novIdx.synopsis if head := SHARED.project.index.getItemHeading(tHandle, sTitle):
if synopText: logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle)
synopLabel = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]) if synopsis := head.synopsis:
synopText = f"<p><b>{synopLabel}</b>: {synopText}</p>" label = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP])
synopsis = f"<p><b>{label}:</b> {synopsis}</p>"
refLines = [] lines = []
refLines = self._appendMetaTag(refTags, nwKeyWords.POV_KEY, refLines) if head := SHARED.project.index.getItemHeading(tHandle, sTitle):
refLines = self._appendMetaTag(refTags, nwKeyWords.FOCUS_KEY, refLines) tags = head.getReferences()
refLines = self._appendMetaTag(refTags, nwKeyWords.CHAR_KEY, refLines) appendTags(tags, nwKeyWords.POV_KEY, lines)
refLines = self._appendMetaTag(refTags, nwKeyWords.PLOT_KEY, refLines) appendTags(tags, nwKeyWords.FOCUS_KEY, lines)
refLines = self._appendMetaTag(refTags, nwKeyWords.TIME_KEY, refLines) appendTags(tags, nwKeyWords.CHAR_KEY, lines)
refLines = self._appendMetaTag(refTags, nwKeyWords.WORLD_KEY, refLines) appendTags(tags, nwKeyWords.PLOT_KEY, lines)
refLines = self._appendMetaTag(refTags, nwKeyWords.OBJECT_KEY, refLines) appendTags(tags, nwKeyWords.TIME_KEY, lines)
refLines = self._appendMetaTag(refTags, nwKeyWords.ENTITY_KEY, refLines) appendTags(tags, nwKeyWords.WORLD_KEY, lines)
refLines = self._appendMetaTag(refTags, nwKeyWords.CUSTOM_KEY, refLines) appendTags(tags, nwKeyWords.OBJECT_KEY, lines)
appendTags(tags, nwKeyWords.ENTITY_KEY, lines)
refText = "" appendTags(tags, nwKeyWords.CUSTOM_KEY, lines)
if refLines:
refList = "<br>".join(refLines)
refText = f"<p>{refList}</p>"
ttText = refText + synopText or self.tr("No meta data")
if ttText:
QToolTip.showText(qPos, ttText)
text = ""
if lines:
refs = "<br>".join(lines)
text = f"<p>{refs}</p>"
if tooltip := (text + synopsis or self.tr("No meta data")):
QToolTip.showText(qPos, tooltip)
return 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"<b>{trConst(nwLabels.KEY_NAME[key])}</b>: {tags}")
return lines
+3 -2
View File
@@ -517,7 +517,6 @@ class GuiProjectTree(QTreeView):
def initSettings(self) -> None: def initSettings(self) -> None:
"""Set or update tree widget settings.""" """Set or update tree widget settings."""
# Scroll bars
if CONFIG.hideVScroll: if CONFIG.hideVScroll:
self.setVerticalScrollBarPolicy(QtScrollAlwaysOff) self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
else: else:
@@ -1022,7 +1021,7 @@ class GuiProjectTree(QTreeView):
return [i for i in self.selectedIndexes() if i.column() == 0] return [i for i in self.selectedIndexes() if i.column() == 0]
def _getModel(self) -> ProjectModel | None: 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): if isinstance(model := self.model(), ProjectModel):
return model return model
return None return None
@@ -1400,6 +1399,7 @@ class _TreeContextMenu(QMenu):
if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed(): if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
self._item.setLayout(nwItemLayout.DOCUMENT) self._item.setLayout(nwItemLayout.DOCUMENT)
self._item.notifyToRefresh() self._item.notifyToRefresh()
self._item.notifyNovelStructureChange()
elif itemLayout == nwItemLayout.NOTE: elif itemLayout == nwItemLayout.NOTE:
self._item.setLayout(nwItemLayout.NOTE) self._item.setLayout(nwItemLayout.NOTE)
self._item.notifyToRefresh() self._item.notifyToRefresh()
@@ -1416,6 +1416,7 @@ class _TreeContextMenu(QMenu):
self._item.setType(nwItemType.FILE) self._item.setType(nwItemType.FILE)
self._item.setLayout(nwItemLayout.DOCUMENT) self._item.setLayout(nwItemLayout.DOCUMENT)
self._item.notifyToRefresh() self._item.notifyToRefresh()
self._item.notifyNovelStructureChange()
elif msgYes and itemLayout == nwItemLayout.NOTE: elif msgYes and itemLayout == nwItemLayout.NOTE:
self._item.setType(nwItemType.FILE) self._item.setType(nwItemType.FILE)
self._item.setLayout(nwItemLayout.NOTE) self._item.setLayout(nwItemLayout.NOTE)
+1 -1
View File
@@ -694,7 +694,7 @@ class GuiIcons:
else: else:
icon = self._loadIcon(name, color, w, h) icon = self._loadIcon(name, color, w, h)
self._qIcons[key] = icon self._qIcons[key] = icon
logger.info("Icon: %s", key) logger.debug("Icon: %s", key)
return icon return icon
def getToggleIcon(self, name: str, size: tuple[int, int], color: str | None = None) -> QIcon: def getToggleIcon(self, name: str, size: tuple[int, int], color: str | None = None) -> QIcon:
+7 -4
View File
@@ -247,8 +247,6 @@ class GuiMain(QMainWindow):
self.docEditor.itemHandleChanged.connect(self.novelView.setActiveHandle) self.docEditor.itemHandleChanged.connect(self.novelView.setActiveHandle)
self.docEditor.itemHandleChanged.connect(self.projView.setActiveHandle) self.docEditor.itemHandleChanged.connect(self.projView.setActiveHandle)
self.docEditor.loadDocumentTagRequest.connect(self._followTag) 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.openDocumentRequest.connect(self._openDocument)
self.docEditor.requestNewNoteCreation.connect(SHARED.createNewNote) self.docEditor.requestNewNoteCreation.connect(SHARED.createNewNote)
self.docEditor.requestNextDocument.connect(self.openNextDocument) self.docEditor.requestNextDocument.connect(self.openNextDocument)
@@ -532,7 +530,7 @@ class GuiMain(QMainWindow):
) -> bool: ) -> bool:
"""Open a specific document, optionally at a given line.""" """Open a specific document, optionally at a given line."""
if not (SHARED.hasProject and tHandle): if not (SHARED.hasProject and tHandle):
logger.error("Nothing to open open") logger.error("Nothing to open")
return False return False
if sTitle and tLine is None: if sTitle and tLine is None:
@@ -735,7 +733,6 @@ class GuiMain(QMainWindow):
SHARED.project.index.rebuild() SHARED.project.index.rebuild()
SHARED.project.tree.refreshAllItems() SHARED.project.tree.refreshAllItems()
self.novelView.refreshTree()
tEnd = time() tEnd = time()
self.mainStatus.setStatusMessage( self.mainStatus.setStatusMessage(
@@ -1174,6 +1171,12 @@ class GuiMain(QMainWindow):
) )
elif view == nwView.OUTLINE: elif view == nwView.OUTLINE:
self.mainStack.setCurrentWidget(self.outlineView) 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 return
@pyqtSlot(nwDocAction) @pyqtSlot(nwDocAction)
+2 -1
View File
@@ -63,10 +63,11 @@ class SharedData(QObject):
indexChangedTags = pyqtSignal(list, list) indexChangedTags = pyqtSignal(list, list)
indexCleared = pyqtSignal() indexCleared = pyqtSignal()
mainClockTick = pyqtSignal() mainClockTick = pyqtSignal()
novelStructureChanged = pyqtSignal(str)
projectItemChanged = pyqtSignal(str, Enum) projectItemChanged = pyqtSignal(str, Enum)
rootFolderChanged = pyqtSignal(str, Enum)
projectStatusChanged = pyqtSignal(bool) projectStatusChanged = pyqtSignal(bool)
projectStatusMessage = pyqtSignal(str) projectStatusMessage = pyqtSignal(str)
rootFolderChanged = pyqtSignal(str, Enum)
spellLanguageChanged = pyqtSignal(str, str) spellLanguageChanged = pyqtSignal(str, str)
statusLabelsChanged = pyqtSignal(str) statusLabelsChanged = pyqtSignal(str)
+4 -3
View File
@@ -137,9 +137,10 @@ class GuiNovelDetails(NNonBlockingDialog):
def updateValues(self) -> None: def updateValues(self) -> None:
"""Load the dialogs initial values.""" """Load the dialogs initial values."""
self.overviewPage.updateProjectData() if handle := self.novelSelector.handle:
self.overviewPage.novelValueChanged(self.novelSelector.handle) self.overviewPage.updateProjectData()
self.contentsPage.novelValueChanged(self.novelSelector.handle) self.overviewPage.novelValueChanged(handle)
self.contentsPage.novelValueChanged(handle)
return return
## ##
+4 -4
View File
@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.7a3" hexVersion="0x020700a3" fileVersion="1.5" fileRevision="4" timeStamp="2025-02-16 21:53:19"> <novelWriterXML appVersion="2.7a3" hexVersion="0x020700a3" fileVersion="1.5" fileRevision="4" timeStamp="2025-03-23 22:26:31">
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="2160" autoCount="281" editTime="95932"> <project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="2166" autoCount="282" editTime="96058">
<name>Sample Project</name> <name>Sample Project</name>
<author>Jane Smith</author> <author>Jane Smith</author>
</project> </project>
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">636b6aa9b697b</entry> <entry key="editor">636b6aa9b697b</entry>
<entry key="viewer">636b6aa9b697b</entry> <entry key="viewer">636b6aa9b697b</entry>
<entry key="novelTree">7031beac91f75</entry> <entry key="novel">7031beac91f75</entry>
<entry key="outline">7031beac91f75</entry> <entry key="outline">7031beac91f75</entry>
</lastHandle> </lastHandle>
<autoReplace> <autoReplace>
@@ -66,7 +66,7 @@
<name status="s90e6c9" import="ia857f0" active="yes">Another Scene</name> <name status="s90e6c9" import="ia857f0" active="yes">Another Scene</name>
</item> </item>
<item handle="ba8a28a246524" parent="7031beac91f75" root="7031beac91f75" order="4" type="FILE" class="NOVEL" layout="DOCUMENT"> <item handle="ba8a28a246524" parent="7031beac91f75" root="7031beac91f75" order="4" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H2" charCount="649" wordCount="101" paraCount="3" cursorPos="1182" /> <meta expanded="no" heading="H2" charCount="617" wordCount="101" paraCount="3" cursorPos="1182" />
<name status="s78ea90" import="ia857f0" active="yes">Interlude</name> <name status="s78ea90" import="ia857f0" active="yes">Interlude</name>
</item> </item>
<item handle="96b68994dfa3d" parent="7031beac91f75" root="7031beac91f75" order="5" type="FILE" class="NOVEL" layout="NOTE"> <item handle="96b68994dfa3d" parent="7031beac91f75" root="7031beac91f75" order="5" type="FILE" class="NOVEL" layout="NOTE">
+1 -1
View File
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">636b6aa9b697b</entry> <entry key="editor">636b6aa9b697b</entry>
<entry key="viewer">636b6aa9b697b</entry> <entry key="viewer">636b6aa9b697b</entry>
<entry key="novelTree">7031beac91f75</entry> <entry key="novel">7031beac91f75</entry>
<entry key="outline">7031beac91f75</entry> <entry key="outline">7031beac91f75</entry>
</lastHandle> </lastHandle>
<autoReplace> <autoReplace>
+1 -1
View File
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">7a992350f3eb6</entry> <entry key="editor">7a992350f3eb6</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">b3643d0f92e32</entry> <entry key="novel">b3643d0f92e32</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace> <autoReplace>
+4 -1
View File
@@ -65,7 +65,10 @@ class MockTheme:
self.guiFontBU = QFont() self.guiFontBU = QFont()
return return
def getPixmap(self, *a): def getPixmap(self, *a) -> QPixmap:
return QPixmap()
def getHeaderDecoration(self, *a) -> QPixmap:
return QPixmap() return QPixmap()
def getIcon(self, *a) -> QIcon: def getIcon(self, *a) -> QIcon:
+2 -1
View File
@@ -1,5 +1,5 @@
[Meta] [Meta]
timestamp = 2025-02-06 11:05:16 timestamp = 2025-02-22 19:57:47
[Main] [Main]
font = font =
@@ -13,6 +13,7 @@ hidevscroll = False
hidehscroll = False hidehscroll = False
lastnotes = 0x0 lastnotes = 0x0
nativefont = True nativefont = True
usecharcount = False
[Sizes] [Sizes]
mainwindow = 1200, 650 mainwindow = 1200, 650
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">None</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace /> <autoReplace />
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">None</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace /> <autoReplace />
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">None</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace /> <autoReplace />
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">None</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace /> <autoReplace />
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">None</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace /> <autoReplace />
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">000000000000f</entry> <entry key="editor">000000000000f</entry>
<entry key="viewer">000000000000f</entry> <entry key="viewer">000000000000f</entry>
<entry key="novelTree">0000000000008</entry> <entry key="novel">0000000000008</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace /> <autoReplace />
@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.6b1" hexVersion="0x020600b1" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-23 17:46:20"> <novelWriterXML appVersion="2.7a3" hexVersion="0x020700a3" fileVersion="1.5" fileRevision="4" timeStamp="2025-03-23 22:33:54">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="2" autoCount="1" editTime="0"> <project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="2" autoCount="1" editTime="0">
<name>New Project</name> <name>New Project</name>
<author>Jane Doe</author> <author>Jane Doe</author>
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">0000000000008</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace /> <autoReplace />
+1 -1
View File
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">None</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace> <autoReplace>
+1 -1
View File
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">None</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace> <autoReplace>
+1 -1
View File
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">None</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace> <autoReplace>
+1 -1
View File
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">None</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace> <autoReplace>
+1 -1
View File
@@ -11,7 +11,7 @@
<lastHandle> <lastHandle>
<entry key="editor">None</entry> <entry key="editor">None</entry>
<entry key="viewer">None</entry> <entry key="viewer">None</entry>
<entry key="novelTree">None</entry> <entry key="novel">None</entry>
<entry key="outline">None</entry> <entry key="outline">None</entry>
</lastHandle> </lastHandle>
<autoReplace> <autoReplace>
+23 -17
View File
@@ -28,17 +28,18 @@ import pytest
from novelwriter import SHARED from novelwriter import SHARED
from novelwriter.constants import nwFiles 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.item import NWItem
from novelwriter.core.novelmodel import NovelModel
from novelwriter.core.project import NWProject 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.mocked import causeException
from tests.tools import C, buildTestProject, cmpFiles from tests.tools import C, buildTestProject, cmpFiles
@pytest.mark.core @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 """Test core functionality of scanning, saving, loading and checking
the index cache file. the index cache file.
""" """
@@ -52,6 +53,18 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
index = Index(project) index = Index(project)
assert repr(index) == "<Index project='Lorem Ipsum'>" assert repr(index) == "<Index project='Lorem Ipsum'>"
# 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 = { notIndexable = {
"b3643d0f92e32": False, # Novel ROOT "b3643d0f92e32": False, # Novel ROOT
"45e6b01ca35c1": False, # Chapter One FOLDER "45e6b01ca35c1": False, # Chapter One FOLDER
@@ -216,7 +229,7 @@ def testCoreIndex_ScanThis(mockGUI):
@pytest.mark.core @pytest.mark.core
def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd): def testCoreIndex_CheckThese(nwGUI, fncPath, mockRnd):
"""Test the tag checker function checkThese.""" """Test the tag checker function checkThese."""
project = NWProject() project = NWProject()
mockRnd.reset() mockRnd.reset()
@@ -338,7 +351,7 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd):
@pytest.mark.core @pytest.mark.core
def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd): def testCoreIndex_ScanText(monkeypatch, nwGUI, fncPath, mockRnd):
"""Check the index text scanner.""" """Check the index text scanner."""
project = NWProject() project = NWProject()
mockRnd.reset() mockRnd.reset()
@@ -586,7 +599,7 @@ def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
@pytest.mark.core @pytest.mark.core
def testCoreIndex_CommentKeys(monkeypatch, mockGUI, fncPath, mockRnd): def testCoreIndex_CommentKeys(monkeypatch, nwGUI, fncPath, mockRnd):
"""Check the index comment key generator.""" """Check the index comment key generator."""
project = NWProject() project = NWProject()
mockRnd.reset() mockRnd.reset()
@@ -623,7 +636,7 @@ def testCoreIndex_CommentKeys(monkeypatch, mockGUI, fncPath, mockRnd):
@pytest.mark.core @pytest.mark.core
def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): def testCoreIndex_ExtractData(nwGUI, fncPath, mockRnd):
"""Check the index data extraction functions.""" """Check the index data extraction functions."""
project = NWProject() project = NWProject()
mockRnd.reset() mockRnd.reset()
@@ -708,15 +721,6 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
assert wC == 12 # Words in text and title only assert wC == 12 # Words in text and title only
assert pC == 2 # Paragraphs in text 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 # getReferences
# ============= # =============
@@ -764,7 +768,9 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
# getClassTags # getClassTags
# ============ # ============
assert index.getClassTags(None) == ["Jane", "John"]
assert index.getClassTags(nwItemClass.CHARACTER) == ["Jane", "John"] assert index.getClassTags(nwItemClass.CHARACTER) == ["Jane", "John"]
assert index.getClassTags(nwItemClass.PLOT) == []
# getTagsData # getTagsData
# =========== # ===========
@@ -1151,7 +1157,7 @@ def testCoreIndex_TagsIndex():
@pytest.mark.core @pytest.mark.core
def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd): def testCoreIndex_ItemIndex(nwGUI, fncPath, mockRnd):
"""Check the ItemIndex class.""" """Check the ItemIndex class."""
project = NWProject() project = NWProject()
mockRnd.reset() mockRnd.reset()
+96 -15
View File
@@ -22,6 +22,8 @@ from __future__ import annotations
import pytest import pytest
from novelwriter import CONFIG
from novelwriter.core.index import TagsIndex
from novelwriter.core.indexdata import IndexHeading, IndexNode from novelwriter.core.indexdata import IndexHeading, IndexNode
from novelwriter.core.item import NWItem from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
@@ -33,9 +35,10 @@ def testCoreIndexData_IndexNode(mockGUI):
handle = "0123456789abc" handle = "0123456789abc"
project = NWProject() project = NWProject()
item = NWItem(project, handle) item = NWItem(project, handle)
tags = TagsIndex()
# Defaults # Defaults
node = IndexNode(handle, item) node = IndexNode(tags, handle, item)
assert node.handle == handle assert node.handle == handle
assert node.item is item assert node.item is item
assert str(node) == f"<IndexNode handle='{handle}'>" assert str(node) == f"<IndexNode handle='{handle}'>"
@@ -44,8 +47,8 @@ def testCoreIndexData_IndexNode(mockGUI):
assert "T0000" in node # Placeholder heading assert "T0000" in node # Placeholder heading
# Add a heading # Add a heading
head1 = IndexHeading(node.nextHeading(), line=1, level="H1", title="Heading 1") head1 = IndexHeading(tags, node.nextHeading(), line=1, level="H1", title="Heading 1")
head2 = IndexHeading(node.nextHeading(), line=10, level="H2", title="Heading 2") head2 = IndexHeading(tags, node.nextHeading(), line=10, level="H2", title="Heading 2")
node.addHeading(head1) node.addHeading(head1)
node.addHeading(head2) node.addHeading(head2)
assert len(node) == 2 assert len(node) == 2
@@ -105,11 +108,12 @@ def testCoreIndexData_IndexNodePackUnpack(mockGUI):
handle = "0123456789abc" handle = "0123456789abc"
project = NWProject() project = NWProject()
item = NWItem(project, handle) item = NWItem(project, handle)
node = IndexNode(handle, item) tags = TagsIndex()
node = IndexNode(tags, handle, item)
# Add some headings and notes # Add some headings and notes
head1 = IndexHeading(node.nextHeading(), line=1, level="H1", title="Heading 1") head1 = IndexHeading(tags, node.nextHeading(), line=1, level="H1", title="Heading 1")
head2 = IndexHeading(node.nextHeading(), line=10, level="H2", title="Heading 2") head2 = IndexHeading(tags, node.nextHeading(), line=10, level="H2", title="Heading 2")
node.addHeading(head1) node.addHeading(head1)
node.addHeading(head2) node.addHeading(head2)
node.setHeadingCounts("T0001", 42, 13, 3) node.setHeadingCounts("T0001", 42, 13, 3)
@@ -128,7 +132,7 @@ def testCoreIndexData_IndexNodePackUnpack(mockGUI):
assert set(data["document"]["footnotes"]) == {"key1", "key2"} assert set(data["document"]["footnotes"]) == {"key1", "key2"}
# Create a new node # Create a new node
new = IndexNode(handle, item) new = IndexNode(tags, handle, item)
# Unpack heading one # Unpack heading one
data = {"T0001": {"meta": { data = {"T0001": {"meta": {
@@ -165,7 +169,8 @@ def testCoreIndexData_IndexNodePackUnpack(mockGUI):
def testCoreIndexData_IndexHeading(): def testCoreIndexData_IndexHeading():
"""Test the IndexHeading class.""" """Test the IndexHeading class."""
# Defaults # Defaults
head = IndexHeading("T0001") tags = TagsIndex()
head = IndexHeading(tags, "T0001")
assert str(head) == "<IndexHeading key='T0001'>" assert str(head) == "<IndexHeading key='T0001'>"
assert repr(head) == "<IndexHeading key='T0001'>" assert repr(head) == "<IndexHeading key='T0001'>"
assert head.key == "T0001" assert head.key == "T0001"
@@ -175,6 +180,7 @@ def testCoreIndexData_IndexHeading():
assert head.charCount == 0 assert head.charCount == 0
assert head.wordCount == 0 assert head.wordCount == 0
assert head.paraCount == 0 assert head.paraCount == 0
assert head.mainCount == 0
assert head.synopsis == "" assert head.synopsis == ""
assert head.tag == "" assert head.tag == ""
assert head.references == {} assert head.references == {}
@@ -201,6 +207,11 @@ def testCoreIndexData_IndexHeading():
assert head.wordCount == 4 assert head.wordCount == 4
assert head.paraCount == 2 assert head.paraCount == 2
# Check Main Count
assert head.mainCount == 4
CONFIG.useCharCount = True
assert head.mainCount == 42
# Set Summary # Set Summary
head.setSynopsis("In the beginning ...") head.setSynopsis("In the beginning ...")
assert head.synopsis == "In the beginning ..." assert head.synopsis == "In the beginning ..."
@@ -231,14 +242,82 @@ def testCoreIndexData_IndexHeading():
assert head.synopsis == "How it started ..." 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 @pytest.mark.core
def testCoreIndexData_IndexHeadingUnpackMeta(): def testCoreIndexData_IndexHeadingUnpackMeta():
"""Test IndexHeading class meta unpacking.""" """Test IndexHeading class meta unpacking."""
tags = TagsIndex()
# Valid # Valid
data = {"meta": { data = {"meta": {
"level": "H1", "title": "So it Begins", "line": 1, "tag": "begins", "counts": [95, 18, 1] "level": "H1", "title": "So it Begins", "line": 1, "tag": "begins", "counts": [95, 18, 1]
}} }}
head = IndexHeading("T0001") head = IndexHeading(tags, "T0001")
head.unpackData(data) head.unpackData(data)
assert head.level == "H1" assert head.level == "H1"
assert head.title == "So it Begins" assert head.title == "So it Begins"
@@ -252,7 +331,7 @@ def testCoreIndexData_IndexHeadingUnpackMeta():
data = {"meta": { data = {"meta": {
"level": "H9", "title": None, "line": None, "tag": None, "counts": [42] "level": "H9", "title": None, "line": None, "tag": None, "counts": [42]
}} }}
head = IndexHeading("T0001") head = IndexHeading(tags, "T0001")
head.unpackData(data) head.unpackData(data)
assert head.level == "H0" assert head.level == "H0"
assert head.title == "None" assert head.title == "None"
@@ -264,7 +343,7 @@ def testCoreIndexData_IndexHeadingUnpackMeta():
# Empty # Empty
data = {"meta": {}} data = {"meta": {}}
head = IndexHeading("T0001") head = IndexHeading(tags, "T0001")
head.unpackData(data) head.unpackData(data)
assert head.level == "H0" assert head.level == "H0"
assert head.title == "" assert head.title == ""
@@ -278,11 +357,13 @@ def testCoreIndexData_IndexHeadingUnpackMeta():
@pytest.mark.core @pytest.mark.core
def testCoreIndexData_IndexHeadingUnpackRefs(): def testCoreIndexData_IndexHeadingUnpackRefs():
"""Test IndexHeading class refs unpacking.""" """Test IndexHeading class refs unpacking."""
tags = TagsIndex()
# Valid # Valid
data = {"refs": { data = {"refs": {
"jane": "@char,@pov", "john": "@char", "earth": "@location", "space": "@mention,@location" "jane": "@char,@pov", "john": "@char", "earth": "@location", "space": "@mention,@location"
}} }}
head = IndexHeading("T0001") head = IndexHeading(tags, "T0001")
head.unpackData(data) head.unpackData(data)
assert head.references["jane"] == {"@char", "@pov"} assert head.references["jane"] == {"@char", "@pov"}
assert head.references["john"] == {"@char"} assert head.references["john"] == {"@char"}
@@ -291,18 +372,18 @@ def testCoreIndexData_IndexHeadingUnpackRefs():
# Invalid key # Invalid key
data = {"refs": {0: "@char,@pov"}} data = {"refs": {0: "@char,@pov"}}
head = IndexHeading("T0001") head = IndexHeading(tags, "T0001")
with pytest.raises(ValueError, match="Heading reference key must be a string"): with pytest.raises(ValueError, match="Heading reference key must be a string"):
head.unpackData(data) head.unpackData(data)
# Invalid value # Invalid value
data = {"refs": {"jane": None}} data = {"refs": {"jane": None}}
head = IndexHeading("T0001") head = IndexHeading(tags, "T0001")
with pytest.raises(ValueError, match="Heading reference value must be a string"): with pytest.raises(ValueError, match="Heading reference value must be a string"):
head.unpackData(data) head.unpackData(data)
# Invalid keyword # Invalid keyword
data = {"refs": {"jane": "@char,@pov,@stuff"}} data = {"refs": {"jane": "@char,@pov,@stuff"}}
head = IndexHeading("T0001") head = IndexHeading(tags, "T0001")
with pytest.raises(ValueError, match="Heading reference contains an invalid keyword"): with pytest.raises(ValueError, match="Heading reference contains an invalid keyword"):
head.unpackData(data) head.unpackData(data)
+178
View File
@@ -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 <https://www.gnu.org/licenses/>.
"""
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",
]
+4 -4
View File
@@ -27,7 +27,7 @@ import pytest
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.options import OptionState from novelwriter.core.options import OptionState
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
from novelwriter.gui.noveltree import NovelTreeColumn from novelwriter.enum import nwNovelExtra
from tests.mocked import causeOSError from tests.mocked import causeOSError
@@ -110,7 +110,7 @@ def testCoreOptions_SetGet(mockGUI):
project = NWProject() project = NWProject()
options = OptionState(project) options = OptionState(project)
nwColHidden = NovelTreeColumn.HIDDEN nwColHidden = nwNovelExtra.HIDDEN
# Set invalid values # Set invalid values
assert options.setValue("MockGroup", "mockItem", None) is False 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.getFloat("GuiNovelDetails", "mockItem", None) is None # type: ignore
assert options.getBool("GuiNovelDetails", "clearDouble", None) is True # type: ignore assert options.getBool("GuiNovelDetails", "clearDouble", None) is True # type: ignore
assert options.getBool("GuiNovelDetails", "mockItem", None) is None # 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 # Get from non-existent groups
assert options.getValue("SomeGroup", "mockItem", None) is None 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.getInt("SomeGroup", "mockItem", None) is None # type: ignore
assert options.getFloat("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.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
+6 -6
View File
@@ -155,7 +155,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, mockGUI, tstPaths, fncPath):
assert data.getLastHandle("editor") == "636b6aa9b697b" assert data.getLastHandle("editor") == "636b6aa9b697b"
assert data.getLastHandle("viewer") == "636b6aa9b697b" assert data.getLastHandle("viewer") == "636b6aa9b697b"
assert data.getLastHandle("novelTree") == "7031beac91f75" assert data.getLastHandle("novel") == "7031beac91f75"
assert data.getLastHandle("outline") == "7031beac91f75" assert data.getLastHandle("outline") == "7031beac91f75"
assert data.itemStatus["sf12341"].name == "New" 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("editor") is None # Dropped by conversion
assert data.getLastHandle("viewer") 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.getLastHandle("outline") is None # Doesn't exist in 1.0
assert data.itemStatus["s000000"].name == "New" 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("editor") is None # Dropped by conversion
assert data.getLastHandle("viewer") 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.getLastHandle("outline") is None # Doesn't exist in 1.1
assert data.itemStatus["s000000"].name == "New" 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("editor") is None # Dropped by conversion
assert data.getLastHandle("viewer") 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.getLastHandle("outline") is None # Doesn't exist in 1.2
assert data.itemStatus["s000000"].name == "New" 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("editor") is None # Dropped by conversion
assert data.getLastHandle("viewer") 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.getLastHandle("outline") is None # Doesn't exist in 1.3
assert data.itemStatus["s000000"].name == "New" 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("editor") is None # Dropped by conversion
assert data.getLastHandle("viewer") 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.getLastHandle("outline") is None # Doesn't exist in 1.3
assert data.itemStatus["sf12341"].name == "New" assert data.itemStatus["sf12341"].name == "New"
+29 -3
View File
@@ -23,13 +23,13 @@ from __future__ import annotations
import pytest import pytest
from PyQt6.QtCore import QEvent, QPoint, QPointF, Qt 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 PyQt6.QtWidgets import QWidget
from novelwriter.extensions.modified import ( 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 from tests.tools import SimpleDialog
@@ -66,6 +66,32 @@ def testExtModified_NDialog(qtbot, monkeypatch):
assert dialog.result() == QtRejected 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 @pytest.mark.gui
def testExtModified_NComboBox(qtbot, monkeypatch): def testExtModified_NComboBox(qtbot, monkeypatch):
"""Test the NComboBox class.""" """Test the NComboBox class."""
+3 -3
View File
@@ -151,12 +151,12 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Novel Tree has focus # Novel Tree has focus
nwGUI._changeView(nwView.NOVEL) nwGUI._changeView(nwView.NOVEL)
nwGUI.novelView.novelTree.refreshTree(rootHandle=None, overRide=True)
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr(GuiNovelView, "treeHasFocus", lambda *a: True) mp.setattr(GuiNovelView, "treeHasFocus", lambda *a: True)
assert nwGUI.docEditor.docHandle is None assert nwGUI.docEditor.docHandle is None
selItem = nwGUI.novelView.novelTree.topLevelItem(2) model = nwGUI.novelView.novelTree._getModel()
nwGUI.novelView.novelTree.setCurrentItem(selItem) assert model is not None
nwGUI.novelView.novelTree.setCurrentIndex(model.createIndex(2, 0))
nwGUI._keyPressReturn() nwGUI._keyPressReturn()
assert nwGUI.docEditor.docHandle == sHandle assert nwGUI.docEditor.docHandle == sHandle
nwGUI.closeDocument() nwGUI.closeDocument()
+72 -124
View File
@@ -24,21 +24,19 @@ from pathlib import Path
import pytest import pytest
from PyQt6.QtCore import QEvent, QPoint, Qt from PyQt6.QtCore import QModelIndex, QPoint, Qt
from PyQt6.QtGui import QFocusEvent
from PyQt6.QtWidgets import QInputDialog, QToolTip from PyQt6.QtWidgets import QInputDialog, QToolTip
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.core.novelmodel import NovelModel
from novelwriter.dialogs.editlabel import GuiEditLabel from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.enum import nwFocus, nwItemType from novelwriter.enum import nwFocus, nwItemType, nwNovelExtra, nwView
from novelwriter.gui.noveltree import GuiNovelTree, NovelTreeColumn
from novelwriter.types import QtMouseLeft, QtMouseMiddle
from tests.tools import C, buildTestProject from tests.tools import C, buildTestProject
@pytest.mark.gui @pytest.mark.gui
def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): def testGuiNovelView_Content(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test navigating the novel tree.""" """Test navigating the novel tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) 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 contentPath = SHARED.project.storage.contentPath
assert isinstance(contentPath, Path) 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" "# 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" "### Scene One\n\n"
"@pov: Jane\n" "@pov: Jane\n"
"@focus: Jane\n\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") ), encoding="utf-8")
novelView = nwGUI.novelView novelView = nwGUI.novelView
novelTree = novelView.novelTree novelTree = nwGUI.novelView.novelTree
novelBar = novelView.novelBar novelBar = nwGUI.novelView.novelBar
# Show/Hide Scrollbars # Show/Hide Scrollbars
# ==================== # ====================
@@ -83,153 +83,101 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Populate Tree # Populate Tree
# ============= # =============
root = QModelIndex()
novelView.setTreeFocus() novelView.setTreeFocus()
nwGUI._changeView(nwView.NOVEL)
nwGUI.projStack.setCurrentWidget(nwGUI.novelView) # Clear tree
nwGUI.rebuildIndex() novelView.setCurrentNovel(None)
novelTree._populateTree(rootHandle=None) assert novelTree._getModel() is None
assert novelTree.topLevelItemCount() == 3
# Rebuild should preserve selection # Reload
topItem = novelTree.topLevelItem(0) novelBar._forceRefreshNovelTree()
assert not topItem.isSelected() model = novelTree._getModel()
topItem.setSelected(True) assert isinstance(model, NovelModel)
assert novelTree.selectedItems()[0] == topItem
assert novelView.getSelectedHandle() == (C.hTitlePage, "T0001")
# Refresh using the slot for the button # Check the items
novelBar._refreshNovelTree() assert model.rowCount(root) == 3
assert novelTree.topLevelItem(0).isSelected() 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 # Open Items
# ========== # ==========
# Clear selection # Clear selection
novelTree.clearSelection() novelTree.clearSelection()
scItem = novelTree.topLevelItem(2) assert novelView.getSelectedHandle() == (None, None)
scItem.setSelected(True)
assert scItem.isSelected()
# Clear selection with mouse # Select scene
vPort = novelTree.viewport() novelTree.setCurrentIndex(model.createIndex(2, 0))
qtbot.mouseClick(vPort, QtMouseLeft, pos=vPort.rect().center(), delay=10) assert novelView.getSelectedHandle() == (C.hSceneDoc, "T0001")
assert not scItem.isSelected()
# Double-click item # Double-click item
scItem.setSelected(True) novelTree._onDoubleClick(model.createIndex(2, 0))
assert scItem.isSelected()
assert nwGUI.docEditor.docHandle is None
novelTree._treeDoubleClick(scItem, 0)
assert nwGUI.docEditor.docHandle == C.hSceneDoc assert nwGUI.docEditor.docHandle == C.hSceneDoc
# Open item with middle mouse button # Middle-click item
scItem.setSelected(True) novelTree._onMiddleClick(model.createIndex(2, 0))
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)
assert nwGUI.docViewer.docHandle == C.hSceneDoc 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 # Item Meta
# ========= # =========
ttText = "" toolTip = ""
def showText(pos, text): def showText(pos, text):
nonlocal ttText nonlocal toolTip
ttText = text toolTip = text
mIndex = novelTree.model().index(2, novelTree.C_MORE)
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr(QToolTip, "showText", showText) mp.setattr(QToolTip, "showText", showText)
ttText = "" toolTip = ""
novelTree._treeItemClicked(mIndex) novelTree._onSingleClick(model.createIndex(2, 3))
assert ttText == ( assert toolTip == (
"<p><b>Point of View</b>: Jane<br><b>Focus</b>: Jane</p>" "<p><b>Point of View:</b> Jane<br><b>Focus:</b> Jane</p>"
"<p><b>Synopsis</b>: This is a scene.</p>" "<p><b>Synopsis:</b> This is a scene.</p>"
) )
ttText = "" toolTip = ""
novelTree._popMetaBox(QPoint(1, 1), C.hInvalid, "T0001") novelTree._popMetaBox(QPoint(1, 1), C.hInvalid, "T0001")
assert ttText == "" assert toolTip == ""
# Set Default Root # Active Status
# ================ # =============
SHARED.project.data.setLastHandle(C.hInvalid, "novelTree") assert novelBar._refresh == {C.hNovelRoot: False}
novelView.openProjectTasks()
assert novelBar.novelValue.handle == C.hNovelRoot
# Tree Focus # Add a document while tree in focus
# ========== nwGUI._changeView(nwView.PROJECT)
with monkeypatch.context() as mp: assert novelBar._active is False
mp.setattr(GuiNovelTree, "hasFocus", lambda *a: False) nwGUI.projView.projTree.setSelectedHandle(C.hChapterDir)
assert novelView.treeHasFocus() is False nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, hLevel=3)
mp.setattr(GuiNovelTree, "hasFocus", lambda *a: True) assert novelBar._refresh == {C.hNovelRoot: True}
assert novelView.treeHasFocus() is True
# Other Checks # Switch back and check that the refresh status is reset
# ============ nwGUI._changeView(nwView.NOVEL)
assert novelBar._refresh == {C.hNovelRoot: False}
scItem = novelTree.topLevelItem(2)
scItem.setSelected(True)
assert scItem.isSelected()
novelTree.focusOutEvent(QFocusEvent(QEvent.Type.None_, Qt.FocusReason.MouseFocusReason))
assert not scItem.isSelected()
# Close # Close
# =====
# qtbot.stop() # qtbot.stop()
nwGUI.closeProject() nwGUI.closeProject()
-1
View File
@@ -39,7 +39,6 @@ def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
search = nwGUI.projSearch search = nwGUI.projSearch
def totalCount(): def totalCount():
nonlocal search
res = search.searchResult res = search.searchResult
return sum( return sum(
int(res.topLevelItem(i).text(GuiProjectSearch.C_COUNT).strip("()")) int(res.topLevelItem(i).text(GuiProjectSearch.C_COUNT).strip("()"))
+1 -1
View File
@@ -71,7 +71,7 @@ def testToolBuildSettings_Init(qtbot, nwGUI, projPath, mockRnd):
@pyqtSlot(BuildSettings) @pyqtSlot(BuildSettings)
def _testNewSettingsReady(new: BuildSettings): def _testNewSettingsReady(new: BuildSettings):
nonlocal build, triggered nonlocal triggered
assert new is build assert new is build
triggered = True triggered = True
-1
View File
@@ -131,7 +131,6 @@ def testToolWelcome_Open(qtbot, monkeypatch, nwGUI, fncPath):
# Context Menu # Context Menu
def getMenuForPos(pos: QPoint) -> QMenu | None: def getMenuForPos(pos: QPoint) -> QMenu | None:
nonlocal tabOpen
tabOpen._openContextMenu(pos) tabOpen._openContextMenu(pos)
for obj in tabOpen.children(): for obj in tabOpen.children():
if isinstance(obj, QMenu) and obj.objectName() == "ContextMenu": if isinstance(obj, QMenu) and obj.objectName() == "ContextMenu":
+2
View File
@@ -216,9 +216,11 @@ def buildTestProject(obj: object, projPath: Path) -> None:
project.setProjectChanged(True) project.setProjectChanged(True)
project.saveProject(autoSave=True) project.saveProject(autoSave=True)
project._valid = True project._valid = True
project._tree._ready = True
if nwGUI is not None: if nwGUI is not None:
nwGUI.projView.openProjectTasks() nwGUI.projView.openProjectTasks()
nwGUI.novelView.openProjectTasks()
return return