diff --git a/docs/source/int_interface.rst b/docs/source/int_interface.rst index dcbf5fd9..d8f91047 100644 --- a/docs/source/int_interface.rst +++ b/docs/source/int_interface.rst @@ -406,6 +406,7 @@ Most features are available as keyboard shortcuts. These are as follows: ":kbd:`F11`", "Activate full screen mode." ":kbd:`Shift`:kbd:`F1`", "Open the online documentation in the system default browser." ":kbd:`Shift`:kbd:`F3`", "Find previous occurrence of search word in current document. (Same as :kbd:`Ctrl`:kbd:`Shift`:kbd:`G`.)" + ":kbd:`Shift`:kbd:`F6`", "Open the :guilabel:`Project Details` dialog." ":kbd:`Return`", "If in the project tree, open a document for editing." .. note:: diff --git a/docs/source/write_projects.rst b/docs/source/write_projects.rst index dbc19691..e00dd234 100644 --- a/docs/source/write_projects.rst +++ b/docs/source/write_projects.rst @@ -15,7 +15,9 @@ A list of recently opened projects is maintained, and displayed in the :guilabel dialog. A project can be removed from this list by selecting it and pressing the :kbd:`Del` key. Project-specific settings are available in :guilabel:`Project Settings` in the :guilabel:`Project` -menu. See further details below in the :ref:`a_proj_settings` section. +menu. See further details below in the :ref:`a_proj_settings` section. Details about the project, +including word counts, and a table of contents with word and page counts, is available through the +:guilabel:`Project Details` dialog. .. _a_proj_roots: @@ -249,14 +251,6 @@ override the default spell checking language here. You can also override the aut setting. -Details Tab ------------ - -This tab presents an overview of technical meta data for the project. It states where on your file -system the project is saved, how may times it has been saved, how many folders and documents it -contains, and how many words exist in the entire project. - - Status and Importance Tabs -------------------------- diff --git a/nw/assets/icons/fallback/settings-dark.svg b/nw/assets/icons/fallback/settings-dark.svg new file mode 100644 index 00000000..e2014bd8 --- /dev/null +++ b/nw/assets/icons/fallback/settings-dark.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/nw/assets/icons/fallback/settings.svg b/nw/assets/icons/fallback/settings.svg new file mode 100644 index 00000000..edc89a8f --- /dev/null +++ b/nw/assets/icons/fallback/settings.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/nw/assets/icons/typicons_colour_dark/heading0.svg b/nw/assets/icons/typicons_colour_dark/heading0.svg new file mode 100644 index 00000000..65ca642c --- /dev/null +++ b/nw/assets/icons/typicons_colour_dark/heading0.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/nw/assets/icons/typicons_colour_dark/icons.conf b/nw/assets/icons/typicons_colour_dark/icons.conf index d4671820..5d4c0f2e 100644 --- a/nw/assets/icons/typicons_colour_dark/icons.conf +++ b/nw/assets/icons/typicons_colour_dark/icons.conf @@ -29,6 +29,7 @@ cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg +doc_h0 = heading0.svg doc_h1 = heading1.svg doc_h2 = heading2.svg doc_h3 = heading3.svg diff --git a/nw/assets/icons/typicons_colour_light/heading0.svg b/nw/assets/icons/typicons_colour_light/heading0.svg new file mode 100644 index 00000000..87155b79 --- /dev/null +++ b/nw/assets/icons/typicons_colour_light/heading0.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/nw/assets/icons/typicons_colour_light/icons.conf b/nw/assets/icons/typicons_colour_light/icons.conf index 4f9ffb6c..775aa4d5 100644 --- a/nw/assets/icons/typicons_colour_light/icons.conf +++ b/nw/assets/icons/typicons_colour_light/icons.conf @@ -29,6 +29,7 @@ cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg +doc_h0 = heading0.svg doc_h1 = heading1.svg doc_h2 = heading2.svg doc_h3 = heading3.svg diff --git a/nw/assets/icons/typicons_grey_dark/heading0.svg b/nw/assets/icons/typicons_grey_dark/heading0.svg new file mode 100644 index 00000000..4cf4dcc3 --- /dev/null +++ b/nw/assets/icons/typicons_grey_dark/heading0.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/nw/assets/icons/typicons_grey_dark/icons.conf b/nw/assets/icons/typicons_grey_dark/icons.conf index 7dae46e9..7dc56936 100644 --- a/nw/assets/icons/typicons_grey_dark/icons.conf +++ b/nw/assets/icons/typicons_grey_dark/icons.conf @@ -29,6 +29,7 @@ cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg +doc_h0 = heading0.svg doc_h1 = heading1.svg doc_h2 = heading2.svg doc_h3 = heading3.svg diff --git a/nw/assets/icons/typicons_grey_light/heading0.svg b/nw/assets/icons/typicons_grey_light/heading0.svg new file mode 100644 index 00000000..b6377afe --- /dev/null +++ b/nw/assets/icons/typicons_grey_light/heading0.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/nw/assets/icons/typicons_grey_light/icons.conf b/nw/assets/icons/typicons_grey_light/icons.conf index f21000cb..7c47ba7d 100644 --- a/nw/assets/icons/typicons_grey_light/icons.conf +++ b/nw/assets/icons/typicons_grey_light/icons.conf @@ -29,6 +29,7 @@ cls_archive = delete.svg cls_trash = trash.svg proj_document = document-text.svg proj_folder = folder.svg +doc_h0 = heading0.svg doc_h1 = heading1.svg doc_h2 = heading2.svg doc_h3 = heading3.svg diff --git a/nw/common.py b/nw/common.py index f0e5900e..922c7b6f 100644 --- a/nw/common.py +++ b/nw/common.py @@ -152,11 +152,11 @@ def formatInt(theInt): theVal /= 1000.0 if theVal < 1000.0: if theVal < 10.0: - return f"{theVal:4.2f}{nwUnicode.U_THNSP}{pF}" + return f"{theVal:4.2f}{nwUnicode.U_THSP}{pF}" elif theVal < 100.0: - return f"{theVal:4.1f}{nwUnicode.U_THNSP}{pF}" + return f"{theVal:4.1f}{nwUnicode.U_THSP}{pF}" else: - return f"{theVal:3.0f}{nwUnicode.U_THNSP}{pF}" + return f"{theVal:3.0f}{nwUnicode.U_THSP}{pF}" return str(theInt) diff --git a/nw/constants/constants.py b/nw/constants/constants.py index a39bb8e1..8efbec6f 100644 --- a/nw/constants/constants.py +++ b/nw/constants/constants.py @@ -241,7 +241,7 @@ class nwQuotes(): # END Class nwQuotes class nwUnicode: - """Supported unicode character constants and translation maps for HTML. + """Supported unicode character constants and their HTML equivalents. """ # Unicode Constants @@ -276,8 +276,10 @@ class nwUnicode: ## Spaces and Lines U_NBSP = "\u00a0" # Non-breaking space - U_THNSP = "\u2009" # Thin space + U_THSP = "\u2009" # Thin space U_THNBSP = "\u202f" # Thin non-breaking space + U_ENSP = "\u2002" # Short (en) space + U_EMSP = "\u2003" # Long (em) space U_LSEP = "\u2028" # Line separator U_PSEP = "\u2029" # Paragraph separator @@ -328,8 +330,10 @@ class nwUnicode: ## Spaces H_NBSP = " " - H_THNSP = " " + H_THSP = " " H_THNBSP = " " + H_ENSP = " " + H_EMSP = " " ## Symbols H_CHECK = "✔" diff --git a/nw/core/index.py b/nw/core/index.py index 2d63040e..2b14a5d4 100644 --- a/nw/core/index.py +++ b/nw/core/index.py @@ -42,6 +42,8 @@ logger = logging.getLogger(__name__) class NWIndex(): + H_LEVEL = {"H0": 0, "H1": 1, "H2": 2, "H3": 3, "H4": 4} + def __init__(self, theProject, theParent): # Internal @@ -343,6 +345,11 @@ class NWIndex(): lastText = "\n".join(theLines[nTitle-1:]) self._indexWordCounts(tHandle, isNovel, lastText, nTitle) + # Index page with no titles and references + if nTitle == 0: + self._indexPage(tHandle, isNovel, itemLayout) + self._indexWordCounts(tHandle, isNovel, theText, nTitle) + # Update timestamps for index changes nowTime = round(time()) self._timeIndex = nowTime @@ -402,6 +409,29 @@ class NWIndex(): return True + def _indexPage(self, tHandle, isNovel, itemLayout): + """Index a page with no title. + """ + theData = { + "level" : "H0", + "title" : "Untitled Page", + "layout" : itemLayout.name, + "synopsis" : "", + "cCount" : 0, + "wCount" : 0, + "pCount" : 0, + "updated" : round(time()), + } + + if isNovel: + if tHandle in self._novelIndex: + self._novelIndex[tHandle]["T000000"] = theData + else: + if tHandle in self._noteIndex: + self._noteIndex[tHandle]["T000000"] = theData + + return + def _indexWordCounts(self, tHandle, isNovel, theText, nTitle): """Count text stats and save the counts to the index. """ @@ -552,16 +582,64 @@ class NWIndex(): they appear in the tree view and in the respective document files, but skipping all note files. """ - for tItem in self.theProject.projTree: - if tItem is not None: - if not tItem.isExported and skipExcluded: - continue - tHandle = tItem.itemHandle - if tHandle not in self._novelIndex: - continue - for sTitle in sorted(self._novelIndex[tHandle]): - tKey = "%s:%s" % (tHandle, sTitle) - yield tKey, tHandle, sTitle, self._novelIndex[tHandle][sTitle] + for tHandle in self._listNovelHandles(skipExcluded): + for sTitle in sorted(self._novelIndex[tHandle]): + tKey = "%s:%s" % (tHandle, sTitle) + yield tKey, tHandle, sTitle, self._novelIndex[tHandle][sTitle] + + def getNovelWordCount(self, skipExcluded=True): + """Count the number of words in the novel project. + """ + wCount = 0 + for tHandle in self._listNovelHandles(skipExcluded): + for sTitle in self._novelIndex[tHandle]: + wCount += self._novelIndex[tHandle][sTitle]["wCount"] + + return wCount + + def getNovelTitleCounts(self, skipExcluded=True): + """Count the number of titles in the novel project. + """ + hCount = [0, 0, 0, 0, 0] + for tHandle in self._listNovelHandles(skipExcluded): + for sTitle in self._novelIndex[tHandle]: + theData = self._novelIndex[tHandle][sTitle] + iLevel = self.H_LEVEL.get(theData["level"], 0) + hCount[iLevel] += 1 + + return hCount + + def getTableOfContents(self, maxDepth, skipExcluded=True): + """Generate a table of contents up to a maxiumum depth. + """ + tOrder = [] + tData = {} + pKey = None + for tHandle in self._listNovelHandles(skipExcluded): + for sTitle in sorted(self._novelIndex[tHandle]): + tKey = "%s:%s" % (tHandle, sTitle) + theData = self._novelIndex[tHandle][sTitle] + iLevel = self.H_LEVEL.get(theData["level"], 0) + if iLevel > maxDepth: + if pKey in tData: + theData["wCount"] + tData[pKey]["words"] += theData["wCount"] + else: + pKey = tKey + tOrder.append(tKey) + tData[tKey] = { + "level": theData["level"], + "title": theData["title"], + "words": theData["wCount"], + } + + theToC = [] + for tKey in tOrder: + theToC.append(( + tKey, tData[tKey]["level"], tData[tKey]["title"], tData[tKey]["words"] + )) + + return theToC def getCounts(self, tHandle, sTitle=None): """Returns the counts for a file, or a section of a file @@ -647,4 +725,22 @@ class NWIndex(): return theRef[1], theRef[0], theRef[3] return None, 0, "T000000" + ## + # Internal Functions + ## + + def _listNovelHandles(self, skipExcluded): + """Return a list of all handles that exist in the novel index. + """ + theHandles = [] + for tItem in self.theProject.projTree: + if tItem is None: + continue + if not tItem.isExported and skipExcluded: + continue + if tItem.itemHandle in self._novelIndex: + theHandles.append(tItem.itemHandle) + + return theHandles + # END Class NWIndex diff --git a/nw/core/options.py b/nw/core/options.py index db94ae45..e409812d 100644 --- a/nw/core/options.py +++ b/nw/core/options.py @@ -87,7 +87,18 @@ class OptionState(): "winWidth", "winHeight", "replaceColW", - } + }, + "GuiProjectDetails": { + "winWidth", + "winHeight", + "widthCol0", + "widthCol1", + "widthCol2", + "widthCol3", + "widthCol4", + "wordsPerPage", + "clearDouble", + }, } return diff --git a/nw/core/project.py b/nw/core/project.py index ac7bf9ef..8cd9b2ec 100644 --- a/nw/core/project.py +++ b/nw/core/project.py @@ -1112,11 +1112,32 @@ class NWProject(): # Getters ## + def getAuthors(self): + """Returns a formatted string of authors. + """ + nAuth = len(self.bookAuthors) + authString = "" + + if nAuth == 1: + authString = self.bookAuthors[0] + elif nAuth > 1: + authString = "%s and %s" % ( + ", ".join(self.bookAuthors[0:-1]), self.bookAuthors[-1] + ) + + return authString + def getSessionWordCount(self): """Returns the number of words added or removed this session. """ return self.currWCount - self.lastWCount + def getCurrentEditTime(self): + """Get the total project edit time, including the time spent in + the current session. + """ + return round(self.editTime + time() - self.projOpened) + def getProjectItems(self): """This function ensures that the item tree loaded is sent to the GUI tree view in such a way that the tree can be built. That diff --git a/nw/core/tohtml.py b/nw/core/tohtml.py index 55da29d1..41807586 100644 --- a/nw/core/tohtml.py +++ b/nw/core/tohtml.py @@ -53,7 +53,7 @@ class ToHtml(Tokenizer): nwUnicode.U_EMDASH : nwUnicode.H_EMDASH, nwUnicode.U_HELLIP : nwUnicode.H_HELLIP, nwUnicode.U_NBSP : nwUnicode.H_NBSP, - nwUnicode.U_THNSP : nwUnicode.H_THNSP, + nwUnicode.U_THSP : nwUnicode.H_THSP, nwUnicode.U_THNBSP : nwUnicode.H_THNBSP, nwUnicode.U_MAPOSS : nwUnicode.H_RSQUO, } diff --git a/nw/gui/__init__.py b/nw/gui/__init__.py index 6b71867d..aa7455b5 100644 --- a/nw/gui/__init__.py +++ b/nw/gui/__init__.py @@ -13,6 +13,7 @@ from nw.gui.noveltree import GuiNovelTree from nw.gui.outline import GuiOutline from nw.gui.outlinedetails import GuiOutlineDetails from nw.gui.preferences import GuiPreferences +from nw.gui.projdetails import GuiProjectDetails from nw.gui.projload import GuiProjectLoad from nw.gui.projsettings import GuiProjectSettings from nw.gui.projtree import GuiProjectTree @@ -37,6 +38,7 @@ __all__ = [ "GuiOutline", "GuiOutlineDetails", "GuiPreferences", + "GuiProjectDetails", "GuiProjectLoad", "GuiProjectSettings", "GuiProjectTree", diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py index 3421276c..e155c9fd 100644 --- a/nw/gui/doceditor.py +++ b/nw/gui/doceditor.py @@ -734,7 +734,7 @@ class GuiDocEditor(QTextEdit): elif theInsert == nwDocInsert.NB_SPACE: theText = nwUnicode.U_NBSP elif theInsert == nwDocInsert.THIN_SPACE: - theText = nwUnicode.U_THNSP + theText = nwUnicode.U_THSP elif theInsert == nwDocInsert.THIN_NB_SPACE: theText = nwUnicode.U_THNBSP elif theInsert == nwDocInsert.SHORT_DASH: diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 554eca52..b22502d1 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -212,6 +212,9 @@ class GuiMainMenu(QMenuBar): self.aCloseProject.triggered.connect(lambda: self.theParent.closeProject(False)) self.projMenu.addAction(self.aCloseProject) + # Project > Separator + self.projMenu.addSeparator() + # Project > Project Settings self.aProjectSettings = QAction("Project Settings", self) self.aProjectSettings.setStatusTip("Project settings") @@ -219,6 +222,13 @@ class GuiMainMenu(QMenuBar): self.aProjectSettings.triggered.connect(lambda: self.theParent.showProjectSettingsDialog()) self.projMenu.addAction(self.aProjectSettings) + # Project > Project Details + self.aProjectDetails = QAction("Project Details", self) + self.aProjectDetails.setStatusTip("Project details") + self.aProjectDetails.setShortcut("Shift+F6") + self.aProjectDetails.triggered.connect(lambda: self.theParent.showProjectDetailsDialog()) + self.projMenu.addAction(self.aProjectDetails) + # Project > Separator self.projMenu.addSeparator() diff --git a/nw/gui/projdetails.py b/nw/gui/projdetails.py new file mode 100644 index 00000000..716177af --- /dev/null +++ b/nw/gui/projdetails.py @@ -0,0 +1,419 @@ +# -*- coding: utf-8 -*- +"""novelWriter GUI Project Details + + novelWriter – GUI Project Details +=================================== + Class holding the project details dialog + + File History: + Created: 2021-01-03 [1.0a0] + + This file is a part of novelWriter + Copyright 2018–2021, Veronica Berglyd Olsen + + 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 . +""" + +import nw +import logging +import math + +from PyQt5.QtCore import Qt, QSize +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QWidget, QDialogButtonBox, QVBoxLayout, QTreeWidget, QTreeWidgetItem, + QLabel, QSpinBox, QGridLayout, QHBoxLayout, QLineEdit +) + +from nw.gui.custom import PagedDialog, QSwitch +from nw.constants import nwUnicode + +logger = logging.getLogger(__name__) + +class GuiProjectDetails(PagedDialog): + + def __init__(self, theParent, theProject): + PagedDialog.__init__(self, theParent) + + logger.debug("Initialising GuiProjectDetails ...") + self.setObjectName("GuiProjectDetails") + + self.mainConf = nw.CONFIG + self.theParent = theParent + self.theProject = theProject + self.optState = theProject.optState + + self.setWindowTitle("Project Details") + + wW = self.mainConf.pxInt(600) + wH = self.mainConf.pxInt(425) + + self.setMinimumWidth(wW) + self.setMinimumHeight(wH) + self.resize( + self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "winWidth", wW)), + self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "winHeight", wH)) + ) + + self.tabMain = GuiProjectDetailsMain(self.theParent, self.theProject) + self.tabContents = GuiProjectDetailsContents(self.theParent, self.theProject) + + self.addTab(self.tabMain, "Overview") + self.addTab(self.tabContents, "Contents") + + self.buttonBox = QDialogButtonBox(QDialogButtonBox.Close) + self.buttonBox.rejected.connect(self._doClose) + self.addControls(self.buttonBox) + + logger.debug("GuiProjectDetails initialisation complete") + + return + + ## + # Slots + ## + + def _doClose(self): + """Save settings and close the dialog. + """ + self._saveGuiSettings() + self.close() + return + + ## + # Internal Functions + ## + + def _saveGuiSettings(self): + """Save GUI settings. + """ + winWidth = self.mainConf.rpxInt(self.width()) + winHeight = self.mainConf.rpxInt(self.height()) + + cColWidth = self.tabContents.getColumnSizes() + widthCol0 = self.mainConf.rpxInt(cColWidth[0]) + widthCol1 = self.mainConf.rpxInt(cColWidth[1]) + widthCol2 = self.mainConf.rpxInt(cColWidth[2]) + widthCol3 = self.mainConf.rpxInt(cColWidth[3]) + widthCol4 = self.mainConf.rpxInt(cColWidth[4]) + + wordsPerPage = self.tabContents.wpValue.value() + clearDouble = self.tabContents.dblValue.isChecked() + + self.optState.setValue("GuiProjectDetails", "winWidth", winWidth) + self.optState.setValue("GuiProjectDetails", "winHeight", winHeight) + self.optState.setValue("GuiProjectDetails", "widthCol0", widthCol0) + self.optState.setValue("GuiProjectDetails", "widthCol1", widthCol1) + self.optState.setValue("GuiProjectDetails", "widthCol2", widthCol2) + self.optState.setValue("GuiProjectDetails", "widthCol3", widthCol3) + self.optState.setValue("GuiProjectDetails", "widthCol4", widthCol4) + self.optState.setValue("GuiProjectDetails", "wordsPerPage", wordsPerPage) + self.optState.setValue("GuiProjectDetails", "clearDouble", clearDouble) + + return + +# END Class GuiProjectDetails + +class GuiProjectDetailsMain(QWidget): + + def __init__(self, theParent, theProject): + QWidget.__init__(self, theParent) + + self.mainConf = nw.CONFIG + self.theParent = theParent + self.theProject = theProject + self.theTheme = theParent.theTheme + self.theIndex = theParent.theIndex + + fPx = self.theTheme.fontPixelSize + vPx = self.mainConf.pxInt(4) + hPx = self.mainConf.pxInt(12) + + # Header + # ====== + + self.bookTitle = QLabel(self.theProject.bookTitle) + bookFont = self.bookTitle.font() + bookFont.setPixelSize(round(2.2*fPx)) + bookFont.setWeight(QFont.Bold) + self.bookTitle.setFont(bookFont) + self.bookTitle.setAlignment(Qt.AlignHCenter) + self.bookTitle.setWordWrap(True) + + self.projName = QLabel("Working Title: %s" % self.theProject.projName) + workFont = self.projName.font() + workFont.setPixelSize(round(0.8*fPx)) + workFont.setItalic(True) + self.projName.setFont(workFont) + self.projName.setAlignment(Qt.AlignHCenter) + self.projName.setWordWrap(True) + + self.bookAuthors = QLabel("By %s" % self.theProject.getAuthors()) + authFont = self.bookAuthors.font() + authFont.setPixelSize(round(1.2*fPx)) + self.bookAuthors.setFont(authFont) + self.bookAuthors.setAlignment(Qt.AlignHCenter) + self.bookAuthors.setWordWrap(True) + + # Stats + # ===== + + hCounts = self.theIndex.getNovelTitleCounts() + nwCount = self.theIndex.getNovelWordCount() + + self.wordCountLbl = QLabel("Words:") + self.wordCountVal = QLabel(f"{nwCount:n}") + + self.chapCountLbl = QLabel("Chapters:") + self.chapCountVal = QLabel(f"{hCounts[2]:n}") + + self.sceneCountLbl = QLabel("Scenes:") + self.sceneCountVal = QLabel(f"{hCounts[3]:n}") + + self.revCountLbl = QLabel("Revisions:") + self.revCountVal = QLabel(f"{self.theProject.saveCount:n}") + + edTime = self.theProject.getCurrentEditTime() + self.editTimeLbl = QLabel("Editing Time:") + self.editTimeVal = QLabel(f"{edTime//3600:02d}:{edTime%3600//60:02d}") + + self.statsGrid = QGridLayout() + self.statsGrid.addWidget(self.wordCountLbl, 0, 0, 1, 1, Qt.AlignRight) + self.statsGrid.addWidget(self.wordCountVal, 0, 1, 1, 1, Qt.AlignLeft) + self.statsGrid.addWidget(self.chapCountLbl, 1, 0, 1, 1, Qt.AlignRight) + self.statsGrid.addWidget(self.chapCountVal, 1, 1, 1, 1, Qt.AlignLeft) + self.statsGrid.addWidget(self.sceneCountLbl, 2, 0, 1, 1, Qt.AlignRight) + self.statsGrid.addWidget(self.sceneCountVal, 2, 1, 1, 1, Qt.AlignLeft) + self.statsGrid.addWidget(self.revCountLbl, 3, 0, 1, 1, Qt.AlignRight) + self.statsGrid.addWidget(self.revCountVal, 3, 1, 1, 1, Qt.AlignLeft) + self.statsGrid.addWidget(self.editTimeLbl, 4, 0, 1, 1, Qt.AlignRight) + self.statsGrid.addWidget(self.editTimeVal, 4, 1, 1, 1, Qt.AlignLeft) + self.statsGrid.setHorizontalSpacing(hPx) + self.statsGrid.setVerticalSpacing(vPx) + + # Meta + # ==== + + self.projPathLbl = QLabel("Path:") + self.projPathVal = QLineEdit() + self.projPathVal.setText(self.theProject.projPath) + self.projPathVal.setReadOnly(True) + + self.projPathBox = QHBoxLayout() + self.projPathBox.addWidget(self.projPathLbl) + self.projPathBox.addWidget(self.projPathVal) + self.projPathBox.setSpacing(hPx) + + # Assemble + # ======== + + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.bookTitle) + self.outerBox.addWidget(self.projName) + self.outerBox.addWidget(self.bookAuthors) + self.outerBox.addSpacing(round(2.5*fPx)) + self.outerBox.addLayout(self.statsGrid) + self.outerBox.addSpacing(round(0.8*fPx)) + self.outerBox.addStretch(1) + self.outerBox.addLayout(self.projPathBox) + + self.setLayout(self.outerBox) + + return + +# END Class GuiProjectDetailsMain + +class GuiProjectDetailsContents(QWidget): + + C_TITLE = 0 + C_WORDS = 1 + C_PAGES = 2 + C_PAGE = 3 + C_PROG = 4 + + def __init__(self, theParent, theProject): + QWidget.__init__(self, theParent) + + self.mainConf = nw.CONFIG + self.theParent = theParent + self.theProject = theProject + self.theTheme = theParent.theTheme + self.theIndex = theParent.theIndex + self.optState = theProject.optState + + # Internal + self._theToC = [] + + iPx = self.theTheme.baseIconSize + hPx = self.mainConf.pxInt(12) + + # Contents Tree + # ============= + + self.tocTree = QTreeWidget() + self.tocTree.setIconSize(QSize(iPx, iPx)) + self.tocTree.setIndentation(0) + self.tocTree.setColumnCount(6) + self.tocTree.setHeaderLabels(["Title", "Words", "Pages", "Page", "Progress", ""]) + + treeHeadItem = self.tocTree.headerItem() + treeHeadItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) + treeHeadItem.setTextAlignment(self.C_PAGES, Qt.AlignRight) + treeHeadItem.setTextAlignment(self.C_PAGE, Qt.AlignRight) + treeHeadItem.setTextAlignment(self.C_PROG, Qt.AlignRight) + + treeHeader = self.tocTree.header() + treeHeader.setStretchLastSection(True) + treeHeader.setMinimumSectionSize(hPx) + + wCol0 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol0", 200)) + wCol1 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol1", 60)) + wCol2 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol2", 60)) + wCol3 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol3", 60)) + wCol4 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "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 = self.optState.getInt("GuiProjectDetails", "wordsPerPage", 350) + clearDouble = self.optState.getInt("GuiProjectDetails", "clearDouble", True) + + wordsHelp = ( + "Typical word count for a 5 by 8 inch book page with 11 pt font is 350." + ) + dblHelp = ( + "Assume a new chapter or partition always start on an odd numbered page." + ) + + self.wpLabel = QLabel("Words per page") + self.wpLabel.setToolTip(wordsHelp) + + self.wpValue = QSpinBox() + self.wpValue.setMinimum(10) + self.wpValue.setMaximum(1000) + self.wpValue.setSingleStep(10) + self.wpValue.setValue(wordsPerPage) + self.wpValue.setToolTip(wordsHelp) + self.wpValue.valueChanged.connect(self._populateTree) + + self.dblLabel = QLabel("Clear double pages") + self.dblLabel.setToolTip(dblHelp) + + self.dblValue = QSwitch(self, 2*iPx, iPx) + self.dblValue.setChecked(clearDouble) + self.dblValue.setToolTip(dblHelp) + self.dblValue.clicked.connect(self._populateTree) + + self.optionsBox = QHBoxLayout() + self.optionsBox.addWidget(self.wpLabel) + self.optionsBox.addWidget(self.wpValue) + self.optionsBox.addStretch(1) + self.optionsBox.addWidget(self.dblLabel) + self.optionsBox.addWidget(self.dblValue) + self.optionsBox.setSpacing(hPx) + + # Assemble + # ======== + + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.tocTree) + self.outerBox.addLayout(self.optionsBox) + + self.setLayout(self.outerBox) + + self._prepareData() + self._populateTree() + + return + + def getColumnSizes(self): + """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 + + ## + # Internal Functions + ## + + def _prepareData(self): + """Extract the data for the tree. + """ + self._theToC = [] + self._theToC = self.theIndex.getTableOfContents(2) + self._theToC.append(("", "H0", "END", 0)) + return + + ## + # Slots + ## + + def _populateTree(self): + """Set the content of the chapter/page tree. + """ + dblPages = self.dblValue.isChecked() + wpPage = self.wpValue.value() + + tPages = 1 + pTotal = 0 + + theList = [] + for _, tLevel, tTitle, wCount in self._theToC: + pCount = math.ceil(wCount/wpPage) + if dblPages: + pCount += pCount%2 + pTotal += pCount + theList.append((tLevel, tTitle, wCount, pCount)) + + self.tocTree.clear() + for tLevel, tTitle, wCount, pCount in theList: + newItem = QTreeWidgetItem() + + if tLevel == "H2": + tTitle = nwUnicode.U_ENSP+tTitle + + pgProg = 100.0*(tPages - 1)/pTotal if pTotal > 0 else 0.0 + + newItem.setIcon(self.C_TITLE, self.theTheme.getIcon("doc_%s" % tLevel.lower())) + 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, f"{tPages:n}") + newItem.setText(self.C_PROG, f"{pgProg:.1f}{nwUnicode.U_THSP}%") + + 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) + + tPages += pCount + + self.tocTree.addTopLevelItem(newItem) + + return + +# END Class GuiProjectDetailsContents diff --git a/nw/gui/projsettings.py b/nw/gui/projsettings.py index 52df0589..23426cb5 100644 --- a/nw/gui/projsettings.py +++ b/nw/gui/projsettings.py @@ -31,9 +31,9 @@ import logging from PyQt5.QtCore import Qt from PyQt5.QtGui import QIcon, QPixmap, QColor, QBrush from PyQt5.QtWidgets import ( - QHBoxLayout, QVBoxLayout, QGridLayout, QLineEdit, QPlainTextEdit, QLabel, - QWidget, QDialogButtonBox, QListWidget, QPushButton, QListWidgetItem, - QColorDialog, QAbstractItemView, QTreeWidget, QTreeWidgetItem, QComboBox + QHBoxLayout, QVBoxLayout, QLineEdit, QPlainTextEdit, QLabel, QWidget, + QDialogButtonBox, QListWidget, QPushButton, QListWidgetItem, QColorDialog, + QAbstractItemView, QTreeWidget, QTreeWidgetItem, QComboBox ) from nw.constants import nwAlert @@ -68,13 +68,11 @@ class GuiProjectSettings(PagedDialog): ) self.tabMain = GuiProjectEditMain(self.theParent, self.theProject) - self.tabMeta = GuiProjectEditMeta(self.theParent, self.theProject) self.tabStatus = GuiProjectEditStatus(self.theParent, self.theProject, True) self.tabImport = GuiProjectEditStatus(self.theParent, self.theProject, False) self.tabReplace = GuiProjectEditReplace(self.theParent, self.theProject) self.addTab(self.tabMain, "Settings") - self.addTab(self.tabMeta, "Details") self.addTab(self.tabStatus, "Status") self.addTab(self.tabImport, "Importance") self.addTab(self.tabReplace, "Auto-Replace") @@ -238,94 +236,6 @@ class GuiProjectEditMain(QWidget): # END Class GuiProjectEditMain -class GuiProjectEditMeta(QWidget): - - def __init__(self, theParent, theProject): - QWidget.__init__(self, theParent) - - self.mainConf = nw.CONFIG - self.theParent = theParent - self.theProject = theProject - - xInd = self.mainConf.pxInt(8) - - # The Form - self.mainForm = QGridLayout() - self.setLayout(self.mainForm) - - self.headLabel = QLabel("Project Details") - - self.nameLabel = QLabel("Working title:") - self.nameLabel.setIndent(xInd) - self.nameValue = QLabel(self.theProject.projName) - self.nameValue.setWordWrap(True) - - self.pathLabel = QLabel("Project path:") - self.pathLabel.setIndent(xInd) - self.pathValue = QLabel(self.theProject.projPath) - self.pathValue.setWordWrap(True) - self.pathValue.setTextInteractionFlags(Qt.TextSelectableByMouse) - self.pathValue.setCursor(Qt.IBeamCursor) - - self.revLabel = QLabel("Revision count:") - self.revLabel.setIndent(xInd) - self.revValue = QLabel(f"{self.theProject.saveCount:n}") - - editHours = self.theProject.editTime/3600 - self.editLabel = QLabel("Edit time:") - self.editLabel.setIndent(xInd) - self.editValue = QLabel(f"{editHours:.2f} hours") - - self.statsLabel = QLabel("Project Stats") - - nR, nD, nF = self.theProject.projTree.countTypes() - - self.nRootLabel = QLabel("Root folders:") - self.nRootLabel.setIndent(xInd) - self.nRootValue = QLabel(f"{nR:n}") - - self.nDirLabel = QLabel("Folders:") - self.nDirLabel.setIndent(xInd) - self.nDirValue = QLabel(f"{nD:n}") - - self.nFileLabel = QLabel("Documents:") - self.nFileLabel.setIndent(xInd) - self.nFileValue = QLabel(f"{nF:n}") - - self.wordsLabel = QLabel("Word count:") - self.wordsLabel.setIndent(xInd) - self.wordsValue = QLabel(f"{self.theProject.currWCount:n}") - - self.mainForm.addWidget(self.headLabel, 0, 0, 1, 2, Qt.AlignTop) - self.mainForm.addWidget(self.nameLabel, 1, 0, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.nameValue, 1, 1, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.pathLabel, 2, 0, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.pathValue, 2, 1, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.revLabel, 3, 0, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.revValue, 3, 1, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.editLabel, 4, 0, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.editValue, 4, 1, 1, 1, Qt.AlignTop) - - self.mainForm.addWidget(self.statsLabel, 5, 0, 1, 2, Qt.AlignTop) - self.mainForm.addWidget(self.nRootLabel, 6, 0, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.nRootValue, 6, 1, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.nDirLabel, 7, 0, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.nDirValue, 7, 1, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.nFileLabel, 8, 0, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.nFileValue, 8, 1, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.wordsLabel, 9, 0, 1, 1, Qt.AlignTop) - self.mainForm.addWidget(self.wordsValue, 9, 1, 1, 1, Qt.AlignTop) - - self.mainForm.setVerticalSpacing(self.mainConf.pxInt(6)) - self.mainForm.setHorizontalSpacing(self.mainConf.pxInt(12)) - self.mainForm.setColumnStretch(0, 0) - self.mainForm.setColumnStretch(1, 1) - self.mainForm.setRowStretch(10, 1) - - return - -# END Class GuiProjectEditMeta - class GuiProjectEditStatus(QWidget): def __init__(self, theParent, theProject, isStatus): @@ -414,7 +324,6 @@ class GuiProjectEditStatus(QWidget): def _selectColour(self): """Open a dialog to select the status icon colour. """ - logger.verbose("Item colour button clicked") if self.selColour is not None: newCol = QColorDialog.getColor( self.selColour, self, "Select Colour", QColorDialog.DontUseNativeDialog @@ -430,7 +339,6 @@ class GuiProjectEditStatus(QWidget): def _newItem(self): """Create a new status item. """ - logger.verbose("New item button clicked") newItem = self._addItem("New Item", (0, 0, 0), None, 0) newItem.setBackground(QBrush(QColor(0, 255, 0, 80))) self.colChanged = True @@ -439,7 +347,6 @@ class GuiProjectEditStatus(QWidget): def _delItem(self): """Delete a status item. """ - logger.verbose("Delete item button clicked") selItem = self._getSelectedItem() if selItem is not None: iRow = self.listBox.row(selItem) @@ -456,7 +363,6 @@ class GuiProjectEditStatus(QWidget): def _saveItem(self): """Save changes made to a status item. """ - logger.verbose("Save item button clicked") selItem = self._getSelectedItem() if selItem is not None: selIdx = selItem.data(Qt.UserRole) @@ -491,7 +397,6 @@ class GuiProjectEditStatus(QWidget): """Extract the info of a selected item and populate the settings boxes and button. """ - logger.verbose("Item selected") selItem = self._getSelectedItem() if selItem is not None: selIdx = selItem.data(Qt.UserRole) diff --git a/nw/gui/theme.py b/nw/gui/theme.py index 61e2fca7..ef97c493 100644 --- a/nw/gui/theme.py +++ b/nw/gui/theme.py @@ -473,6 +473,9 @@ class GuiTheme: return def _parseLine(self, confParser, cnfSec, cnfName, cnfDefault): + """Simple wrapper for the config parser to check that the entry + exists before attempting to load it. + """ if confParser.has_section(cnfSec): if confParser.has_option(cnfSec, cnfName): return confParser.get(cnfSec, cnfName) @@ -500,12 +503,13 @@ class GuiIcons: the ICON_MAP data tuple[0]. This will let Qt pull the closest system icon. * Third action is to look up the freedesktop icon theme name using - the fromTheme Qt call. This generally produces the same results + the fromTheme Qt call. This generally produces the same result as the step above, but has more icons available in other cases. * Fourth, and finally, the icon is looked up in the fallback folder. Files in this folder must have the same file name as the - novelWriter internal icon key, with '-dark' appended to it for - the dark background version of the icon. + novelWriter internal icon key, with '-dark' appended to them for + the dark background version of the icon. If no dark icon exists, + the non-dark version will be returned. """ ICON_MAP = { @@ -529,6 +533,7 @@ class GuiIcons: "status_time" : (None, None), "status_stats" : (None, None), "status_lines" : (None, None), + "doc_h0" : (QStyle.SP_FileIcon, "x-office-document"), "doc_h1" : (QStyle.SP_FileIcon, "x-office-document"), "doc_h2" : (QStyle.SP_FileIcon, "x-office-document"), "doc_h3" : (QStyle.SP_FileIcon, "x-office-document"), @@ -562,6 +567,7 @@ class GuiIcons: "reference" : (None, None), "backward" : (None, None), "forward" : (None, None), + "settings" : (None, None), ## Switches "sticky-on" : (None, None), @@ -610,7 +616,6 @@ class GuiIcons: the GUI icons cannot really be replaced without writing specific update functions for the classes where they're used. """ - logger.debug("Loading icon theme files") self.themeMap = {} @@ -747,7 +752,7 @@ class GuiIcons: ## def _loadIcon(self, iconKey): - """Load an icon from the assets or theme folder, with a + """Load an icon from the assets or themes folder, with a preference for dark/light icons depending on theme type, if such an icon exists. Prefer svg files over png files. Always returns a QIcon. @@ -785,6 +790,7 @@ class GuiIcons: if os.path.isfile(fbackIcon): logger.verbose("Loading icon '%s' from fallback theme (dark mode)" % iconKey) return QIcon(fbackIcon) + fbackIcon = os.path.join(self.mainConf.iconPath, self.fbackName, "%s.svg" % iconKey) if os.path.isfile(fbackIcon): logger.verbose("Loading icon '%s' from fallback theme (light mode)" % iconKey) @@ -796,8 +802,8 @@ class GuiIcons: return QIcon() def _parseLine(self, confParser, cnfSec, cnfName, cnfDefault): - """Simple wrapper for the config parser check for entry existing - before arrempting to load. + """Simple wrapper for the config parser to check that the entry + exists before attempting to load it. """ if confParser.has_section(cnfSec): if confParser.has_option(cnfSec, cnfName): diff --git a/nw/guimain.py b/nw/guimain.py index 5c550192..f141b8e5 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -32,19 +32,19 @@ import os from datetime import datetime from time import time -from PyQt5.QtCore import Qt, QTimer, QThreadPool, pyqtSlot +from PyQt5.QtCore import Qt, QTimer, QSize, QThreadPool, pyqtSlot from PyQt5.QtGui import QIcon, QPixmap, QColor, QKeySequence, QCursor from PyQt5.QtWidgets import ( qApp, QMainWindow, QVBoxLayout, QWidget, QSplitter, QFileDialog, QShortcut, - QMessageBox, QDialog, QTabWidget + QMessageBox, QDialog, QTabWidget, QToolBar, QAction ) from nw.gui import ( GuiAbout, GuiBuildNovel, GuiDocEditor, GuiDocMerge, GuiDocSplit, GuiDocViewDetails, GuiDocViewer, GuiItemDetails, GuiItemEditor, GuiMainMenu, GuiMainStatus, GuiNovelTree, GuiOutline, GuiOutlineDetails, - GuiPreferences, GuiProjectLoad, GuiProjectSettings, GuiProjectTree, - GuiProjectWizard, GuiTheme, GuiWritingStats + GuiPreferences, GuiProjectDetails, GuiProjectLoad, GuiProjectSettings, + GuiProjectTree, GuiProjectWizard, GuiTheme, GuiWritingStats ) from nw.core import NWProject, NWDoc, NWIndex from nw.constants import nwItemType, nwItemClass, nwAlert, nwLists @@ -112,7 +112,7 @@ class GuiMain(QMainWindow): self.statusIcons = [] self.importIcons = [] - # Project Tabs : Project / Novel + # Project Tree Tabs self.projTabs = QTabWidget() self.projTabs.setTabPosition(QTabWidget.South) self.projTabs.setStyleSheet("QTabWidget::pane {border: 0;};") @@ -124,6 +124,30 @@ class GuiMain(QMainWindow): tabFont.setPointSize(round(0.9*self.theTheme.fontPointSize)) self.projTabs.tabBar().setFont(tabFont) + # Project Tree Action Buttons + btnSize = round(0.7*self.theTheme.fontPixelSize) + self.treeButtons = QToolBar() + self.treeButtons.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.treeButtons.setIconSize(QSize(btnSize, btnSize)) + self.treeButtons.setContentsMargins(0, 0, 0, 0) + self.treeButtons.setStyleSheet(r"QToolBar {padding: 0;}") + self.projTabs.setCornerWidget(self.treeButtons, Qt.BottomRightCorner) + + self.projDetailsBtn = QAction("Project Details") + self.projDetailsBtn.setIcon(self.theTheme.getIcon("status_lines")) + self.projDetailsBtn.triggered.connect(lambda: self.showProjectDetailsDialog()) + self.treeButtons.addAction(self.projDetailsBtn) + + self.projStatsBtn = QAction("Writing Statistics") + self.projStatsBtn.setIcon(self.theTheme.getIcon("status_stats")) + self.projStatsBtn.triggered.connect(lambda: self.showWritingStatsDialog()) + self.treeButtons.addAction(self.projStatsBtn) + + self.projSettingsBtn = QAction("Project Settings") + self.projSettingsBtn.setIcon(self.theTheme.getIcon("settings")) + self.projSettingsBtn.triggered.connect(lambda: self.showProjectSettingsDialog()) + self.treeButtons.addAction(self.projSettingsBtn) + # Project Tree View self.treePane = QWidget() self.treeBox = QVBoxLayout() @@ -922,6 +946,19 @@ class GuiMain(QMainWindow): return + def showProjectDetailsDialog(self): + """Open the project details dialog. + """ + if not self.hasProject: + logger.error("No project open") + return + + dlgDetails = GuiProjectDetails(self, self.theProject) + dlgDetails.setModal(False) + dlgDetails.show() + + return + def showBuildProjectDialog(self): """Open the build project dialog. """ diff --git a/tests/reference/coreIndex_LoadSave_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json index 281073cc..2aa3df3c 100644 --- a/tests/reference/coreIndex_LoadSave_tagsIndex.json +++ b/tests/reference/coreIndex_LoadSave_tagsIndex.json @@ -300,7 +300,18 @@ "updated": 123 } }, - "8c58a65414c23": {}, + "8c58a65414c23": { + "T000000": { + "level": "H0", + "title": "Untitled Page", + "layout": "PAGE", + "synopsis": "", + "cCount": 1058, + "wCount": 176, + "pCount": 2, + "updated": 123 + } + }, "88d59a277361b": { "T000001": { "level": "H2", diff --git a/tests/test_core_index.py b/tests/test_core_index.py index b19af00d..d865cd41 100644 --- a/tests/test_core_index.py +++ b/tests/test_core_index.py @@ -276,6 +276,7 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI): assert not theIndex.scanText(xHandle, "Hello World!") # Make some usable items + pHandle = theProject.newFile("Page", nwItemClass.NOVEL, "a508bb932959c") nHandle = theProject.newFile("Hello", nwItemClass.NOVEL, "a508bb932959c") cHandle = theProject.newFile("Jane", nwItemClass.CHARACTER, "afb3043c7b2b3") sHandle = theProject.newFile("Scene", nwItemClass.NOVEL, "a508bb932959c") @@ -310,7 +311,7 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI): "#### Title Four\n\n" "% synopsis: Synopsis Four.\n\n" "Paragraph Four.\n\n" - "##### Title Five\n\n" # Not interpreted as a title, the hashes is counted as a word + "##### Title Five\n\n" # Not interpreted as a title, the hashes are counted as a word "Paragraph Five.\n\n" )) assert theIndex._refIndex[nHandle].get("T000000", None) is not None # Always there @@ -411,6 +412,33 @@ def testCoreIndex_ScanText(nwMinimal, dummyGUI): [[3, "@pov", "One"], [5, "@char", "Two"]] ) + # Page wo/Title + theProject.projTree[pHandle].itemLayout = nwItemLayout.PAGE + assert theIndex.scanText(pHandle, ( + "This is a page with some text on it.\n\n" + )) + assert theIndex._novelIndex[pHandle]["T000000"]["level"] == "H0" + assert theIndex._novelIndex[pHandle]["T000000"]["title"] == "Untitled Page" + assert theIndex._novelIndex[pHandle]["T000000"]["layout"] == "PAGE" + assert theIndex._novelIndex[pHandle]["T000000"]["synopsis"] == "" + assert theIndex._novelIndex[pHandle]["T000000"]["cCount"] == 36 + assert theIndex._novelIndex[pHandle]["T000000"]["wCount"] == 9 + assert theIndex._novelIndex[pHandle]["T000000"]["pCount"] == 1 + assert pHandle not in theIndex._noteIndex + + theProject.projTree[pHandle].itemLayout = nwItemLayout.NOTE + assert theIndex.scanText(pHandle, ( + "This is a page with some text on it.\n\n" + )) + assert theIndex._noteIndex[pHandle]["T000000"]["level"] == "H0" + assert theIndex._noteIndex[pHandle]["T000000"]["title"] == "Untitled Page" + assert theIndex._noteIndex[pHandle]["T000000"]["layout"] == "NOTE" + assert theIndex._noteIndex[pHandle]["T000000"]["synopsis"] == "" + assert theIndex._noteIndex[pHandle]["T000000"]["cCount"] == 36 + assert theIndex._noteIndex[pHandle]["T000000"]["wCount"] == 9 + assert theIndex._noteIndex[pHandle]["T000000"]["pCount"] == 1 + assert pHandle not in theIndex._novelIndex + assert theProject.closeProject() # END Test testCoreIndex_ScanText @@ -579,6 +607,54 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI): assert wC == 12 assert pC == 2 + ## + # Novel Stats + ## + + hHandle = theProject.newFile("Chapter", nwItemClass.NOVEL, "a508bb932959c") + sHandle = theProject.newFile("Scene One", nwItemClass.NOVEL, "a508bb932959c") + tHandle = theProject.newFile("Scene Two", nwItemClass.NOVEL, "a508bb932959c") + + theProject.projTree[hHandle].itemLayout == nwItemLayout.CHAPTER + theProject.projTree[sHandle].itemLayout == nwItemLayout.SCENE + theProject.projTree[tHandle].itemLayout == nwItemLayout.SCENE + + assert theIndex.scanText(hHandle, "## Chapter One\n\n") + assert theIndex.scanText(sHandle, "### Scene One\n\n") + assert theIndex.scanText(tHandle, "### Scene Two\n\n") + + assert theIndex._listNovelHandles(False) == [nHandle, hHandle, sHandle, tHandle] + assert theIndex._listNovelHandles(True) == [hHandle, sHandle, tHandle] + + # Add a fake handle to the tree and check that it's ignored + theProject.projTree._treeOrder.append("0000000000000") + assert theIndex._listNovelHandles(False) == [nHandle, hHandle, sHandle, tHandle] + theProject.projTree._treeOrder.remove("0000000000000") + + # Extract stats + assert theIndex.getNovelWordCount(False) == 30 + assert theIndex.getNovelWordCount(True) == 6 + assert theIndex.getNovelTitleCounts(False) == [0, 2, 1, 2, 0] + assert theIndex.getNovelTitleCounts(True) == [0, 0, 1, 2, 0] + + # Table of Contents + assert theIndex.getTableOfContents(0, True) == [] + assert theIndex.getTableOfContents(1, True) == [] + assert theIndex.getTableOfContents(2, True) == [ + ("%s:T000001" % hHandle, "H2", "Chapter One", 6), + ] + assert theIndex.getTableOfContents(3, True) == [ + ("%s:T000001" % hHandle, "H2", "Chapter One", 2), + ("%s:T000001" % sHandle, "H3", "Scene One", 2), + ("%s:T000001" % tHandle, "H3", "Scene Two", 2), + ] + + assert theIndex.getTableOfContents(0, False) == [] + assert theIndex.getTableOfContents(1, False) == [ + ("%s:T000001" % nHandle, "H1", "Hello World!", 12), + ("%s:T000011" % nHandle, "H1", "Hello World!", 18), + ] + assert theProject.closeProject() # END Test testCoreIndex_ExtractData diff --git a/tests/test_core_project.py b/tests/test_core_project.py index 57ee8f51..6fbe14c9 100644 --- a/tests/test_core_project.py +++ b/tests/test_core_project.py @@ -682,10 +682,31 @@ def testCoreProject_Methods(monkeypatch, nwMinimal, dummyGUI, tmpDir): assert theProject.bookTitle == "A Title" # Project Authors + # Check that the list is cleaned up and that it can be extracted as + # a properly formatted string, depending on number of names assert not theProject.setBookAuthors([]) assert theProject.setBookAuthors(" Jane Doe \n John Doh \n ") assert theProject.bookAuthors == ["Jane Doe", "John Doh"] + assert theProject.setBookAuthors("") + assert theProject.getAuthors() == "" + + assert theProject.setBookAuthors("Jane Doe") + assert theProject.getAuthors() == "Jane Doe" + + assert theProject.setBookAuthors("Jane Doe\nJohn Doh") + assert theProject.getAuthors() == "Jane Doe and John Doh" + + assert theProject.setBookAuthors("Jane Doe\nJohn Doh\nBod Owens") + assert theProject.getAuthors() == "Jane Doe, John Doh and Bod Owens" + + # Edit Time + theProject.editTime = 1234 + theProject.projOpened = 1600000000 + monkeypatch.setattr("nw.core.project.time", lambda: 1600005600) + assert theProject.getCurrentEditTime() == 6834 + monkeypatch.undo() + # Trash folder # Should create on first call, and just returned on later calls assert theProject.projTree["73475cb40a568"] is None diff --git a/tests/test_gui_mainmenu.py b/tests/test_gui_mainmenu.py index 33408505..86e3b749 100644 --- a/tests/test_gui_mainmenu.py +++ b/tests/test_gui_mainmenu.py @@ -410,7 +410,7 @@ def testGuiMenu_Insert(qtbot, monkeypatch, nwGUI, fncDir, fncProj): nwGUI.docEditor.clear() nwGUI.mainMenu.aInsThinSpace.activate(QAction.Trigger) - assert nwGUI.docEditor.getText() == nwUnicode.U_THNSP + assert nwGUI.docEditor.getText() == nwUnicode.U_THSP nwGUI.docEditor.clear() nwGUI.mainMenu.aInsThinNBSpace.activate(QAction.Trigger) diff --git a/tests/test_gui_projdetails.py b/tests/test_gui_projdetails.py new file mode 100644 index 00000000..5af043a1 --- /dev/null +++ b/tests/test_gui_projdetails.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""novelWriter Writing Stats Dialog Class Tester +""" + +import pytest + +from tools import getGuiItem + +from PyQt5.QtWidgets import QAction, QMessageBox + +from nw.gui import GuiProjectDetails +from nw.constants import nwUnicode + +keyDelay = 2 +typeDelay = 1 +stepDelay = 20 + +@pytest.mark.gui +def testGuiProjDetails_Dialog(qtbot, monkeypatch, nwGUI, nwLipsum): + """Test the full writing stats tool. + """ + # Block message box + monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "information", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "warning", lambda *args: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "critical", lambda *args: QMessageBox.Yes) + + # Create a project to work on + assert nwGUI.openProject(nwLipsum) + assert nwGUI.rebuildIndex(beQuiet=True) + qtbot.wait(100) + + # Open the Writing Stats dialog + nwGUI.mainConf.lastPath = "" + nwGUI.mainMenu.aProjectDetails.activate(QAction.Trigger) + qtbot.waitUntil(lambda: getGuiItem("GuiProjectDetails") is not None, timeout=1000) + + projDet = getGuiItem("GuiProjectDetails") + assert isinstance(projDet, GuiProjectDetails) + qtbot.wait(stepDelay) + + # Overview Page + # ============= + + assert projDet.tabMain.bookTitle.text() == "Lorem Ipsum" + assert projDet.tabMain.projName.text()[-11:] == "Lorem Ipsum" + assert projDet.tabMain.bookAuthors.text()[-10:] == "lipsum.com" + + assert projDet.tabMain.wordCountVal.text() == f"{3000:n}" + assert projDet.tabMain.chapCountVal.text() == f"{3:n}" + assert projDet.tabMain.sceneCountVal.text() == f"{5:n}" + assert projDet.tabMain.revCountVal.text() == f"{nwGUI.theProject.saveCount:n}" + + assert projDet.tabMain.projPathVal.text() == nwLipsum + + # Contents Page + # ============= + + tocTab = projDet.tabContents + tocTree = tocTab.tocTree + assert tocTree.topLevelItemCount() == 7 + assert tocTree.topLevelItem(0).text(tocTab.C_TITLE) == "Lorem Ipsum" + assert tocTree.topLevelItem(2).text(tocTab.C_TITLE) == nwUnicode.U_ENSP+"Prologue" + assert tocTree.topLevelItem(3).text(tocTab.C_TITLE) == "Act One" + assert tocTree.topLevelItem(4).text(tocTab.C_TITLE) == nwUnicode.U_ENSP+"Chapter One" + assert tocTree.topLevelItem(5).text(tocTab.C_TITLE) == nwUnicode.U_ENSP+"Chapter Two" + assert tocTree.topLevelItem(6).text(tocTab.C_TITLE) == "END" + + # Count Pages + tocTab.wpValue.setValue(100) + tocTab.dblValue.setChecked(False) + tocTab._populateTree() + + thePages = [1, 2, 1, 1, 11, 17, 0] + thePage = [1, 2, 4, 5, 6, 17, 34] + for i in range(7): + assert tocTree.topLevelItem(i).text(tocTab.C_PAGES) == f"{thePages[i]:n}" + assert tocTree.topLevelItem(i).text(tocTab.C_PAGE) == f"{thePage[i]:n}" + + tocTab.dblValue.setChecked(True) + tocTab._populateTree() + + thePages = [2, 2, 2, 2, 12, 18, 0] + thePage = [1, 3, 5, 7, 9, 21, 39] + for i in range(7): + assert tocTree.topLevelItem(i).text(tocTab.C_PAGES) == f"{thePages[i]:n}" + assert tocTree.topLevelItem(i).text(tocTab.C_PAGE) == f"{thePage[i]:n}" + + # qtbot.stopForInteraction() + + # Clean Up + projDet._doClose() + nwGUI.closeMain() + monkeypatch.undo() + +# END Test testGuiProjDetails_Dialog