From 9a63a84fc113a1777f03068fd2195610cfb8fd75 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 22 Feb 2025 20:12:50 +0100
Subject: [PATCH 01/20] Add a basic model for the novel view
---
novelwriter/config.py | 57 +-
novelwriter/core/index.py | 24 +
novelwriter/core/indexdata.py | 5 +
novelwriter/core/itemmodel.py | 2 +-
novelwriter/core/novelmodel.py | 100 +++
novelwriter/gui/noveltree.py | 649 ++++++++++----------
tests/reference/baseConfig_novelwriter.conf | 3 +-
7 files changed, 497 insertions(+), 343 deletions(-)
create mode 100644 novelwriter/core/novelmodel.py
diff --git a/novelwriter/config.py b/novelwriter/config.py
index 15bce92b..949f49f3 100644
--- a/novelwriter/config.py
+++ b/novelwriter/config.py
@@ -68,23 +68,23 @@ class Config:
"_backupPath",
"appName", "appHandle", "guiLocale", "guiTheme", "guiSyntax", "guiFont", "hideVScroll",
- "hideHScroll", "lastNotes", "nativeFont", "iconTheme", "iconColTree", "iconColDocs",
- "mainWinSize", "welcomeWinSize", "prefsWinSize", "mainPanePos", "viewPanePos",
- "outlinePanePos", "autoSaveProj", "autoSaveDoc", "emphLabels", "backupOnClose",
- "askBeforeBackup", "askBeforeExit", "textFont", "textWidth", "textMargin", "tabWidth",
- "cursorWidth", "focusWidth", "hideFocusFooter", "showFullPath", "autoSelect", "doJustify",
- "showTabsNSpaces", "showLineEndings", "showMultiSpaces", "doReplace", "doReplaceSQuote",
- "doReplaceDQuote", "doReplaceDash", "doReplaceDots", "autoScroll", "autoScrollPos",
- "scrollPastEnd", "dialogStyle", "allowOpenDial", "dialogLine", "narratorBreak",
- "narratorDialog", "altDialogOpen", "altDialogClose", "highlightEmph", "stopWhenIdle",
- "userIdleTime", "incNotesWCount", "fmtApostrophe", "fmtSQuoteOpen", "fmtSQuoteClose",
- "fmtDQuoteOpen", "fmtDQuoteClose", "fmtPadBefore", "fmtPadAfter", "fmtPadThin",
- "spellLanguage", "showViewerPanel", "showEditToolBar", "showSessionTime", "viewComments",
- "viewSynopsis", "searchCase", "searchWord", "searchRegEx", "searchLoop", "searchNextFile",
- "searchMatchCap", "searchProjCase", "searchProjWord", "searchProjRegEx", "verQtString",
- "verQtValue", "verPyQtString", "verPyQtValue", "verPyString", "osType", "osLinux",
- "osWindows", "osDarwin", "osUnknown", "hostName", "kernelVer", "isDebug", "memInfo",
- "hasEnchant",
+ "hideHScroll", "lastNotes", "nativeFont", "useCharCount", "iconTheme", "iconColTree",
+ "iconColDocs", "mainWinSize", "welcomeWinSize", "prefsWinSize", "mainPanePos",
+ "viewPanePos", "outlinePanePos", "autoSaveProj", "autoSaveDoc", "emphLabels",
+ "backupOnClose", "askBeforeBackup", "askBeforeExit", "textFont", "textWidth", "textMargin",
+ "tabWidth", "cursorWidth", "focusWidth", "hideFocusFooter", "showFullPath", "autoSelect",
+ "doJustify", "showTabsNSpaces", "showLineEndings", "showMultiSpaces", "doReplace",
+ "doReplaceSQuote", "doReplaceDQuote", "doReplaceDash", "doReplaceDots", "autoScroll",
+ "autoScrollPos", "scrollPastEnd", "dialogStyle", "allowOpenDial", "dialogLine",
+ "narratorBreak", "narratorDialog", "altDialogOpen", "altDialogClose", "highlightEmph",
+ "stopWhenIdle", "userIdleTime", "incNotesWCount", "fmtApostrophe", "fmtSQuoteOpen",
+ "fmtSQuoteClose", "fmtDQuoteOpen", "fmtDQuoteClose", "fmtPadBefore", "fmtPadAfter",
+ "fmtPadThin", "spellLanguage", "showViewerPanel", "showEditToolBar", "showSessionTime",
+ "viewComments", "viewSynopsis", "searchCase", "searchWord", "searchRegEx", "searchLoop",
+ "searchNextFile", "searchMatchCap", "searchProjCase", "searchProjWord", "searchProjRegEx",
+ "verQtString", "verQtValue", "verPyQtString", "verPyQtValue", "verPyString", "osType",
+ "osLinux", "osWindows", "osDarwin", "osUnknown", "hostName", "kernelVer", "isDebug",
+ "memInfo", "hasEnchant",
)
LANG_NW = 1
@@ -157,6 +157,7 @@ class Config:
self.hideHScroll = False # Hide horizontal scroll bars on main widgets
self.lastNotes = "0x0" # The latest release notes that have been shown
self.nativeFont = True # Use native font dialog
+ self.useCharCount = False # Use character count as primary count
# Icons
self.iconTheme = DEF_ICONS # Icons theme
@@ -591,16 +592,17 @@ class Config:
# Main
sec = "Main"
self.setGuiFont(conf.rdStr(sec, "font", ""))
- self.guiTheme = conf.rdStr(sec, "theme", self.guiTheme)
- self.guiSyntax = conf.rdStr(sec, "syntax", self.guiSyntax)
- self.iconTheme = conf.rdStr(sec, "icons", self.iconTheme)
- self.iconColTree = conf.rdStr(sec, "iconcoltree", self.iconColTree)
- self.iconColDocs = conf.rdBool(sec, "iconcoldocs", self.iconColDocs)
- self.guiLocale = conf.rdStr(sec, "localisation", self.guiLocale)
- self.hideVScroll = conf.rdBool(sec, "hidevscroll", self.hideVScroll)
- self.hideHScroll = conf.rdBool(sec, "hidehscroll", self.hideHScroll)
- self.lastNotes = conf.rdStr(sec, "lastnotes", self.lastNotes)
- self.nativeFont = conf.rdBool(sec, "nativefont", self.nativeFont)
+ self.guiTheme = conf.rdStr(sec, "theme", self.guiTheme)
+ self.guiSyntax = conf.rdStr(sec, "syntax", self.guiSyntax)
+ self.iconTheme = conf.rdStr(sec, "icons", self.iconTheme)
+ self.iconColTree = conf.rdStr(sec, "iconcoltree", self.iconColTree)
+ self.iconColDocs = conf.rdBool(sec, "iconcoldocs", self.iconColDocs)
+ self.guiLocale = conf.rdStr(sec, "localisation", self.guiLocale)
+ self.hideVScroll = conf.rdBool(sec, "hidevscroll", self.hideVScroll)
+ self.hideHScroll = conf.rdBool(sec, "hidehscroll", self.hideHScroll)
+ self.lastNotes = conf.rdStr(sec, "lastnotes", self.lastNotes)
+ self.nativeFont = conf.rdBool(sec, "nativefont", self.nativeFont)
+ self.useCharCount = conf.rdBool(sec, "usecharcount", self.useCharCount)
# Sizes
sec = "Sizes"
@@ -717,6 +719,7 @@ class Config:
"hidehscroll": str(self.hideHScroll),
"lastnotes": str(self.lastNotes),
"nativefont": str(self.nativeFont),
+ "usecharcount": str(self.useCharCount),
}
conf["Sizes"] = {
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 10edf7c0..4419dca0 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -38,6 +38,7 @@ 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.core.novelmodel import NovelModel
from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
from novelwriter.text.comments import processComment
@@ -89,6 +90,9 @@ class Index:
self._itemIndex = ItemIndex(project)
self._indexBroken = False
+ # Models
+ self._novelModels: dict[str, NovelModel] = {}
+
# TimeStamps
self._indexChange = 0.0
self._rootChange = {}
@@ -106,6 +110,26 @@ 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)
+
+ def _generateNovelModel(self, tHandle: str) -> None:
+ """Generate a novel model for a specific handle."""
+ if (item := SHARED.project.tree[tHandle]) and item.isRootType() and item.isNovelLike():
+ model = NovelModel(item)
+ for handle in SHARED.project.tree.subTree(tHandle):
+ if node := self._itemIndex[handle]:
+ model.append(node)
+ self._novelModels[tHandle] = model
+ return
+
##
# Public Methods
##
diff --git a/novelwriter/core/indexdata.py b/novelwriter/core/indexdata.py
index 727b0d6e..1dc6043b 100644
--- a/novelwriter/core/indexdata.py
+++ b/novelwriter/core/indexdata.py
@@ -31,6 +31,7 @@ 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
@@ -237,6 +238,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]
diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py
index cab012e0..a14b8cc0 100644
--- a/novelwriter/core/itemmodel.py
+++ b/novelwriter/core/itemmodel.py
@@ -331,7 +331,7 @@ class ProjectModel(QAbstractItemModel):
return QModelIndex()
def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex:
- """get the index of a child item of a parent."""
+ """Get the index of a child item of a parent."""
if self.hasIndex(row, column, parent):
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
if child := node.child(row):
diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py
new file mode 100644
index 00000000..6aac5552
--- /dev/null
+++ b/novelwriter/core/novelmodel.py
@@ -0,0 +1,100 @@
+"""
+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
{synopLabel}: {synopText}
" + # synopText = novIdx.synopsis + # if synopText: + # synopLabel = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]) + # synopText = f"{synopLabel}: {synopText}
" - 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) + # 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 = "{refList}
" + # refText = "" + # if refLines: + # refList = "{refList}
" - ttText = refText + synopText or self.tr("No meta data") - if ttText: - QToolTip.showText(qPos, ttText) + # ttText = refText + synopText or self.tr("No meta data") + # if ttText: + # QToolTip.showText(qPos, ttText) - return + # return - @staticmethod - def _appendMetaTag(refs: dict, key: str, lines: list[str]) -> list[str]: - """Generate a reference list for a given reference key.""" - tags = ", ".join(refs.get(key, [])) - if tags: - lines.append(f"{trConst(nwLabels.KEY_NAME[key])}: {tags}") - return lines + # @staticmethod + # def _appendMetaTag(refs: dict, key: str, lines: list[str]) -> list[str]: + # """Generate a reference list for a given reference key.""" + # tags = ", ".join(refs.get(key, [])) + # if tags: + # lines.append(f"{trConst(nwLabels.KEY_NAME[key])}: {tags}") + # return lines diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf index 1a8dd31b..fb701405 100644 --- a/tests/reference/baseConfig_novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -1,5 +1,5 @@ [Meta] -timestamp = 2025-02-06 11:05:16 +timestamp = 2025-02-22 19:57:47 [Main] font = @@ -13,6 +13,7 @@ hidevscroll = False hidehscroll = False lastnotes = 0x0 nativefont = True +usecharcount = False [Sizes] mainwindow = 1200, 650 From 71ad7c3229e7d47f3829aacbc9dd73b208d6fd9a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 22 Feb 2025 21:54:24 +0100 Subject: [PATCH 02/20] Re-implement basic novel view functionality --- novelwriter/core/index.py | 2 +- novelwriter/core/novelmodel.py | 59 ++++++--- novelwriter/extensions/modified.py | 17 ++- novelwriter/gui/noveltree.py | 190 +++++++++++++++-------------- novelwriter/gui/projtree.py | 2 +- 5 files changed, 153 insertions(+), 117 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 4419dca0..0adbe3c1 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -123,7 +123,7 @@ class Index: def _generateNovelModel(self, tHandle: str) -> None: """Generate a novel model for a specific handle.""" if (item := SHARED.project.tree[tHandle]) and item.isRootType() and item.isNovelLike(): - model = NovelModel(item) + model = NovelModel() for handle in SHARED.project.tree.subTree(tHandle): if node := self._itemIndex[handle]: model.append(node) diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py index 6aac5552..caa7c977 100644 --- a/novelwriter/core/novelmodel.py +++ b/novelwriter/core/novelmodel.py @@ -31,37 +31,45 @@ from PyQt6.QtGui import QIcon, QPixmap from novelwriter import SHARED from novelwriter.constants import nwStyles from novelwriter.core.indexdata import IndexNode -from novelwriter.core.item import NWItem from novelwriter.types import QtAlignRight logger = logging.getLogger(__name__) C_FACTOR = 0x0100 -C_TITLE_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole -C_TITLE_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole -C_COUNT_TEXT = 0x0100 | Qt.ItemDataRole.DisplayRole -C_COUNT_ALIGN = 0x0100 | Qt.ItemDataRole.TextAlignmentRole -C_EXTRA_TEXT = 0x0200 | Qt.ItemDataRole.DisplayRole -C_EXTRA_TIP = 0x0200 | Qt.ItemDataRole.ToolTipRole -C_MORE_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole +R_TEXT = Qt.ItemDataRole.DisplayRole +R_ICON = Qt.ItemDataRole.DecorationRole +R_ALIGN = Qt.ItemDataRole.TextAlignmentRole +R_TIP = Qt.ItemDataRole.ToolTipRole T_NodeData = str | QIcon | QPixmap | Qt.AlignmentFlag | None class NovelModel(QAbstractTableModel): - def __init__(self, rootItem: NWItem) -> None: + __slots__ = ("_meta", "_rows", "_more", "_columns") + + def __init__(self) -> None: super().__init__() - self._root = rootItem - self._rows: list[tuple[str, str, dict]] = [] + self._meta: list[tuple[str, str]] = [] + self._rows: list[dict[int, T_NodeData]] = [] self._more = SHARED.theme.getIcon("more_arrow") + self._columns = 3 return def __del__(self) -> None: # pragma: no cover logger.debug("Delete: NovelModel") return + ## + # Setters + ## + + def setExtraColumn(self, state: bool) -> None: + """Set extra data column settings.""" + self._columns = 4 if state else 3 + return + ## # Model Interface ## @@ -72,29 +80,42 @@ class NovelModel(QAbstractTableModel): def columnCount(self, index: QModelIndex) -> int: """Return the number of columns for an entry.""" - return 4 + return self._columns def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData: """Return display data for a node.""" if index.isValid() and (row := index.row()) < len(self._rows): - return self._rows[row][2].get(C_FACTOR*index.column() | role) + return self._rows[row].get(C_FACTOR*index.column() | role) return None + def keys(self, index: QModelIndex) -> tuple[str | None, str | None]: + """Return display data for a node.""" + if index.isValid() and (row := index.row()) < len(self._rows): + return self._meta[row] + return None, 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": iLevel = nwStyles.H_LEVEL.get(head.level, 0) + more = self._columns - 1 data = {} - data[C_TITLE_TEXT] = head.title - data[C_TITLE_ICON] = SHARED.theme.getHeaderDecoration(iLevel) - data[C_COUNT_TEXT] = f"{head.mainCount:n}" - data[C_COUNT_ALIGN] = QtAlignRight - data[C_MORE_ICON] = self._more - self._rows.append((handle, key, data)) + 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 + data[C_FACTOR*more | R_ICON] = self._more + self._meta.append((handle, key)) + self._rows.append(data) return diff --git a/novelwriter/extensions/modified.py b/novelwriter/extensions/modified.py index 67aa7453..ea0dc103 100644 --- a/novelwriter/extensions/modified.py +++ b/novelwriter/extensions/modified.py @@ -30,15 +30,15 @@ from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING -from PyQt6.QtCore import QSize, Qt, pyqtSignal, pyqtSlot +from PyQt6.QtCore import QModelIndex, QSize, Qt, pyqtSignal, pyqtSlot from PyQt6.QtGui import QMouseEvent, QWheelEvent from PyQt6.QtWidgets import ( QApplication, QComboBox, QDialog, QDoubleSpinBox, QLabel, QSpinBox, - QToolButton, QWidget + QToolButton, QTreeView, QWidget ) from novelwriter import CONFIG, SHARED -from novelwriter.types import QtMouseLeft +from novelwriter.types import QtMouseLeft, QtMouseMiddle if TYPE_CHECKING: # pragma: no cover from novelwriter.guimain import GuiMain @@ -99,6 +99,17 @@ 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: + self.middleClicked.emit(self.indexAt(event.pos())) + return super().mousePressEvent(event) + + class NComboBox(QComboBox): def __init__(self, parent: QWidget | None = None) -> None: diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index 5bb709ce..4d315731 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -29,17 +29,15 @@ import logging from enum import Enum -from PyQt6.QtCore import pyqtSignal, pyqtSlot +from PyQt6.QtCore import QModelIndex, pyqtSignal, pyqtSlot from PyQt6.QtGui import QActionGroup, QFont, QPalette -from PyQt6.QtWidgets import ( - QFrame, QHBoxLayout, QMenu, QTreeView, QTreeWidgetItem, QVBoxLayout, - QWidget -) +from PyQt6.QtWidgets import QAbstractItemView, QFrame, QHBoxLayout, QMenu, QVBoxLayout, QWidget from novelwriter import CONFIG, SHARED from novelwriter.common import qtAddAction, qtAddMenu, qtLambda -from novelwriter.enum import nwChange, nwItemClass -from novelwriter.extensions.modified import NIconToolButton +from novelwriter.core.novelmodel import NovelModel +from novelwriter.enum import nwChange, nwDocMode, nwItemClass +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 ( @@ -93,7 +91,6 @@ class GuiNovelView(QWidget): def updateTheme(self) -> None: """Update theme elements.""" self.novelBar.updateTheme() - self.novelTree.updateTheme() self.refreshTree() return @@ -104,7 +101,7 @@ class GuiNovelView(QWidget): def clearNovelView(self) -> None: """Clear project-related GUI content.""" - self.novelTree.clearContent() + # self.novelTree.clearContent() self.novelBar.clearContent() self.novelBar.setEnabled(False) return @@ -351,7 +348,7 @@ class GuiNovelToolBar(QWidget): return -class GuiNovelTree(QTreeView): +class GuiNovelTree(NTreeView): C_DATA = 0 C_TITLE = 0 @@ -372,68 +369,36 @@ class GuiNovelTree(QTreeView): 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._lastBuild = 0 + # self._lastCol = NovelTreeColumn.POV + # self._lastColSize = 0.25 + # self._actHandle = None + # self._treeMap: dict[str, QTreeWidgetItem] = {} # 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 - self.setIconSize(SHARED.theme.baseIconSize) self.setFrameStyle(QFrame.Shape.NoFrame) self.setUniformRowHeights(True) self.setAllColumnsShowFocus(True) self.setHeaderHidden(True) self.setIndentation(2) + self.setDragEnabled(False) - # self.setIconSize(iSz) - # 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") @@ -441,25 +406,87 @@ class GuiNovelTree(QTreeView): 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)) + ## + # 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(): + return model.keys(self.currentIndex()) + return None, None + + ## + # Class Methods + ## + + def resizeColumsn(self) -> None: + """Set the correct column sizes.""" + if (header := self.header()) and (model := self._getModel()): + header.setStretchLastSection(False) + header.setMinimumSectionSize(SHARED.theme.baseIconHeight + 6) + header.setSectionResizeMode(0, QtHeaderStretch) + for i in range(1, model.columnCount(QModelIndex())): + header.setSectionResizeMode(i, QtHeaderToContents) return + ## + # Private Slots + ## + + @pyqtSlot(QModelIndex) + def _onSingleClick(self, index: QModelIndex) -> None: + """The user single-clicked an index.""" + if (model := self._getModel()) and (keys := model.keys(index)) and (tHandle := keys[0]): + self.novelView.selectedItemChanged.emit(tHandle) + return + + @pyqtSlot(QModelIndex) + def _onDoubleClick(self, index: QModelIndex) -> None: + """The user double-clicked an index.""" + if ( + (model := self._getModel()) and (keys := model.keys(index)) + and (tHandle := keys[0]) and (sTitle := keys[1]) + ): + self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "", False) + return + + @pyqtSlot(QModelIndex) + def _onMiddleClick(self, index: QModelIndex) -> None: + """The user middle-clicked an index.""" + if ( + (model := self._getModel()) and (keys := model.keys(index)) + and (tHandle := keys[0]) and (sTitle := keys[1]) + ): + self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, sTitle or "", False) + return + + ## + # Internal Functions + ## + + def _getModel(self) -> NovelModel | None: + """Return the model, if it exists.""" + if isinstance(model := self.model(), NovelModel): + return model + return None + + ## + # Old Code + ## + ## # Properties ## @@ -476,23 +503,12 @@ class GuiNovelTree(QTreeView): # Class Methods ## - def clearContent(self) -> None: - """Clear the GUI content and the related maps.""" - # self.clear() - # self._treeMap = {} - # self._lastBuild = 0 - return - - def resizeColumsn(self) -> None: - """Set the correct column sizes.""" - if header := self.header(): - header.setStretchLastSection(False) - header.setMinimumSectionSize(SHARED.theme.baseIconHeight + 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) - return + # def clearContent(self) -> None: + # """Clear the GUI content and the related maps.""" + # self.clear() + # self._treeMap = {} + # self._lastBuild = 0 + # return # def refreshTree(self, rootHandle: str | None = None, overRide: bool = False) -> None: # """Refresh the tree if it has been changed.""" @@ -526,18 +542,6 @@ class GuiNovelTree(QTreeView): # return # return - 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: @@ -575,9 +579,9 @@ class GuiNovelTree(QTreeView): # self._actHandle = tHandle or None # return - # ## - # # Events - # ## + ## + # Events + ## # def mousePressEvent(self, event: QMouseEvent) -> None: # """Overload mousePressEvent to clear selection if clicking the diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 46ae15f5..877425ec 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -1022,7 +1022,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 From 43e420ab0b68fb608bb6e69a3527c5991506a07f Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 22 Feb 2025 23:13:57 +0100 Subject: [PATCH 03/20] Restore novel heading details tooltip and active row colouring --- novelwriter/core/index.py | 6 +- novelwriter/core/novelmodel.py | 6 +- novelwriter/gui/noveltree.py | 139 ++++++++++++++++++--------------- novelwriter/gui/projtree.py | 1 - 4 files changed, 83 insertions(+), 69 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 0adbe3c1..59899c6b 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -125,7 +125,11 @@ class Index: if (item := SHARED.project.tree[tHandle]) and item.isRootType() and item.isNovelLike(): model = NovelModel() for handle in SHARED.project.tree.subTree(tHandle): - if node := self._itemIndex[handle]: + if ( + (node := self._itemIndex[handle]) + and node.item.isDocumentLayout() + and node.item.isActive + ): model.append(node) self._novelModels[tHandle] = model return diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py index caa7c977..32f1c518 100644 --- a/novelwriter/core/novelmodel.py +++ b/novelwriter/core/novelmodel.py @@ -84,8 +84,10 @@ class NovelModel(QAbstractTableModel): def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData: """Return display data for a node.""" - if index.isValid() and (row := index.row()) < len(self._rows): - return self._rows[row].get(C_FACTOR*index.column() | role) + try: + return self._rows[index.row()].get(C_FACTOR*index.column() | role) + except Exception: + print("NovelModel Debug: Oops!") return None def keys(self, index: QModelIndex) -> tuple[str | None, str | None]: diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index 4d315731..c3f8a5fe 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -29,20 +29,24 @@ import logging from enum import Enum -from PyQt6.QtCore import QModelIndex, pyqtSignal, pyqtSlot -from PyQt6.QtGui import QActionGroup, QFont, QPalette -from PyQt6.QtWidgets import QAbstractItemView, QFrame, QHBoxLayout, QMenu, QVBoxLayout, QWidget +from PyQt6.QtCore import QModelIndex, QPoint, pyqtSignal, pyqtSlot +from PyQt6.QtGui import QActionGroup, QFont, QPainter, QPalette +from PyQt6.QtWidgets import ( + QAbstractItemView, QFrame, QHBoxLayout, QMenu, QStyleOptionViewItem, + QToolTip, QVBoxLayout, QWidget +) from novelwriter import CONFIG, SHARED from novelwriter.common import qtAddAction, qtAddMenu, qtLambda +from novelwriter.constants import nwKeyWords, nwLabels, trConst from novelwriter.core.novelmodel import NovelModel -from novelwriter.enum import nwChange, nwDocMode, nwItemClass +from novelwriter.enum import nwChange, nwDocMode, nwItemClass, 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 ( QtHeaderStretch, QtHeaderToContents, QtScrollAlwaysOff, QtScrollAsNeeded, - QtSizeExpanding, QtUserRole + QtSizeExpanding ) logger = logging.getLogger(__name__) @@ -166,7 +170,7 @@ class GuiNovelView(QWidget): @pyqtSlot(str) def setActiveHandle(self, tHandle: str) -> None: """Highlight the rows associated with a given handle.""" - # self.novelTree.setActiveHandle(tHandle) + self.novelTree.setActiveHandle(tHandle) return @pyqtSlot() @@ -350,17 +354,6 @@ class GuiNovelToolBar(QWidget): class GuiNovelTree(NTreeView): - C_DATA = 0 - C_TITLE = 0 - C_WORDS = 1 - C_EXTRA = 2 - C_MORE = 3 - - D_HANDLE = QtUserRole - D_TITLE = QtUserRole + 1 - D_KEY = QtUserRole + 2 - D_EXTRA = QtUserRole + 3 - def __init__(self, novelView: GuiNovelView) -> None: super().__init__(parent=novelView) @@ -369,10 +362,10 @@ class GuiNovelTree(NTreeView): self.novelView = novelView # Internal Variables + self._actHandle = None # self._lastBuild = 0 # self._lastCol = NovelTreeColumn.POV # self._lastColSize = 0.25 - # self._actHandle = None # self._treeMap: dict[str, QTreeWidgetItem] = {} # Cached Strings @@ -428,6 +421,17 @@ class GuiNovelTree(NTreeView): return model.keys(self.currentIndex()) return None, None + ## + # Setters + ## + + def setActiveHandle(self, tHandle: str | None) -> None: + """Set the handle to be highlighted.""" + self._actHandle = tHandle + if viewport := self.viewport(): + viewport.repaint() + return + ## # Class Methods ## @@ -442,6 +446,17 @@ class GuiNovelTree(NTreeView): header.setSectionResizeMode(i, QtHeaderToContents) return + ## + # Overloads + ## + + def drawRow(self, painter: QPainter, opt: QStyleOptionViewItem, index: QModelIndex) -> None: + """Draw a box on the active row.""" + if (model := self._getModel()) and model.keys(index)[0] == self._actHandle: + painter.fillRect(opt.rect, self.palette().alternateBase()) + super().drawRow(painter, opt, index) + return + ## # Private Slots ## @@ -449,8 +464,13 @@ class GuiNovelTree(NTreeView): @pyqtSlot(QModelIndex) def _onSingleClick(self, index: QModelIndex) -> None: """The user single-clicked an index.""" - if (model := self._getModel()) and (keys := model.keys(index)) and (tHandle := keys[0]): - self.novelView.selectedItemChanged.emit(tHandle) + if index.isValid() and (model := self._getModel()): + keys = model.keys(index) + if (tHandle := keys[0]) and (sTitle := keys[1]): + 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(QModelIndex) @@ -483,6 +503,40 @@ class GuiNovelTree(NTreeView): return model return None + def _popMetaBox(self, qPos: QPoint, tHandle: str, sTitle: str) -> None: + """Show the novel meta data box.""" + if head := SHARED.project.index.getItemHeading(tHandle, sTitle): + logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle) + if synopsis := head.synopsis: + label = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]) + synopsis = f"{label}: {synopsis}
" + + def appendTags(refs: dict, key: str, lines: list[str]) -> list[str]: + """Generate a reference list for a given reference key.""" + if tags := ", ".join(refs.get(key, [])): + lines.append(f"{trConst(nwLabels.KEY_NAME[key])}: {tags}") + return lines + + tags = SHARED.project.index.getReferences(tHandle, sTitle) + lines = [] + lines = appendTags(tags, nwKeyWords.POV_KEY, lines) + lines = appendTags(tags, nwKeyWords.FOCUS_KEY, lines) + lines = appendTags(tags, nwKeyWords.CHAR_KEY, lines) + lines = appendTags(tags, nwKeyWords.PLOT_KEY, lines) + lines = appendTags(tags, nwKeyWords.TIME_KEY, lines) + lines = appendTags(tags, nwKeyWords.WORLD_KEY, lines) + lines = appendTags(tags, nwKeyWords.OBJECT_KEY, lines) + lines = appendTags(tags, nwKeyWords.ENTITY_KEY, lines) + lines = appendTags(tags, nwKeyWords.CUSTOM_KEY, lines) + + text = "" + if lines: + refs = "{refs}
" + if tooltip := (text + synopsis or self.tr("No meta data")): + QToolTip.showText(qPos, tooltip) + return + ## # Old Code ## @@ -747,48 +801,3 @@ class GuiNovelTree(NTreeView): # return toolText, f"{refName}: {toolText}" # return "", "" - - # 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: - # return - - # synopText = novIdx.synopsis - # if synopText: - # synopLabel = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]) - # synopText = f"{synopLabel}: {synopText}
" - - # 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 = "{refList}
" - - # ttText = refText + synopText or self.tr("No meta data") - # if ttText: - # QToolTip.showText(qPos, ttText) - - # return - - # @staticmethod - # def _appendMetaTag(refs: dict, key: str, lines: list[str]) -> list[str]: - # """Generate a reference list for a given reference key.""" - # tags = ", ".join(refs.get(key, [])) - # if tags: - # lines.append(f"{trConst(nwLabels.KEY_NAME[key])}: {tags}") - # return lines diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 877425ec..0d8575a2 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -517,7 +517,6 @@ class GuiProjectTree(QTreeView): def initSettings(self) -> None: """Set or update tree widget settings.""" - # Scroll bars if CONFIG.hideVScroll: self.setVerticalScrollBarPolicy(QtScrollAlwaysOff) else: From 4da120633e394071621ba5cfb49c06ac228e453a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 23 Feb 2025 22:34:53 +0100 Subject: [PATCH 04/20] Save handle and title key with other data in novel model --- novelwriter/core/novelmodel.py | 44 +++++++++++++++++++----------- novelwriter/extensions/modified.py | 7 +++-- novelwriter/gui/noveltree.py | 22 ++++++++------- 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py index 32f1c518..8d5a644f 100644 --- a/novelwriter/core/novelmodel.py +++ b/novelwriter/core/novelmodel.py @@ -37,21 +37,22 @@ 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_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 +T_NodeData = str | tuple[str, str] | QIcon | QPixmap | Qt.AlignmentFlag | None class NovelModel(QAbstractTableModel): - __slots__ = ("_meta", "_rows", "_more", "_columns") + __slots__ = ("_rows", "_more", "_columns") def __init__(self) -> None: super().__init__() - self._meta: list[tuple[str, str]] = [] self._rows: list[dict[int, T_NodeData]] = [] self._more = SHARED.theme.getIcon("more_arrow") self._columns = 3 @@ -90,11 +91,21 @@ class NovelModel(QAbstractTableModel): print("NovelModel Debug: Oops!") return None - def keys(self, index: QModelIndex) -> tuple[str | None, str | None]: - """Return display data for a node.""" - if index.isValid() and (row := index.row()) < len(self._rows): - return self._meta[row] - return None, 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: + print("NovelModel Debug: Oops!") + 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: + print("NovelModel Debug: Oops!") + return None ## # Data Methods @@ -113,11 +124,12 @@ class NovelModel(QAbstractTableModel): iLevel = nwStyles.H_LEVEL.get(head.level, 0) more = self._columns - 1 data = {} - 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*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 data[C_FACTOR*more | R_ICON] = self._more - self._meta.append((handle, key)) + data[R_HANDLE] = handle + data[R_KEY] = key self._rows.append(data) return diff --git a/novelwriter/extensions/modified.py b/novelwriter/extensions/modified.py index ea0dc103..8d20912a 100644 --- a/novelwriter/extensions/modified.py +++ b/novelwriter/extensions/modified.py @@ -105,8 +105,11 @@ class NTreeView(QTreeView): def mousePressEvent(self, event: QMouseEvent | None) -> None: """Emit a signal on mouse middle click.""" - if event and event.button() == QtMouseMiddle: - self.middleClicked.emit(self.indexAt(event.pos())) + if ( + event and event.button() == QtMouseMiddle + and (index := self.indexAt(event.pos())).isValid() + ): + self.middleClicked.emit(index) return super().mousePressEvent(event) diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index c3f8a5fe..c6a53ad6 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -418,7 +418,8 @@ class GuiNovelTree(NTreeView): items are selected, return the first. """ if model := self._getModel(): - return model.keys(self.currentIndex()) + index = self.currentIndex() + return model.handle(index), model.key(index) return None, None ## @@ -452,7 +453,7 @@ class GuiNovelTree(NTreeView): def drawRow(self, painter: QPainter, opt: QStyleOptionViewItem, index: QModelIndex) -> None: """Draw a box on the active row.""" - if (model := self._getModel()) and model.keys(index)[0] == self._actHandle: + if (model := self._getModel()) and model.handle(index) == self._actHandle: painter.fillRect(opt.rect, self.palette().alternateBase()) super().drawRow(painter, opt, index) return @@ -465,8 +466,7 @@ class GuiNovelTree(NTreeView): def _onSingleClick(self, index: QModelIndex) -> None: """The user single-clicked an index.""" if index.isValid() and (model := self._getModel()): - keys = model.keys(index) - if (tHandle := keys[0]) and (sTitle := keys[1]): + 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()) @@ -477,20 +477,22 @@ class GuiNovelTree(NTreeView): def _onDoubleClick(self, index: QModelIndex) -> None: """The user double-clicked an index.""" if ( - (model := self._getModel()) and (keys := model.keys(index)) - and (tHandle := keys[0]) and (sTitle := keys[1]) + (model := self._getModel()) + and (tHandle := model.handle(index)) + and (sTitle := model.key(index)) ): - self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "", False) + self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle, False) return @pyqtSlot(QModelIndex) def _onMiddleClick(self, index: QModelIndex) -> None: """The user middle-clicked an index.""" if ( - (model := self._getModel()) and (keys := model.keys(index)) - and (tHandle := keys[0]) and (sTitle := keys[1]) + (model := self._getModel()) + and (tHandle := model.handle(index)) + and (sTitle := model.key(index)) ): - self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, sTitle or "", False) + self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, sTitle, False) return ## From cf42eeeae3bd2942dec80cebe8d3c7b2452ee6d5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 22 Mar 2025 16:32:52 +0100 Subject: [PATCH 05/20] Fix typos --- CHANGELOG.md | 2 +- novelwriter/guimain.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e83846e1..4d4d6ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1173,7 +1173,7 @@ _These Release Notes also include the changes from the 2.2 Beta 1 and 2.2 RC 1 r **Usability** * Use `Ctrl+K, H` for inserting short description comments (alias to synopsis), drop the space - after the `%` symbol when inserting special comments, add a browse icon to the open open project + after the `%` symbol when inserting special comments, add a browse icon to the open project dialog, and remove the popup warning for Alpha releases. PR #1626. * Menu entries no longer clear the status bar message when they are hovered. This was caused by a status tip feature in Qt, which prints a blank message to the status bar. PR #1630. diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 607bec50..30467411 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -532,7 +532,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: From edf765f91ff977d058dc6adcfbceb6fc36eca6cc Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 22 Mar 2025 16:51:10 +0100 Subject: [PATCH 06/20] Allow novel model refresh --- novelwriter/core/index.py | 28 +++++++++++++++++++++------- novelwriter/gui/noveltree.py | 4 +++- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 59899c6b..be8df2ed 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -120,20 +120,34 @@ class Index: self._generateNovelModel(tHandle) return self._novelModels.get(tHandle) + def refreshNovelModel(self, tHandle: str) -> None: + """Refresh a novel model.""" + if model := self.getNovelModel(tHandle): + model.beginResetModel() + model.clear() + self._appendSubTreeToModel(tHandle, model) + model.endResetModel() + return + def _generateNovelModel(self, tHandle: str) -> None: """Generate a novel model for a specific handle.""" if (item := SHARED.project.tree[tHandle]) and item.isRootType() and item.isNovelLike(): model = NovelModel() - for handle in SHARED.project.tree.subTree(tHandle): - if ( - (node := self._itemIndex[handle]) - and node.item.isDocumentLayout() - and node.item.isActive - ): - model.append(node) + 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 SHARED.project.tree.subTree(tHandle): + if ( + (node := self._itemIndex[handle]) + and node.item.isDocumentLayout() + and node.item.isActive + ): + model.append(node) + return + ## # Public Methods ## diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index c6a53ad6..b512ab51 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -323,7 +323,9 @@ class GuiNovelToolBar(QWidget): @pyqtSlot() def _refreshNovelTree(self) -> None: """Rebuild the current tree.""" - self.novelView.setCurrentNovel(SHARED.project.data.getLastHandle("novelTree")) + if rootHandle := self.novelValue.handle: + SHARED.project.index.refreshNovelModel(rootHandle) + # self.novelView.setCurrentNovel(SHARED.project.data.getLastHandle("novelTree")) return @pyqtSlot() From 9990ebf193b9fc67eed7e9029f3089ab1aebf847 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 22 Mar 2025 20:25:34 +0100 Subject: [PATCH 07/20] Handle novel model refresh scenarios --- novelwriter/core/index.py | 3 +++ novelwriter/core/item.py | 6 ++++++ novelwriter/core/itemmodel.py | 5 ++++- novelwriter/core/tree.py | 10 ++++++++- novelwriter/gui/noveltree.py | 38 ++++++++++++++++++++++++++++++----- novelwriter/gui/projtree.py | 2 ++ novelwriter/guimain.py | 3 +++ novelwriter/shared.py | 3 ++- 8 files changed, 62 insertions(+), 8 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index be8df2ed..398a49b2 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -123,6 +123,7 @@ class Index: def refreshNovelModel(self, tHandle: str) -> None: """Refresh a novel model.""" if model := self.getNovelModel(tHandle): + logger.debug("Refreshing novel model '%s'", tHandle) model.beginResetModel() model.clear() self._appendSubTreeToModel(tHandle, model) @@ -170,6 +171,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: diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index 38a2dbcc..3bbb8cec 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -293,6 +293,12 @@ class NWItem: self._project.tree.refreshItems([self._handle]) return + def notifyNovelStructureChange(self) -> None: + """Notify that the structure of a novel has changed.""" + if self._root and self._class == nwItemClass.NOVEL: + self._project.tree.novelStructureChanged(self._root) + return + ## # Lookup Methods ## diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py index a14b8cc0..4d52aabd 100644 --- a/novelwriter/core/itemmodel.py +++ b/novelwriter/core/itemmodel.py @@ -193,7 +193,7 @@ class ProjectNode: return self._parent def child(self, row: int) -> ProjectNode | None: - """Return a child ofg the node.""" + """Return a child of the node.""" if 0 <= row < len(self._children): return self._children[row] return None @@ -218,6 +218,7 @@ class ProjectNode: child._row = len(self._children) self._children.append(child) self._refreshChildrenPos() + self._item.notifyNovelStructureChange() return def takeChild(self, pos: int) -> ProjectNode | None: @@ -226,6 +227,7 @@ class ProjectNode: node = self._children.pop(pos) self._refreshChildrenPos() self.updateCount() + self._item.notifyNovelStructureChange() return node return None @@ -236,6 +238,7 @@ class ProjectNode: node = self._children.pop(source) self._children.insert(target, node) self._refreshChildrenPos() + self._item.notifyNovelStructureChange() return def setExpanded(self, state: bool) -> None: diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 002ff811..be582526 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -60,7 +60,7 @@ class NWTree: also used for file names. """ - __slots__ = ("_project", "_model", "_items", "_nodes", "_trash") + __slots__ = ("_project", "_model", "_items", "_nodes", "_trash", "_ready") def __init__(self, project: NWProject) -> None: self._project = project @@ -68,6 +68,7 @@ class NWTree: self._items: dict[str, NWItem] = {} self._nodes: dict[str, ProjectNode] = {} self._trash = None + self._ready = False logger.debug("Ready: NWTree") return @@ -249,6 +250,7 @@ class NWTree: logger.error("Not all items could be added to project tree") self._trash = self._getTrashNode() + self._ready = True self._model.endInsertRows() self._model.layoutChanged.emit() @@ -278,6 +280,12 @@ class NWTree: self._model.layoutChanged.emit() return + def novelStructureChanged(self, tHandle: str) -> None: + """Emit a novel structure change signal.""" + if self._ready: + SHARED.novelStructureChanged.emit(tHandle) + return + def checkConsistency(self, prefix: str) -> tuple[int, int]: """Check the project tree consistency. Also check the content folder and add back files that were discovered but were not diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index b512ab51..3e02d750 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -84,6 +84,7 @@ class GuiNovelView(QWidget): self.setLayout(self.outerBox) # Function Mappings + self.setActive = self.novelBar.setActive self.getSelectedHandle = self.novelTree.getSelectedHandle return @@ -203,6 +204,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) @@ -227,7 +231,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) @@ -261,6 +265,9 @@ class GuiNovelToolBar(QWidget): self.updateTheme() + # Connect Signals + SHARED.novelStructureChanged.connect(self._refreshNovelTree) + logger.debug("Ready: GuiNovelToolBar") return @@ -316,16 +323,37 @@ class GuiNovelToolBar(QWidget): # self.novelView.novelTree.setLastColType(colType, doRefresh=doRefresh) 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: + self._refreshNovelTree(self.novelValue.handle) + return + ## # Private Slots ## @pyqtSlot() - def _refreshNovelTree(self) -> None: + def _forceRefreshNovelTree(self) -> None: """Rebuild the current tree.""" - if rootHandle := self.novelValue.handle: - SHARED.project.index.refreshNovelModel(rootHandle) - # self.novelView.setCurrentNovel(SHARED.project.data.getLastHandle("novelTree")) + self._refresh[self.novelValue.handle] = True + self._refreshNovelTree(self.novelValue.handle) + return + + @pyqtSlot(str) + def _refreshNovelTree(self, tHandle: str) -> None: + """Refresh or schedule refresh of a novel tree.""" + if tHandle: + if self._active: + if self._refresh.get(tHandle, False): + SHARED.project.index.refreshNovelModel(tHandle) + self._refresh[tHandle] = False + else: + self._refresh[tHandle] = True + print(self._refresh) return @pyqtSlot() diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 0d8575a2..7e15c2fa 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -1399,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() @@ -1415,6 +1416,7 @@ class _TreeContextMenu(QMenu): self._item.setType(nwItemType.FILE) self._item.setLayout(nwItemLayout.DOCUMENT) self._item.notifyToRefresh() + self._item.notifyNovelStructureChange() elif msgYes and itemLayout == nwItemLayout.NOTE: self._item.setType(nwItemType.FILE) self._item.setLayout(nwItemLayout.NOTE) diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 30467411..98b2c7ef 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -1163,15 +1163,18 @@ class GuiMain(QMainWindow): elif view == nwView.PROJECT: self.mainStack.setCurrentWidget(self.splitMain) self.projStack.setCurrentWidget(self.projView) + self.novelView.setActive(False) elif view == nwView.NOVEL: self.mainStack.setCurrentWidget(self.splitMain) self.projStack.setCurrentWidget(self.novelView) + self.novelView.setActive(True) elif view == nwView.SEARCH: self.mainStack.setCurrentWidget(self.splitMain) self.projStack.setCurrentWidget(self.projSearch) self.projSearch.beginSearch( self.docEditor.getSelectedText() if self.docEditor.anyFocus() else "" ) + self.novelView.setActive(False) elif view == nwView.OUTLINE: self.mainStack.setCurrentWidget(self.outlineView) return diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 588252d9..17b0a58f 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -63,10 +63,11 @@ class SharedData(QObject): indexChangedTags = pyqtSignal(list, list) indexCleared = pyqtSignal() mainClockTick = pyqtSignal() + novelStructureChanged = pyqtSignal(str) projectItemChanged = pyqtSignal(str, Enum) - rootFolderChanged = pyqtSignal(str, Enum) projectStatusChanged = pyqtSignal(bool) projectStatusMessage = pyqtSignal(str) + rootFolderChanged = pyqtSignal(str, Enum) spellLanguageChanged = pyqtSignal(str, str) statusLabelsChanged = pyqtSignal(str) From ef652355bf8c902fa4de18b8e48c937c79613645 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 22 Mar 2025 21:10:01 +0100 Subject: [PATCH 08/20] Refresh model when document heading count changes --- novelwriter/core/index.py | 77 ++++++++++++++++-------------- novelwriter/gui/doceditor.py | 11 ----- novelwriter/gui/noveltree.py | 29 +++++------ novelwriter/gui/theme.py | 2 +- novelwriter/guimain.py | 12 ++--- tests/test_core/test_core_index.py | 9 ---- 6 files changed, 59 insertions(+), 81 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 398a49b2..550fe3f6 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -120,35 +120,6 @@ class Index: self._generateNovelModel(tHandle) return self._novelModels.get(tHandle) - def refreshNovelModel(self, tHandle: str) -> None: - """Refresh a novel model.""" - if model := self.getNovelModel(tHandle): - logger.debug("Refreshing novel model '%s'", tHandle) - model.beginResetModel() - model.clear() - self._appendSubTreeToModel(tHandle, model) - model.endResetModel() - return - - def _generateNovelModel(self, tHandle: str) -> None: - """Generate a novel model for a specific handle.""" - if (item := SHARED.project.tree[tHandle]) and item.isRootType() and item.isNovelLike(): - model = NovelModel() - 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 SHARED.project.tree.subTree(tHandle): - if ( - (node := self._itemIndex[handle]) - and node.item.isDocumentLayout() - and node.item.isActive - ): - model.append(node) - return - ## # Public Methods ## @@ -207,6 +178,16 @@ class Index: return self._rootChange.get(rootHandle, self._indexChange) > float(checkTime) return False + def refreshNovelModel(self, tHandle: str) -> None: + """Refresh a novel model.""" + if model := self.getNovelModel(tHandle): + logger.info("Refreshing novel model '%s'", tHandle) + model.beginResetModel() + model.clear() + self._appendSubTreeToModel(tHandle, model) + model.endResetModel() + return + ## # Load and Save Index to/from File ## @@ -304,6 +285,7 @@ class Index: # Keep a record of existing tags, and create a new item entry itemTags = dict.fromkeys(self._itemIndex.allItemTags(tHandle), False) + itemHeadings = self._itemIndex.headingCount(tHandle) self._itemIndex.add(tHandle, tItem) # Run word counter for the whole text @@ -327,6 +309,11 @@ class Index: else: self._scanActive(tHandle, tItem, text, itemTags) + if itemHeadings == self._itemIndex.headingCount(tHandle): + pass + else: + tItem.notifyNovelStructureChange() + # Update timestamps for index changes nowTime = time() self._indexChange = nowTime @@ -467,6 +454,25 @@ class Index: return + def _generateNovelModel(self, tHandle: str) -> None: + """Generate a novel model for a specific handle.""" + if (item := SHARED.project.tree[tHandle]) and item.isRootType() and item.isNovelLike(): + model = NovelModel() + 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 SHARED.project.tree.subTree(tHandle): + if ( + (node := self._itemIndex[handle]) + and node.item.isDocumentLayout() + and node.item.isActive + ): + model.append(node) + return + ## # Check @ Lines ## @@ -607,13 +613,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]]: @@ -916,6 +915,12 @@ class ItemIndex: self._items[tHandle] = IndexNode(tHandle, nwItem) return + def headingCount(self, tHandle: str) -> int: + """Return the number of headings in this item.""" + if tHandle in self._items: + return len(self._items[tHandle]) + return 0 + def allItemTags(self, tHandle: str) -> list[str]: """Get all tags set for headings of an item.""" if tHandle in self._items: diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 58230c7d..69e24763 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -115,7 +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) @@ -498,18 +497,8 @@ class GuiDocEditor(QPlainTextEdit): self.setDocumentChanged(False) self.docTextChanged.emit(self._docHandle, self._lastEdit) - - oldCount = SHARED.project.index.getHandleHeaderCount(tHandle) SHARED.project.index.scanText(tHandle, text) - newCount = SHARED.project.index.getHandleHeaderCount(tHandle) - if self._nwItem.itemClass == nwItemClass.NOVEL: - if oldCount == newCount: - self.novelItemMetaChanged.emit(tHandle) - else: - self.novelStructureChanged.emit() - - # Update the status bar self.updateStatusMessage.emit(self.tr("Saved Document: {0}").format(self._nwItem.itemName)) return True diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index 3e02d750..5710cb35 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -96,7 +96,6 @@ class GuiNovelView(QWidget): def updateTheme(self) -> None: """Update theme elements.""" self.novelBar.updateTheme() - self.refreshTree() return def initSettings(self) -> None: @@ -174,12 +173,6 @@ class GuiNovelView(QWidget): 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.""" @@ -296,6 +289,8 @@ class GuiNovelToolBar(QWidget): self.novelValue.refreshNovelList() self.tbNovel.setVisible(self.novelValue.count() > 1) + self._forceRefreshNovelTree() + return def clearContent(self) -> None: @@ -328,7 +323,7 @@ class GuiNovelToolBar(QWidget): refresh when content structure changes. """ self._active = state - if self._active: + if self._active and self._refresh.get(self.novelValue.handle, False): self._refreshNovelTree(self.novelValue.handle) return @@ -339,21 +334,19 @@ class GuiNovelToolBar(QWidget): @pyqtSlot() def _forceRefreshNovelTree(self) -> None: """Rebuild the current tree.""" - self._refresh[self.novelValue.handle] = True - self._refreshNovelTree(self.novelValue.handle) + if tHandle := self.novelValue.handle: + 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 tHandle: - if self._active: - if self._refresh.get(tHandle, False): - SHARED.project.index.refreshNovelModel(tHandle) - self._refresh[tHandle] = False - else: - self._refresh[tHandle] = True - print(self._refresh) + if self._active: + SHARED.project.index.refreshNovelModel(tHandle) + self._refresh[tHandle] = False + else: + self._refresh[tHandle] = True return @pyqtSlot() diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index d7578389..729db7ee 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -694,7 +694,7 @@ class GuiIcons: else: icon = self._loadIcon(name, color, w, h) self._qIcons[key] = icon - logger.info("Icon: %s", key) + logger.debug("Icon: %s", key) return icon def getToggleIcon(self, name: str, size: tuple[int, int], color: str | None = None) -> QIcon: diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 98b2c7ef..3f5ca87d 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -247,8 +247,6 @@ class GuiMain(QMainWindow): self.docEditor.itemHandleChanged.connect(self.novelView.setActiveHandle) self.docEditor.itemHandleChanged.connect(self.projView.setActiveHandle) self.docEditor.loadDocumentTagRequest.connect(self._followTag) - self.docEditor.novelItemMetaChanged.connect(self.novelView.updateNovelItemMeta) - self.docEditor.novelStructureChanged.connect(self.novelView.refreshTree) self.docEditor.openDocumentRequest.connect(self._openDocument) self.docEditor.requestNewNoteCreation.connect(SHARED.createNewNote) self.docEditor.requestNextDocument.connect(self.openNextDocument) @@ -735,7 +733,6 @@ class GuiMain(QMainWindow): SHARED.project.index.rebuild() SHARED.project.tree.refreshAllItems() - self.novelView.refreshTree() tEnd = time() self.mainStatus.setStatusMessage( @@ -1163,20 +1160,23 @@ class GuiMain(QMainWindow): elif view == nwView.PROJECT: self.mainStack.setCurrentWidget(self.splitMain) self.projStack.setCurrentWidget(self.projView) - self.novelView.setActive(False) elif view == nwView.NOVEL: self.mainStack.setCurrentWidget(self.splitMain) self.projStack.setCurrentWidget(self.novelView) - self.novelView.setActive(True) elif view == nwView.SEARCH: self.mainStack.setCurrentWidget(self.splitMain) self.projStack.setCurrentWidget(self.projSearch) self.projSearch.beginSearch( self.docEditor.getSelectedText() if self.docEditor.anyFocus() else "" ) - self.novelView.setActive(False) elif view == nwView.OUTLINE: self.mainStack.setCurrentWidget(self.outlineView) + + # Set active status + isMain = self.mainStack.currentWidget() == self.splitMain + isNovel = self.projStack.currentWidget() == self.novelView + self.novelView.setActive(isMain and isNovel) + return @pyqtSlot(nwDocAction) diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 0b24a788..d22fba9f 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -708,15 +708,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 # ============= From 848027856d6036d38932bb0bea21eaacf8c916bb Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 22 Mar 2025 22:37:03 +0100 Subject: [PATCH 09/20] Allow refreshing a novel model node --- novelwriter/core/index.py | 31 ++++++++------ novelwriter/core/novelmodel.py | 78 ++++++++++++++++++++++++++++------ 2 files changed, 84 insertions(+), 25 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 550fe3f6..e3c5517c 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -178,9 +178,9 @@ class Index: return self._rootChange.get(rootHandle, self._indexChange) > float(checkTime) return False - def refreshNovelModel(self, tHandle: str) -> None: + def refreshNovelModel(self, tHandle: str | None) -> None: """Refresh a novel model.""" - if model := self.getNovelModel(tHandle): + if tHandle and (model := self.getNovelModel(tHandle)): logger.info("Refreshing novel model '%s'", tHandle) model.beginResetModel() model.clear() @@ -188,6 +188,19 @@ class Index: 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 ## @@ -285,7 +298,6 @@ class Index: # Keep a record of existing tags, and create a new item entry itemTags = dict.fromkeys(self._itemIndex.allItemTags(tHandle), False) - itemHeadings = self._itemIndex.headingCount(tHandle) self._itemIndex.add(tHandle, tItem) # Run word counter for the whole text @@ -309,10 +321,9 @@ class Index: else: self._scanActive(tHandle, tItem, text, itemTags) - if itemHeadings == self._itemIndex.headingCount(tHandle): - pass - else: - tItem.notifyNovelStructureChange() + if tItem.itemClass == nwItemClass.NOVEL and not blockSignal: + if not self.updateNovelModelData(tItem): + self.refreshNovelModel(tItem.itemRoot) # Update timestamps for index changes nowTime = time() @@ -915,12 +926,6 @@ class ItemIndex: self._items[tHandle] = IndexNode(tHandle, nwItem) return - def headingCount(self, tHandle: str) -> int: - """Return the number of headings in this item.""" - if tHandle in self._items: - return len(self._items[tHandle]) - return 0 - def allItemTags(self, tHandle: str) -> list[str]: """Get all tags set for headings of an item.""" if tHandle in self._items: diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py index 8d5a644f..a5f425d8 100644 --- a/novelwriter/core/novelmodel.py +++ b/novelwriter/core/novelmodel.py @@ -30,7 +30,8 @@ from PyQt6.QtGui import QIcon, QPixmap from novelwriter import SHARED from novelwriter.constants import nwStyles -from novelwriter.core.indexdata import IndexNode +from novelwriter.core.indexdata import IndexHeading, IndexNode +from novelwriter.error import logException from novelwriter.types import QtAlignRight logger = logging.getLogger(__name__) @@ -121,15 +122,68 @@ class NovelModel(QAbstractTableModel): handle = node.handle for key, head in node.items(): if key != "T0000": - iLevel = nwStyles.H_LEVEL.get(head.level, 0) - more = self._columns - 1 - data = {} - 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 - data[C_FACTOR*more | R_ICON] = self._more - data[R_HANDLE] = handle - data[R_KEY] = key - self._rows.append(data) + 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 == []: + return False + + cols = self._columns - 1 + + first = current[0] + last = current[-1] + remains = [] + try: + 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: + 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: + self.beginRemoveRows(QModelIndex(), current[0], current[-1]) + del self._rows[current[0]:current[-1] + 1] + self.endRemoveRows() + + except Exception: + # This is faster than to check for index boundaries. + # We definitely don't want to cause a crash. + logException() + return False + + return True + + ## + # Internal Functions + ## + + def _generateEntry(self, handle: str, key: str, heading: IndexHeading) -> dict: + """Generate a cache entry.""" + iLevel = nwStyles.H_LEVEL.get(heading.level, 0) + more = self._columns - 1 + data = {} + data[C_FACTOR*0 | R_TEXT] = heading.title + data[C_FACTOR*0 | R_ICON] = SHARED.theme.getHeaderDecoration(iLevel) + data[C_FACTOR*1 | R_TEXT] = f"{heading.mainCount:n}" + data[C_FACTOR*1 | R_ALIGN] = QtAlignRight + data[C_FACTOR*more | R_ICON] = self._more + data[R_HANDLE] = handle + data[R_KEY] = key + return data From 04c68475902fd0036679a09ed60cacc2a76564c7 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 22 Mar 2025 22:52:36 +0100 Subject: [PATCH 10/20] Add some error checking and handling --- novelwriter/core/novelmodel.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py index a5f425d8..d4dafa35 100644 --- a/novelwriter/core/novelmodel.py +++ b/novelwriter/core/novelmodel.py @@ -77,11 +77,11 @@ class NovelModel(QAbstractTableModel): ## def rowCount(self, index: QModelIndex) -> int: - """Return the number of rows for an entry.""" + """Return the number of rows.""" return len(self._rows) def columnCount(self, index: QModelIndex) -> int: - """Return the number of columns for an entry.""" + """Return the number of columns.""" return self._columns def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData: @@ -89,7 +89,7 @@ class NovelModel(QAbstractTableModel): try: return self._rows[index.row()].get(C_FACTOR*index.column() | role) except Exception: - print("NovelModel Debug: Oops!") + logger.error("Novel model index is inconsistent") return None def handle(self, index: QModelIndex) -> str | None: @@ -97,7 +97,7 @@ class NovelModel(QAbstractTableModel): try: return self._rows[index.row()].get(R_HANDLE) # type: ignore except Exception: - print("NovelModel Debug: Oops!") + logger.error("Novel model index is inconsistent") return None def key(self, index: QModelIndex) -> str | None: @@ -105,7 +105,7 @@ class NovelModel(QAbstractTableModel): try: return self._rows[index.row()].get(R_KEY) # type: ignore except Exception: - print("NovelModel Debug: Oops!") + logger.error("Novel model index is inconsistent") return None ## @@ -134,12 +134,17 @@ class NovelModel(QAbstractTableModel): 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] + + if len(current) != last - first + 1: + logger.warning("Novel model entries for '%s' are not continuous", handle) + return False + remains = [] try: for key, head in node.items(): @@ -165,6 +170,7 @@ class NovelModel(QAbstractTableModel): except Exception: # This is faster than to check for index boundaries. # We definitely don't want to cause a crash. + logger.error("Novel model refresh error for '%s'", handle) logException() return False From 9d63a018e847f58a1785b9bf950db088c16441cd Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 23 Mar 2025 00:42:35 +0100 Subject: [PATCH 11/20] Add back extra column GUI handling --- novelwriter/core/index.py | 19 +++- novelwriter/core/novelmodel.py | 41 ++++++-- novelwriter/enum.py | 8 ++ novelwriter/gui/noveltree.py | 168 ++++++++++++++++----------------- 4 files changed, 143 insertions(+), 93 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index e3c5517c..827d287a 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -39,7 +39,7 @@ 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.core.novelmodel import NovelModel -from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType +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 @@ -92,6 +92,7 @@ class Index: # Models self._novelModels: dict[str, NovelModel] = {} + self._novelExtra = nwNovelExtra.HIDDEN # TimeStamps self._indexChange = 0.0 @@ -120,6 +121,15 @@ class Index: 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 ## @@ -184,6 +194,7 @@ class Index: logger.info("Refreshing novel model '%s'", tHandle) model.beginResetModel() model.clear() + model.setExtraColumn(self._novelExtra) self._appendSubTreeToModel(tHandle, model) model.endResetModel() return @@ -439,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 @@ -469,6 +481,7 @@ class Index: """Generate a novel model for a specific handle.""" if (item := SHARED.project.tree[tHandle]) and item.isRootType() and item.isNovelLike(): model = NovelModel() + model.setExtraColumn(self._novelExtra) self._appendSubTreeToModel(tHandle, model) self._novelModels[tHandle] = model return diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py index d4dafa35..e52b3bc2 100644 --- a/novelwriter/core/novelmodel.py +++ b/novelwriter/core/novelmodel.py @@ -25,12 +25,14 @@ from __future__ import annotations import logging +from PyQt5.QtCore import QSize from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt from PyQt6.QtGui import QIcon, QPixmap from novelwriter import SHARED -from novelwriter.constants import nwStyles +from novelwriter.constants import nwKeyWords, nwStyles from novelwriter.core.indexdata import IndexHeading, IndexNode +from novelwriter.enum import nwNovelExtra from novelwriter.error import logException from novelwriter.types import QtAlignRight @@ -50,26 +52,49 @@ T_NodeData = str | tuple[str, str] | QIcon | QPixmap | Qt.AlignmentFlag | None class NovelModel(QAbstractTableModel): - __slots__ = ("_rows", "_more", "_columns") + __slots__ = ("_rows", "_header", "_more", "_columns", "_extra") def __init__(self) -> None: super().__init__() self._rows: list[dict[int, T_NodeData]] = [] + self._header: list[QSize] = [] self._more = SHARED.theme.getIcon("more_arrow") self._columns = 3 + self._extra = "" 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, state: bool) -> None: + def setExtraColumn(self, extra: nwNovelExtra) -> None: """Set extra data column settings.""" - self._columns = 4 if state else 3 + match extra: + case nwNovelExtra.HIDDEN: + self._columns = 3 + self._extra = "" + case nwNovelExtra.POV: + self._columns = 4 + self._extra = nwKeyWords.POV_KEY + case nwNovelExtra.FOCUS: + self._columns = 4 + self._extra = nwKeyWords.FOCUS_KEY + case nwNovelExtra.PLOT: + self._columns = 4 + self._extra = nwKeyWords.PLOT_KEY return ## @@ -183,13 +208,17 @@ class NovelModel(QAbstractTableModel): def _generateEntry(self, handle: str, key: str, heading: IndexHeading) -> dict: """Generate a cache entry.""" iLevel = nwStyles.H_LEVEL.get(heading.level, 0) - more = self._columns - 1 data = {} data[C_FACTOR*0 | R_TEXT] = heading.title data[C_FACTOR*0 | R_ICON] = SHARED.theme.getHeaderDecoration(iLevel) data[C_FACTOR*1 | R_TEXT] = f"{heading.mainCount:n}" data[C_FACTOR*1 | R_ALIGN] = QtAlignRight - data[C_FACTOR*more | R_ICON] = self._more + if self._columns == 3: + data[C_FACTOR*2 | R_ICON] = self._more + else: + data[C_FACTOR*2 | R_TIP] = "Hello World" + data[C_FACTOR*2 | R_TEXT] = "Hello World" + data[C_FACTOR*3 | R_ICON] = self._more data[R_HANDLE] = handle data[R_KEY] = key return data diff --git a/novelwriter/enum.py b/novelwriter/enum.py index c9d871b3..a837d824 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -180,6 +180,14 @@ class nwOutline(Enum): SYNOP = 19 +class nwNovelExtra(Enum): + + HIDDEN = 0 + POV = 1 + FOCUS = 2 + PLOT = 3 + + class nwBuildFmt(Enum): ODT = 0 diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index 5710cb35..c26d0fdd 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -30,36 +30,28 @@ import logging from enum import Enum from PyQt6.QtCore import QModelIndex, QPoint, pyqtSignal, pyqtSlot -from PyQt6.QtGui import QActionGroup, QFont, QPainter, QPalette +from PyQt6.QtGui import QActionGroup, QFont, QPainter, QPalette, QResizeEvent from PyQt6.QtWidgets import ( - QAbstractItemView, QFrame, QHBoxLayout, QMenu, QStyleOptionViewItem, - QToolTip, QVBoxLayout, QWidget + QAbstractItemView, QFrame, QHBoxLayout, QInputDialog, QMenu, + QStyleOptionViewItem, QToolTip, QVBoxLayout, QWidget ) from novelwriter import CONFIG, SHARED -from novelwriter.common import qtAddAction, qtAddMenu, qtLambda +from novelwriter.common import minmax, qtAddAction, qtAddMenu, qtLambda from novelwriter.constants import nwKeyWords, nwLabels, trConst from novelwriter.core.novelmodel import NovelModel -from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwOutline +from novelwriter.enum import nwChange, nwDocMode, nwItemClass, 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 ( - QtHeaderStretch, QtHeaderToContents, QtScrollAlwaysOff, QtScrollAsNeeded, - QtSizeExpanding + QtHeaderFixed, 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 @@ -119,11 +111,11 @@ class GuiNovelView(QWidget): 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 ) - # lastColSize = SHARED.project.options.getInt( - # "GuiNovelView", "lastColSize", 25 - # ) self.clearNovelView() self.novelBar.buildNovelRootMenu() @@ -131,18 +123,18 @@ class GuiNovelView(QWidget): self.novelBar.setCurrentRoot(lastNovel) self.novelBar.setEnabled(True) - # self.novelTree.setLastColSize(lastColSize) + self.novelTree.setLastColSize(lastColSize) return def closeProjectTasks(self) -> None: """Run closing project tasks.""" - # 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) + lastColType = self.novelTree.lastColType + lastColSize = self.novelTree.lastColSize + logger.debug("Saving State: GuiNovelView") + options = SHARED.project.options + options.setValue("GuiNovelView", "lastCol", lastColType) + options.setValue("GuiNovelView", "lastColSize", lastColSize) self.clearNovelView() return @@ -164,7 +156,7 @@ class GuiNovelView(QWidget): """Set the current novel to display.""" if rootHandle and (model := SHARED.project.index.getNovelModel(rootHandle)): self.novelTree.setModel(model) - self.novelTree.resizeColumsn() + self.novelTree.resizeColumns() return @pyqtSlot(str) @@ -232,10 +224,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")) @@ -312,10 +304,13 @@ class GuiNovelToolBar(QWidget): 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.aLastCol[colType].setChecked(True) + self.novelView.novelTree.setLastColType(colType) + if doRefresh: + self._forceRefreshNovelTree() + self.novelView.novelTree.resizeColumns() return def setActive(self, state: bool) -> None: @@ -352,20 +347,20 @@ class GuiNovelToolBar(QWidget): @pyqtSlot() def _selectLastColumnSize(self) -> None: """Set the maximum width for the last column.""" - # oldSize = self.novelView.novelTree.lastColSize - # newSize, isOk = QInputDialog.getInt( - # self, self.tr("Column Size"), self.tr("Maximum column size in %"), oldSize, 15, 75, 5 - # ) - # if isOk: - # self.novelView.novelTree.setLastColSize(newSize) - # self._refreshNovelTree() + oldSize = self.novelView.novelTree.lastColSize + newSize, isOk = QInputDialog.getInt( + self, self.tr("Column Size"), self.tr("Maximum column size in %"), oldSize, 15, 75, 5 + ) + if isOk: + self.novelView.novelTree.setLastColSize(newSize) + 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) @@ -385,10 +380,10 @@ class GuiNovelTree(NTreeView): self.novelView = novelView # Internal Variables - self._actHandle = None + self._actHandle = None + self._lastColType = nwNovelExtra.POV + self._lastColSize = 0.25 # self._lastBuild = 0 - # self._lastCol = NovelTreeColumn.POV - # self._lastColSize = 0.25 # self._treeMap: dict[str, QTreeWidgetItem] = {} # Cached Strings @@ -432,6 +427,20 @@ class GuiNovelTree(NTreeView): self.setHorizontalScrollBarPolicy(QtScrollAsNeeded) return + ## + # Properties + ## + + @property + 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 ## @@ -456,18 +465,34 @@ class GuiNovelTree(NTreeView): 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 resizeColumsn(self) -> None: + def resizeColumns(self) -> None: """Set the correct column sizes.""" - if (header := self.header()) and (model := self._getModel()): + 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) - for i in range(1, model.columnCount(QModelIndex())): - header.setSectionResizeMode(i, QtHeaderToContents) + header.setSectionResizeMode(1, QtHeaderToContents) + if model.columns == 3: + header.setSectionResizeMode(2, QtHeaderToContents) + elif model.columns == 4: + header.setSectionResizeMode(2, QtHeaderFixed) + header.setSectionResizeMode(3, QtHeaderToContents) + header.resizeSection(2, int(self._lastColSize * vp.width())) return ## @@ -481,6 +506,16 @@ class GuiNovelTree(NTreeView): super().drawRow(painter, opt, index) return + ## + # Events + ## + + def resizeEvent(self, event: QResizeEvent) -> None: + """Process size changed.""" + super().resizeEvent(event) + self.resizeColumns() + return + ## # Private Slots ## @@ -566,18 +601,6 @@ class GuiNovelTree(NTreeView): # Old Code ## - ## - # Properties - ## - - # @property - # def lastColType(self) -> NovelTreeColumn: - # return self._lastCol - - # @property - # def lastColSize(self) -> int: - # return int(self._lastColSize * 100) - ## # Class Methods ## @@ -632,11 +655,6 @@ class GuiNovelTree(NTreeView): # 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 @@ -693,24 +711,6 @@ class GuiNovelTree(NTreeView): # self.clearSelection() # return - # def resizeEvent(self, event: QResizeEvent) -> None: - # """Elide labels in the extra column.""" - # 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) - # ) - # return - ## # Private Slots ## From 95d8e3ac98898449617840e1afb130b0589cd7d5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 23 Mar 2025 16:41:40 +0100 Subject: [PATCH 12/20] Add back extra column content --- novelwriter/core/index.py | 13 +- novelwriter/core/indexdata.py | 42 +++- novelwriter/core/novelmodel.py | 26 ++- novelwriter/gui/noveltree.py | 267 ++----------------------- tests/test_core/test_core_indexdata.py | 38 ++-- tests/test_core/test_core_options.py | 8 +- tests/test_gui/test_gui_noveltree.py | 20 +- 7 files changed, 113 insertions(+), 301 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 827d287a..3f75f841 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -87,7 +87,7 @@ class Index: # Storage and State self._tagsIndex = TagsIndex() - self._itemIndex = ItemIndex(project) + self._itemIndex = ItemIndex(project, self._tagsIndex) self._indexBroken = False # Models @@ -906,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 @@ -936,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]: @@ -994,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 @@ -1065,7 +1066,7 @@ class ItemIndex: nwItem = self._project.tree[tHandle] if nwItem is not None: - tItem = IndexNode(tHandle, nwItem) + tItem = IndexNode(self._tags, tHandle, nwItem) tItem.unpackData(tData) self._items[tHandle] = tItem diff --git a/novelwriter/core/indexdata.py b/novelwriter/core/indexdata.py index 1dc6043b..46b444f4 100644 --- a/novelwriter/core/indexdata.py +++ b/novelwriter/core/indexdata.py @@ -36,6 +36,7 @@ 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__) @@ -56,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 @@ -179,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": @@ -202,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 @@ -314,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: + refs[keyword].append(self._tags.tagName(tag)) + 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: + refs.append(self._tags.tagName(tag)) + return refs + ## # Data Methods ## diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py index e52b3bc2..5c99d7a5 100644 --- a/novelwriter/core/novelmodel.py +++ b/novelwriter/core/novelmodel.py @@ -30,7 +30,7 @@ from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt from PyQt6.QtGui import QIcon, QPixmap from novelwriter import SHARED -from novelwriter.constants import nwKeyWords, nwStyles +from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst from novelwriter.core.indexdata import IndexHeading, IndexNode from novelwriter.enum import nwNovelExtra from novelwriter.error import logException @@ -52,7 +52,7 @@ T_NodeData = str | tuple[str, str] | QIcon | QPixmap | Qt.AlignmentFlag | None class NovelModel(QAbstractTableModel): - __slots__ = ("_rows", "_header", "_more", "_columns", "_extra") + __slots__ = ("_rows", "_header", "_more", "_columns", "_extraKey", "_extraLabel") def __init__(self) -> None: super().__init__() @@ -60,7 +60,8 @@ class NovelModel(QAbstractTableModel): self._header: list[QSize] = [] self._more = SHARED.theme.getIcon("more_arrow") self._columns = 3 - self._extra = "" + self._extraKey = "" + self._extraLabel = "" return def __del__(self) -> None: # pragma: no cover @@ -85,16 +86,20 @@ class NovelModel(QAbstractTableModel): match extra: case nwNovelExtra.HIDDEN: self._columns = 3 - self._extra = "" + self._extraKey = "" + self._extraLabel = "" case nwNovelExtra.POV: self._columns = 4 - self._extra = nwKeyWords.POV_KEY + self._extraKey = nwKeyWords.POV_KEY + self._extraLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY]) case nwNovelExtra.FOCUS: self._columns = 4 - self._extra = nwKeyWords.FOCUS_KEY + self._extraKey = nwKeyWords.FOCUS_KEY + self._extraLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY]) case nwNovelExtra.PLOT: self._columns = 4 - self._extra = nwKeyWords.PLOT_KEY + self._extraKey = nwKeyWords.PLOT_KEY + self._extraLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY]) return ## @@ -209,6 +214,7 @@ class NovelModel(QAbstractTableModel): """Generate a cache entry.""" iLevel = nwStyles.H_LEVEL.get(heading.level, 0) data = {} + data[C_FACTOR*0 | R_TIP] = heading.title data[C_FACTOR*0 | R_TEXT] = heading.title data[C_FACTOR*0 | R_ICON] = SHARED.theme.getHeaderDecoration(iLevel) data[C_FACTOR*1 | R_TEXT] = f"{heading.mainCount:n}" @@ -216,8 +222,10 @@ class NovelModel(QAbstractTableModel): if self._columns == 3: data[C_FACTOR*2 | R_ICON] = self._more else: - data[C_FACTOR*2 | R_TIP] = "Hello World" - data[C_FACTOR*2 | R_TEXT] = "Hello World" + if self._extraKey and (refs := heading.getReferencesByKeyword(self._extraKey)): + text = ", ".join(refs) + data[C_FACTOR*2 | R_TEXT] = text + data[C_FACTOR*2 | R_TIP] = f"{self._extraLabel}: {text}" data[C_FACTOR*3 | R_ICON] = self._more data[R_HANDLE] = handle data[R_KEY] = key diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index c26d0fdd..6732fb24 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -383,14 +383,8 @@ class GuiNovelTree(NTreeView): self._actHandle = None self._lastColType = nwNovelExtra.POV self._lastColSize = 0.25 - # self._lastBuild = 0 - # self._treeMap: dict[str, QTreeWidgetItem] = {} - - # 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]) + # Widget Setup self.setIconSize(SHARED.theme.baseIconSize) self.setFrameStyle(QFrame.Shape.NoFrame) self.setUniformRowHeights(True) @@ -569,25 +563,26 @@ class GuiNovelTree(NTreeView): logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle) if synopsis := head.synopsis: label = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]) - synopsis = f"{label}: {synopsis}
" + synopsis = f"{label}: {synopsis}
" - def appendTags(refs: dict, key: str, lines: list[str]) -> list[str]: + def appendTags(refs: dict, key: str, lines: list[str]) -> None: """Generate a reference list for a given reference key.""" if tags := ", ".join(refs.get(key, [])): - lines.append(f"{trConst(nwLabels.KEY_NAME[key])}: {tags}") - return lines + lines.append(f"{trConst(nwLabels.KEY_NAME[key])}: {tags}") + return - tags = SHARED.project.index.getReferences(tHandle, sTitle) lines = [] - lines = appendTags(tags, nwKeyWords.POV_KEY, lines) - lines = appendTags(tags, nwKeyWords.FOCUS_KEY, lines) - lines = appendTags(tags, nwKeyWords.CHAR_KEY, lines) - lines = appendTags(tags, nwKeyWords.PLOT_KEY, lines) - lines = appendTags(tags, nwKeyWords.TIME_KEY, lines) - lines = appendTags(tags, nwKeyWords.WORLD_KEY, lines) - lines = appendTags(tags, nwKeyWords.OBJECT_KEY, lines) - lines = appendTags(tags, nwKeyWords.ENTITY_KEY, lines) - lines = appendTags(tags, nwKeyWords.CUSTOM_KEY, 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: @@ -596,233 +591,3 @@ class GuiNovelTree(NTreeView): if tooltip := (text + synopsis or self.tr("No meta data")): QToolTip.showText(qPos, tooltip) return - - ## - # Old Code - ## - - ## - # Class Methods - ## - - # def clearContent(self) -> None: - # """Clear the GUI content and the related maps.""" - # self.clear() - # self._treeMap = {} - # self._lastBuild = 0 - # 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) - - # 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 - - # 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 setActiveHandle(self, tHandle: str | None) -> None: - # """Highlight the rows associated with a given handle.""" - # didScroll = False - # brushOn = self.palette().alternateBase() - # brushOff = self.palette().base() - # if pHandle := self._actHandle: - # for key, item in self._treeMap.items(): - # if key.startswith(pHandle): - # for i in range(self.columnCount()): - # item.setBackground(i, brushOff) - # if tHandle: - # for key, item in self._treeMap.items(): - # if key.startswith(tHandle): - # for i in range(self.columnCount()): - # item.setBackground(i, brushOn) - # if not didScroll: - # self.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter) - # didScroll = True - # self._actHandle = tHandle or None - # return - - ## - # 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 - - ## - # 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) - # 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) - # 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) - # 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 "", "" diff --git a/tests/test_core/test_core_indexdata.py b/tests/test_core/test_core_indexdata.py index e8436825..5767a669 100644 --- a/tests/test_core/test_core_indexdata.py +++ b/tests/test_core/test_core_indexdata.py @@ -22,6 +22,7 @@ from __future__ import annotations import pytest +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 +34,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"{label}: {synopsis}
" - def appendTags(refs: dict, key: str, lines: list[str]) -> None: - """Generate a reference list for a given reference key.""" - if tags := ", ".join(refs.get(key, [])): - lines.append(f"{trConst(nwLabels.KEY_NAME[key])}: {tags}") - return - lines = [] if head := SHARED.project.index.getItemHeading(tHandle, sTitle): tags = head.getReferences() diff --git a/tests/test_gui/test_gui_noveltree.py b/tests/test_gui/test_gui_noveltree.py index 3c9bfcea..2da937bd 100644 --- a/tests/test_gui/test_gui_noveltree.py +++ b/tests/test_gui/test_gui_noveltree.py @@ -24,22 +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, nwNovelExtra -from novelwriter.gui.noveltree import GuiNovelTree -from novelwriter.types import QtMouseLeft, QtMouseMiddle +from novelwriter.enum import nwFocus, nwItemType, nwNovelExtra, nwView from tests.tools import C, buildTestProject @pytest.mark.gui -@pytest.mark.skip -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)) @@ -52,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 # ==================== @@ -84,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(nwNovelExtra.HIDDEN) - assert novelTree.isColumnHidden(novelTree.C_EXTRA) is True - assert novelTree.lastColType == nwNovelExtra.HIDDEN - assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == ("", "") - - novelBar.setLastColType(nwNovelExtra.PLOT) - assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False - assert novelTree.lastColType == nwNovelExtra.PLOT - assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == ( - "", "" - ) - - novelBar.setLastColType(nwNovelExtra.FOCUS) - assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False - assert novelTree.lastColType == nwNovelExtra.FOCUS - assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == ( - "Jane", "Focus: Jane" - ) - - novelBar.setLastColType(nwNovelExtra.POV) - assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False - assert novelTree.lastColType == nwNovelExtra.POV - assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == ( - "Jane", "Point of View: Jane" - ) - - novelTree._lastCol = None - assert novelTree._getLastColumnText("0000000000000", "T0000") == ("", "") - - # This forces the resizeEvent function to process labels - spSize = nwGUI.splitMain.sizes() - nwGUI.splitMain.setSizes([spSize[0] + 10, spSize[1] - 10]) - - # Resize the last column - with monkeypatch.context() as mp: - mp.setattr(QInputDialog, "getInt", lambda *a, **k: (40, True)) - novelBar._selectLastColumnSize() - # Item Meta # ========= - ttText = "" + toolTip = "" def showText(pos, text): - nonlocal ttText - ttText = text + nonlocal toolTip + toolTip = text - mIndex = novelTree.model().index(2, novelTree.C_MORE) with monkeypatch.context() as mp: mp.setattr(QToolTip, "showText", showText) - ttText = "" - novelTree._treeItemClicked(mIndex) - assert ttText == ( - "Point of View: Jane
Focus: Jane
Synopsis: This is a scene.
" + toolTip = "" + novelTree._onSingleClick(model.createIndex(2, 3)) + assert toolTip == ( + "Point of View: Jane
Focus: Jane
Synopsis: This is a scene.
" ) - ttText = "" + toolTip = "" novelTree._popMetaBox(QPoint(1, 1), C.hInvalid, "T0001") - assert ttText == "" + assert toolTip == "" - # Set Default Root - # ================ - SHARED.project.data.setLastHandle(C.hInvalid, "novel") - novelView.openProjectTasks() - assert novelBar.novelValue.handle == C.hNovelRoot + # Active Status + # ============= + assert novelBar._refresh == {C.hNovelRoot: False} - # Tree Focus - # ========== - with monkeypatch.context() as mp: - mp.setattr(GuiNovelTree, "hasFocus", lambda *a: False) - assert novelView.treeHasFocus() is False - mp.setattr(GuiNovelTree, "hasFocus", lambda *a: True) - assert novelView.treeHasFocus() is True + # Add a document while tree in focus + nwGUI._changeView(nwView.PROJECT) + assert novelBar._active is False + nwGUI.projView.projTree.setSelectedHandle(C.hChapterDir) + nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, hLevel=3) + assert novelBar._refresh == {C.hNovelRoot: True} - # Other Checks - # ============ - - scItem = novelTree.topLevelItem(2) - scItem.setSelected(True) - assert scItem.isSelected() - novelTree.focusOutEvent(QFocusEvent(QEvent.Type.None_, Qt.FocusReason.MouseFocusReason)) - assert not scItem.isSelected() + # Switch back and check that the refresh status is reset + nwGUI._changeView(nwView.NOVEL) + assert novelBar._refresh == {C.hNovelRoot: False} # Close - # ===== - # qtbot.stop() nwGUI.closeProject() diff --git a/tests/tools.py b/tests/tools.py index f1414761..7695ae72 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -216,6 +216,7 @@ 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() From d1aa576bfd47bb26977c683b56ef70fd76d49a3c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 30 Mar 2025 22:37:32 +0200 Subject: [PATCH 18/20] Remove unused nonlocals in tests --- tests/test_gui/test_gui_search.py | 1 - tests/test_tools/test_tools_manussettings.py | 2 +- tests/test_tools/test_tools_welcome.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_gui/test_gui_search.py b/tests/test_gui/test_gui_search.py index 843c1ae9..3110fa3e 100644 --- a/tests/test_gui/test_gui_search.py +++ b/tests/test_gui/test_gui_search.py @@ -39,7 +39,6 @@ def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum): search = nwGUI.projSearch def totalCount(): - nonlocal search res = search.searchResult return sum( int(res.topLevelItem(i).text(GuiProjectSearch.C_COUNT).strip("()")) diff --git a/tests/test_tools/test_tools_manussettings.py b/tests/test_tools/test_tools_manussettings.py index 13b57464..e5756a18 100644 --- a/tests/test_tools/test_tools_manussettings.py +++ b/tests/test_tools/test_tools_manussettings.py @@ -71,7 +71,7 @@ def testToolBuildSettings_Init(qtbot, nwGUI, projPath, mockRnd): @pyqtSlot(BuildSettings) def _testNewSettingsReady(new: BuildSettings): - nonlocal build, triggered + nonlocal triggered assert new is build triggered = True diff --git a/tests/test_tools/test_tools_welcome.py b/tests/test_tools/test_tools_welcome.py index 17047282..42157b2d 100644 --- a/tests/test_tools/test_tools_welcome.py +++ b/tests/test_tools/test_tools_welcome.py @@ -131,7 +131,6 @@ def testToolWelcome_Open(qtbot, monkeypatch, nwGUI, fncPath): # Context Menu def getMenuForPos(pos: QPoint) -> QMenu | None: - nonlocal tabOpen tabOpen._openContextMenu(pos) for obj in tabOpen.children(): if isinstance(obj, QMenu) and obj.objectName() == "ContextMenu": From bf4a923b08a212ef3fa056c6403d6f1612ece981 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 30 Mar 2025 22:45:52 +0200 Subject: [PATCH 19/20] Fix type error due to type change in novel selector --- novelwriter/tools/noveldetails.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/novelwriter/tools/noveldetails.py b/novelwriter/tools/noveldetails.py index 84692407..9f4bfb26 100644 --- a/novelwriter/tools/noveldetails.py +++ b/novelwriter/tools/noveldetails.py @@ -137,9 +137,10 @@ class GuiNovelDetails(NNonBlockingDialog): def updateValues(self) -> None: """Load the dialogs initial values.""" - self.overviewPage.updateProjectData() - self.overviewPage.novelValueChanged(self.novelSelector.handle) - self.contentsPage.novelValueChanged(self.novelSelector.handle) + if handle := self.novelSelector.handle: + self.overviewPage.updateProjectData() + self.overviewPage.novelValueChanged(handle) + self.contentsPage.novelValueChanged(handle) return ## From 8ef99c4cfa376c067e04c2f181383847ad3790c9 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 30 Mar 2025 22:59:51 +0200 Subject: [PATCH 20/20] Add test coverage of new modified widget --- tests/test_ext/test_ext_modified.py | 32 ++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/test_ext/test_ext_modified.py b/tests/test_ext/test_ext_modified.py index 161531da..844d47a6 100644 --- a/tests/test_ext/test_ext_modified.py +++ b/tests/test_ext/test_ext_modified.py @@ -23,13 +23,13 @@ from __future__ import annotations import pytest from PyQt6.QtCore import QEvent, QPoint, QPointF, Qt -from PyQt6.QtGui import QKeyEvent, QMouseEvent, QWheelEvent +from PyQt6.QtGui import QKeyEvent, QMouseEvent, QStandardItem, QStandardItemModel, QWheelEvent from PyQt6.QtWidgets import QWidget from novelwriter.extensions.modified import ( - NClickableLabel, NComboBox, NDialog, NDoubleSpinBox, NSpinBox + NClickableLabel, NComboBox, NDialog, NDoubleSpinBox, NSpinBox, NTreeView ) -from novelwriter.types import QtModNone, QtMouseLeft, QtRejected +from novelwriter.types import QtModNone, QtMouseLeft, QtMouseMiddle, QtRejected from tests.tools import SimpleDialog @@ -66,6 +66,32 @@ def testExtModified_NDialog(qtbot, monkeypatch): assert dialog.result() == QtRejected +@pytest.mark.gui +def testExtModified_NTreeView(qtbot, monkeypatch): + """Test the NTreeView class.""" + model = QStandardItemModel(1, 1) + model.insertRow(0, QStandardItem("Hello World")) + + widget = NTreeView() + widget.setModel(model) + dialog = SimpleDialog(widget) + dialog.show() + + vPort = widget.viewport() + item = model.item(0, 0) + assert vPort is not None + assert item is not None + + position = QPointF(widget.visualRect(model.createIndex(0, 0)).center()) + event = QMouseEvent( + QEvent.Type.MouseButtonPress, position, QtMouseMiddle, QtMouseMiddle, QtModNone + ) + with qtbot.waitSignal(widget.middleClicked): + widget.mousePressEvent(event) + + # qtbot.stop() + + @pytest.mark.gui def testExtModified_NComboBox(qtbot, monkeypatch): """Test the NComboBox class."""