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

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