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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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