Re-implement Novel View as an item model (#2272)
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
##
|
##
|
||||||
|
|||||||
@@ -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
|
||||||
##
|
##
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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] = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
##
|
##
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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("()"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user