From 23713bb79192cc9d330035d7411d005fbc9727a6 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 25 Jan 2024 21:25:43 +0100 Subject: [PATCH] Add table of contents code to novel details panel --- novelwriter/core/index.py | 21 +- novelwriter/core/options.py | 4 +- novelwriter/tools/noveldetails.py | 322 +++++++++++++++++++++++++++-- tests/test_core/test_core_index.py | 5 + 4 files changed, 326 insertions(+), 26 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index f58145f9..7f1598b6 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -528,17 +528,20 @@ class NWIndex: yield f"{tHandle}:{sTitle}", tHandle, sTitle, hItem return - def getNovelWordCount(self, activeOnly: bool = True) -> int: - """Count the number of words in the novel project.""" - wCount = 0 - for _, _, hItem in self._itemIndex.iterNovelStructure(activeOnly=activeOnly): - wCount += hItem.wordCount - return wCount + def getNovelWordCount(self, rootHandle: str | None = None, activeOnly: bool = True) -> int: + """Count the number of words in one or all novel roots.""" + return sum(hItem.wordCount for _, _, hItem in self._itemIndex.iterNovelStructure( + rHandle=rootHandle, activeOnly=activeOnly + )) - def getNovelTitleCounts(self, activeOnly: bool = True) -> list[int]: - """Count the number of titles in the novel project.""" + def getNovelTitleCounts( + self, rootHandle: str | None = None, activeOnly: bool = True + ) -> list[int]: + """Count the number of titles in one or all novel roots.""" hCount = [0, 0, 0, 0, 0] - for _, _, hItem in self._itemIndex.iterNovelStructure(activeOnly=activeOnly): + for _, _, hItem in self._itemIndex.iterNovelStructure( + rHandle=rootHandle, activeOnly=activeOnly + ): iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0) hCount[iLevel] += 1 return hCount diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index 93ec4d8c..e2e71296 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -73,7 +73,9 @@ VALID_MAP: dict[str, set[str]] = { "colWidths", "hideInactive", }, "GuiNovelDetails": { - "winWidth", "winHeight", + "winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2", + "widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble", + "novelRoot", }, } diff --git a/novelwriter/tools/noveldetails.py b/novelwriter/tools/noveldetails.py index fdced6b9..9139a655 100644 --- a/novelwriter/tools/noveldetails.py +++ b/novelwriter/tools/noveldetails.py @@ -23,23 +23,29 @@ along with this program. If not, see . """ from __future__ import annotations +import math import logging -from PyQt5.QtCore import pyqtSlot from PyQt5.QtGui import QCloseEvent +from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( - QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel, QStackedWidget, QVBoxLayout, - QWidget + QAbstractItemView, QDialog, QDialogButtonBox, QFormLayout, QGridLayout, + QHBoxLayout, QLabel, QSpinBox, QStackedWidget, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter import CONFIG, SHARED -from novelwriter.common import formatTime +from novelwriter.common import formatTime, numberToRoman +from novelwriter.constants import nwUnicode +from novelwriter.extensions.switch import NSwitch from novelwriter.extensions.configlayout import NColourLabel -from novelwriter.extensions.novelselector import NovelSelector from novelwriter.extensions.pagedsidebar import NPagedSideBar +from novelwriter.extensions.novelselector import NovelSelector logger = logging.getLogger(__name__) +HEADER_SIZE = 1.4 + class GuiNovelDetails(QDialog): @@ -72,6 +78,7 @@ class GuiNovelDetails(QDialog): # Novel Selector self.novelSelector = NovelSelector(self) self.novelSelector.refreshNovelList() + self.novelSelector.setHandle(options.getString("GuiNovelDetails", "novelRoot", "")) # SideBar self.sidebar = NPagedSideBar(self) @@ -83,9 +90,11 @@ class GuiNovelDetails(QDialog): # Content self.overviewPage = _OverviewPage(self) + self.contentsPage = _ContentsPage(self) self.mainStack = QStackedWidget(self) self.mainStack.addWidget(self.overviewPage) + self.mainStack.addWidget(self.contentsPage) # Buttons self.buttonBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) @@ -111,6 +120,10 @@ class GuiNovelDetails(QDialog): self.setLayout(self.outerBox) self.setSizeGripEnabled(True) + # Connect Signals + self.novelSelector.novelSelectionChanged.connect(self.overviewPage.novelValueChanged) + self.novelSelector.novelSelectionChanged.connect(self.contentsPage.novelValueChanged) + logger.debug("Ready: GuiNovelDetails") return @@ -126,6 +139,8 @@ class GuiNovelDetails(QDialog): def updateValues(self) -> None: """Load the dialogs initial values.""" self.overviewPage.updateProjectData() + self.overviewPage.novelValueChanged(self.novelSelector.handle) + self.contentsPage.novelValueChanged(self.novelSelector.handle) return ## @@ -148,6 +163,8 @@ class GuiNovelDetails(QDialog): """Process a user request to switch page.""" if pageId == self.PAGE_OVERVIEW: self.mainStack.setCurrentWidget(self.overviewPage) + elif pageId == self.PAGE_CONTENTS: + self.mainStack.setCurrentWidget(self.contentsPage) return ## @@ -158,11 +175,16 @@ class GuiNovelDetails(QDialog): """Save the user GUI settings.""" winWidth = CONFIG.rpxInt(self.width()) winHeight = CONFIG.rpxInt(self.height()) + novelRoot = self.novelSelector.handle logger.debug("Saving State: GuiNovelDetails") options = SHARED.project.options options.setValue("GuiNovelDetails", "winWidth", winWidth) options.setValue("GuiNovelDetails", "winHeight", winHeight) + options.setValue("GuiNovelDetails", "novelRoot", novelRoot) + + self.contentsPage.saveSettings() + return # END Class GuiNovelDetails @@ -179,24 +201,32 @@ class _OverviewPage(QWidget): vPx = CONFIG.pxInt(4) # Project Info - self.projLabel = NColourLabel(self.tr("Project"), SHARED.theme.helpText, self, 1.5) + self.projLabel = NColourLabel( + self.tr("Project"), SHARED.theme.helpText, parent=self, scale=HEADER_SIZE + ) self.projName = QLabel("", self) self.projWords = QLabel("", self) + self.projNovels = QLabel("", self) + self.projNotes = QLabel("", self) self.projRevisions = QLabel("", self) self.projEditTime = QLabel("", self) self.projForm = QFormLayout() - self.projForm.addRow("%s" % self.tr("Name"), self.projName) - self.projForm.addRow("%s" % self.tr("Word Count"), self.projWords) - self.projForm.addRow("%s" % self.tr("Revisions"), self.projRevisions) - self.projForm.addRow("%s" % self.tr("Editing Time"), self.projEditTime) + self.projForm.addRow("{0}".format(self.tr("Name")), self.projName) + self.projForm.addRow("{0}".format(self.tr("Revisions")), self.projRevisions) + self.projForm.addRow("{0}".format(self.tr("Editing Time")), self.projEditTime) + self.projForm.addRow("{0}".format(self.tr("Word Count")), self.projWords) + self.projForm.addRow("\u2026 {0}".format(self.tr("In Novels")), self.projNovels) + self.projForm.addRow("\u2026 {0}".format(self.tr("In Notes ")), self.projNotes) self.projForm.setContentsMargins(mPx, 0, 0, 0) self.projForm.setHorizontalSpacing(hPx) self.projForm.setVerticalSpacing(vPx) # Novel Info - self.novelLabel = NColourLabel(self.tr("Novel"), SHARED.theme.helpText, self, 1.5) + self.novelLabel = NColourLabel( + self.tr("Selected Novel"), SHARED.theme.helpText, parent=self, scale=HEADER_SIZE + ) self.novelName = QLabel("", self) self.novelWords = QLabel("", self) @@ -204,10 +234,10 @@ class _OverviewPage(QWidget): self.novelScenes = QLabel("", self) self.novelForm = QFormLayout() - self.novelForm.addRow("%s" % self.tr("Name"), self.novelName) - self.novelForm.addRow("%s" % self.tr("Word Count"), self.novelWords) - self.novelForm.addRow("%s" % self.tr("Chapters"), self.novelChapters) - self.novelForm.addRow("%s" % self.tr("Scenes"), self.novelScenes) + self.novelForm.addRow("{0}".format(self.tr("Name")), self.novelName) + self.novelForm.addRow("{0}".format(self.tr("Word Count")), self.novelWords) + self.novelForm.addRow("{0}".format(self.tr("Chapters")), self.novelChapters) + self.novelForm.addRow("{0}".format(self.tr("Scenes")), self.novelScenes) self.novelForm.setContentsMargins(mPx, 0, 0, 0) self.novelForm.setHorizontalSpacing(hPx) self.novelForm.setVerticalSpacing(vPx) @@ -233,10 +263,270 @@ class _OverviewPage(QWidget): def updateProjectData(self) -> None: """Load information about the project.""" project = SHARED.project + project.updateWordCounts() + wcNovel, wcNotes = project.data.currCounts + self.projName.setText(project.data.name) - self.projWords.setText(f"{project.index.getNovelWordCount():n}") self.projRevisions.setText(f"{project.data.saveCount:n}") self.projEditTime.setText(formatTime(project.currentEditTime)) + self.projWords.setText(f"{wcNovel + wcNotes:n}") + self.projNovels.setText(f"{wcNovel:n}") + self.projNotes.setText(f"{wcNotes:n}") + return + + ## + # Public Slots + ## + + @pyqtSlot(str) + def novelValueChanged(self, tHandle: str) -> None: + """Refresh the data for the selected novel.""" + project = SHARED.project + if nwItem := project.tree[tHandle]: + self.novelName.setText(nwItem.itemName) + + nwCount = project.index.getNovelWordCount(rootHandle=tHandle) + self.novelWords.setText(f"{nwCount:n}") + + hCounts = project.index.getNovelTitleCounts(rootHandle=tHandle) + self.novelChapters.setText(f"{hCounts[2]:n}") + self.novelScenes.setText(f"{hCounts[3]:n}") + return # END Class _OverviewPage + + +class _ContentsPage(QWidget): + + C_TITLE = 0 + C_WORDS = 1 + C_PAGES = 2 + C_PAGE = 3 + C_PROG = 4 + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + self._data = [] + self._currentRoot = None + + iPx = SHARED.theme.baseIconSize + hPx = CONFIG.pxInt(12) + vPx = CONFIG.pxInt(4) + options = SHARED.project.options + + # Title + self.contentLabel = NColourLabel( + self.tr("Table of Contents"), SHARED.theme.helpText, parent=self, scale=HEADER_SIZE + ) + + # Contents Tree + self.tocTree = QTreeWidget(self) + self.tocTree.setIconSize(QSize(iPx, iPx)) + self.tocTree.setIndentation(0) + self.tocTree.setColumnCount(6) + self.tocTree.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.tocTree.setHeaderLabels([ + self.tr("Title"), + self.tr("Words"), + self.tr("Pages"), + self.tr("Page"), + self.tr("Progress"), + "", + ]) + + treeHeadItem = self.tocTree.headerItem() + if treeHeadItem: + treeHeadItem.setTextAlignment(self.C_WORDS, Qt.AlignmentFlag.AlignRight) + treeHeadItem.setTextAlignment(self.C_PAGES, Qt.AlignmentFlag.AlignRight) + treeHeadItem.setTextAlignment(self.C_PAGE, Qt.AlignmentFlag.AlignRight) + treeHeadItem.setTextAlignment(self.C_PROG, Qt.AlignmentFlag.AlignRight) + + treeHeader = self.tocTree.header() + treeHeader.setStretchLastSection(True) + treeHeader.setMinimumSectionSize(hPx) + + wCol0 = CONFIG.pxInt(options.getInt("GuiNovelDetails", "widthCol0", 200)) + wCol1 = CONFIG.pxInt(options.getInt("GuiNovelDetails", "widthCol1", 60)) + wCol2 = CONFIG.pxInt(options.getInt("GuiNovelDetails", "widthCol2", 60)) + wCol3 = CONFIG.pxInt(options.getInt("GuiNovelDetails", "widthCol3", 60)) + wCol4 = CONFIG.pxInt(options.getInt("GuiNovelDetails", "widthCol4", 90)) + + self.tocTree.setColumnWidth(0, wCol0) + self.tocTree.setColumnWidth(1, wCol1) + self.tocTree.setColumnWidth(2, wCol2) + self.tocTree.setColumnWidth(3, wCol3) + self.tocTree.setColumnWidth(4, wCol4) + self.tocTree.setColumnWidth(5, hPx) + + # Options + wordsPerPage = options.getInt("GuiNovelDetails", "wordsPerPage", 350) + countFrom = options.getInt("GuiNovelDetails", "countFrom", 1) + clearDouble = options.getBool("GuiNovelDetails", "clearDouble", True) + + self.wpLabel = QLabel(self.tr("Words per page")) + + self.wpValue = QSpinBox(self) + self.wpValue.setMinimum(10) + self.wpValue.setMaximum(1000) + self.wpValue.setSingleStep(10) + self.wpValue.setValue(wordsPerPage) + self.wpValue.valueChanged.connect(self._populateTree) + + self.poLabel = QLabel(self.tr("First page offset")) + + self.poValue = QSpinBox(self) + self.poValue.setMinimum(1) + self.poValue.setMaximum(9999) + self.poValue.setSingleStep(1) + self.poValue.setValue(countFrom) + self.poValue.valueChanged.connect(self._populateTree) + + self.dblLabel = QLabel(self.tr("Chapters on odd pages")) + + self.dblValue = NSwitch(self, 2*iPx, iPx) + self.dblValue.setChecked(clearDouble) + self.dblValue.clicked.connect(self._populateTree) + + self.optionsBox = QGridLayout() + self.optionsBox.addWidget(self.wpLabel, 0, 0) + self.optionsBox.addWidget(self.wpValue, 0, 1) + self.optionsBox.addWidget(self.dblLabel, 0, 3) + self.optionsBox.addWidget(self.dblValue, 0, 4) + self.optionsBox.addWidget(self.poLabel, 1, 0) + self.optionsBox.addWidget(self.poValue, 1, 1) + self.optionsBox.setHorizontalSpacing(hPx) + self.optionsBox.setVerticalSpacing(vPx) + self.optionsBox.setColumnStretch(2, 1) + + # Assemble + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.contentLabel) + self.outerBox.addWidget(self.tocTree) + self.outerBox.addLayout(self.optionsBox) + self.outerBox.setContentsMargins(0, 0, 0, 0) + + self.setLayout(self.outerBox) + + return + + def saveSettings(self) -> None: + """Save the user GUI settings.""" + options = SHARED.project.options + options.setValue("GuiNovelDetails", "widthCol0", self.tocTree.columnWidth(0)) + options.setValue("GuiNovelDetails", "widthCol1", self.tocTree.columnWidth(1)) + options.setValue("GuiNovelDetails", "widthCol2", self.tocTree.columnWidth(2)) + options.setValue("GuiNovelDetails", "widthCol3", self.tocTree.columnWidth(3)) + options.setValue("GuiNovelDetails", "widthCol4", self.tocTree.columnWidth(4)) + options.setValue("GuiNovelDetails", "wordsPerPage", self.wpValue.value()) + options.setValue("GuiNovelDetails", "countFrom", self.poValue.value()) + options.setValue("GuiNovelDetails", "clearDouble", self.dblValue.isChecked()) + return + + def getColumnSizes(self) -> list[int]: + """Return the column widths for the tree columns.""" + retVals = [ + self.tocTree.columnWidth(0), + self.tocTree.columnWidth(1), + self.tocTree.columnWidth(2), + self.tocTree.columnWidth(3), + self.tocTree.columnWidth(4), + ] + return retVals + + ## + # Public Slots + ## + + @pyqtSlot(str) + def novelValueChanged(self, tHandle: str) -> None: + """Refresh the tree with another root item.""" + if tHandle != self._currentRoot: + self._prepareData(tHandle) + self._populateTree() + self._currentRoot = tHandle + return + + ## + # Private Slots + ## + + @pyqtSlot() + def _populateTree(self) -> None: + """Set the content of the chapter/page tree.""" + dblPages = self.dblValue.isChecked() + wpPage = self.wpValue.value() + fstPage = self.poValue.value() - 1 + + pTotal = 0 + tPages = 1 + + theList = [] + for _, tLevel, tTitle, wCount in self._data: + pCount = math.ceil(wCount/wpPage) + if dblPages: + pCount += pCount%2 + + pTotal += pCount + theList.append((tLevel, tTitle, wCount, pCount)) + + pMax = pTotal - fstPage + + self.tocTree.clear() + for tLevel, tTitle, wCount, pCount in theList: + newItem = QTreeWidgetItem() + + if tPages <= fstPage: + progPage = numberToRoman(tPages, True) + progText = "" + else: + cPage = tPages - fstPage + pgProg = 100.0*(cPage - 1)/pMax if pMax > 0 else 0.0 + progPage = f"{cPage:n}" + progText = f"{pgProg:.1f}{nwUnicode.U_THSP}%" + + hDec = SHARED.theme.getHeaderDecoration(tLevel) + if tTitle.strip() == "": + tTitle = self.tr("Untitled") + + newItem.setData(self.C_TITLE, Qt.DecorationRole, hDec) + newItem.setText(self.C_TITLE, tTitle) + newItem.setText(self.C_WORDS, f"{wCount:n}") + newItem.setText(self.C_PAGES, f"{pCount:n}") + newItem.setText(self.C_PAGE, progPage) + newItem.setText(self.C_PROG, progText) + + newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) + newItem.setTextAlignment(self.C_PAGES, Qt.AlignRight) + newItem.setTextAlignment(self.C_PAGE, Qt.AlignRight) + newItem.setTextAlignment(self.C_PROG, Qt.AlignRight) + + # Make pages and titles/partitions stand out + if tLevel < 2: + bFont = newItem.font(self.C_TITLE) + if tLevel == 0: + bFont.setItalic(True) + else: + bFont.setBold(True) + bFont.setUnderline(True) + newItem.setFont(self.C_TITLE, bFont) + + tPages += pCount + + self.tocTree.addTopLevelItem(newItem) + + return + + ## + # Internal Functions + ## + + def _prepareData(self, rootHandle: str | None) -> None: + """Extract the information from the project index.""" + logger.debug("Populating ToC from handle '%s'", rootHandle) + self._data = SHARED.project.index.getTableOfContents(rootHandle, 2) + self._data.append(("", 0, self.tr("END"), 0)) + return + +# END Class _ContentsPage diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 5e9dee3f..44836349 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -695,6 +695,7 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): "This is a story about Jane Smith.\n\n" "Well, not really. She's still awesome though.\n" )) + # Whole document cC, wC, pC = index.getCounts(nHandle) assert cC == 152 @@ -798,6 +799,10 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): # Extract stats assert index.getNovelWordCount(activeOnly=False) == 43 assert index.getNovelWordCount(activeOnly=True) == 15 + assert index.getNovelWordCount(rootHandle=C.hNovelRoot, activeOnly=False) == 43 + assert index.getNovelWordCount(rootHandle=C.hNovelRoot, activeOnly=True) == 15 + assert index.getNovelWordCount(rootHandle=C.hWorldRoot, activeOnly=False) == 0 + assert index.getNovelWordCount(rootHandle=C.hWorldRoot, activeOnly=True) == 0 assert index.getNovelTitleCounts(activeOnly=False) == [0, 3, 2, 3, 0] assert index.getNovelTitleCounts(activeOnly=True) == [0, 1, 2, 3, 0]