Display build details in Manuscript tool (#1516)

This commit is contained in:
Veronica Berglyd Olsen
2023-09-01 18:50:55 +01:00
committed by GitHub
6 changed files with 323 additions and 49 deletions
+44 -16
View File
@@ -1986,53 +1986,53 @@
<context>
<name>GuiManuscript</name>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="80" />
<location filename="../novelwriter/tools/manuscript.py" line="82" />
<source>Build Manuscript</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="110" />
<location filename="../novelwriter/tools/manuscript.py" line="112" />
<source>Add New Build</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="117" />
<location filename="../novelwriter/tools/manuscript.py" line="119" />
<source>Delete Selected Build</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="124" />
<location filename="../novelwriter/tools/manuscript.py" line="126" />
<source>Edit Selected Build</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="128" />
<location filename="../novelwriter/tools/manuscript.py" line="130" />
<source>Builds</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="145" />
<location filename="../novelwriter/tools/manuscript.py" line="164" />
<source>Preview</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="148" />
<location filename="../novelwriter/tools/manuscript.py" line="167" />
<source>Print</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="151" />
<location filename="../novelwriter/tools/manuscript.py" line="170" />
<source>Build</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="154" />
<location filename="../novelwriter/tools/manuscript.py" line="173" />
<source>Close</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="254" />
<location filename="../novelwriter/tools/manuscript.py" line="207" />
<location filename="../novelwriter/tools/manuscript.py" line="278" />
<location filename="../novelwriter/tools/manuscript.py" line="226" />
<source>My Manuscript</source>
<translation type="unfinished" />
</message>
@@ -4487,6 +4487,34 @@
<translation type="unfinished" />
</message>
</context>
<context>
<name>_DetailsWidget</name>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="504" />
<source>Setting</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="504" />
<source>Value</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="573" />
<source>Name</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="579" />
<source>Selection</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="595" />
<source>Title</source>
<translation type="unfinished" />
</message>
</context>
<context>
<name>_FilterTab</name>
<message>
@@ -4625,27 +4653,27 @@
<context>
<name>_PreviewWidget</name>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="467" />
<location filename="../novelwriter/tools/manuscript.py" line="659" />
<source>Press the "Build Preview" button to generate ...</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="565" />
<location filename="../novelwriter/tools/manuscript.py" line="757" />
<source>Processing ...</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="594" />
<location filename="../novelwriter/tools/manuscript.py" line="786" />
<source>Done</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="637" />
<location filename="../novelwriter/tools/manuscript.py" line="829" />
<source>Unknown</source>
<translation type="unfinished" />
</message>
<message>
<location filename="../novelwriter/tools/manuscript.py" line="638" />
<location filename="../novelwriter/tools/manuscript.py" line="830" />
<source>Built</source>
<translation type="unfinished" />
</message>
+50 -5
View File
@@ -319,7 +319,6 @@ class BuildSettings:
value = min(max(value, definition[2]), definition[3])
self._changed = value != self._settings[key]
self._settings[key] = value
logger.debug(f"Build Setting '{key}' set to: {value}")
return True
##
@@ -457,6 +456,8 @@ class BuildCollection:
def __init__(self, project: NWProject) -> None:
self._project = project
self._builds = {}
self._lastBuild = ""
self._defaultBuild = ""
self._loadCollection()
return
@@ -465,7 +466,21 @@ class BuildCollection:
return len(self._builds)
##
# Methods
# Properties
##
@property
def lastBuild(self) -> str:
"""Return the last active build."""
return self._lastBuild
@property
def defaultBuild(self) -> str:
"""Return the default build."""
return self._defaultBuild
##
# Getters
##
def getBuild(self, buildID: str) -> BuildSettings | None:
@@ -476,6 +491,24 @@ class BuildCollection:
build.unpack(self._builds[buildID])
return build
##
# Setters
##
def setLastBuild(self, buildID: str) -> None:
"""Set the last active build id."""
if buildID != self._lastBuild:
self._lastBuild = buildID
self._saveCollection()
return
def setDefaultBuild(self, buildID: str) -> None:
"""Set the default build id."""
if buildID != self._defaultBuild:
self._defaultBuild = buildID
self._saveCollection()
return
def setBuild(self, build: BuildSettings) -> None:
"""Set build settings data in the collection."""
if isinstance(build, BuildSettings):
@@ -484,6 +517,10 @@ class BuildCollection:
self._saveCollection()
return
##
# Methods
##
def removeBuild(self, buildID: str) -> None:
"""Remove the a build from the collection."""
self._builds.pop(buildID, None)
@@ -527,7 +564,11 @@ class BuildCollection:
return False
for key, entry in builds.items():
if isinstance(entry, dict):
if key == "lastBuild":
self._lastBuild = str(entry)
elif key == "defaultBuild":
self._defaultBuild = str(entry)
elif isinstance(entry, dict):
self._builds[key] = entry
return True
@@ -540,9 +581,13 @@ class BuildCollection:
logger.debug("Saving builds file")
try:
data = {"novelWriter.builds": self._builds}
data = {
"lastBuild": self._lastBuild,
"defaultBuild": self._defaultBuild,
}
data.update(self._builds)
with open(buildsFile, mode="w+", encoding="utf-8") as outFile:
outFile.write(jsonEncode(data, nmax=4))
outFile.write(jsonEncode({"novelWriter.builds": data}, nmax=4))
except Exception:
logger.error("Failed to save builds file")
logException()
+4 -3
View File
@@ -58,13 +58,14 @@ VALID_MAP = {
"GuiWordList": {"winWidth", "winHeight"},
"GuiNovelView": {"lastCol", "lastColSize"},
"GuiBuildSettings": {
"winWidth", "winHeight", "treeWidth", "filterWidth"
"winWidth", "winHeight", "treeWidth", "filterWidth",
},
"GuiManuscript": {
"winWidth", "winHeight", "optsWidth", "viewWidth"
"winWidth", "winHeight", "optsWidth", "viewWidth", "listHeight",
"detailsHeight", "detailsWidth", "detailsExpanded",
},
"GuiManuscriptBuild": {
"winWidth", "winHeight", "fmtWidth", "sumWidth"
"winWidth", "winHeight", "fmtWidth", "sumWidth",
},
}
+1 -1
View File
@@ -600,7 +600,7 @@ class NWProject:
self._langData = json.load(inFile)
logger.debug("Loaded project language file: %s", langFile.name)
except Exception:
logger.error("Failed to project language file")
logger.error("Failed to load project language file")
logException()
return False
+211 -21
View File
@@ -33,8 +33,9 @@ from datetime import datetime
from PyQt5.QtGui import QCloseEvent, QColor, QCursor, QFont, QPalette, QResizeEvent
from PyQt5.QtCore import QSize, QTimer, Qt, pyqtSlot
from PyQt5.QtWidgets import (
QDialog, QGridLayout, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton,
QSplitter, QTextBrowser, QToolButton, QVBoxLayout, QWidget, qApp
QAbstractItemView, QDialog, QGridLayout, QHBoxLayout, QLabel, QListWidget,
QListWidgetItem, QPushButton, QSplitter, QTextBrowser, QToolButton,
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, qApp
)
from PyQt5.QtPrintSupport import QPrintPreviewDialog, QPrinter
@@ -43,6 +44,7 @@ from novelwriter.error import logException
from novelwriter.common import checkInt, fuzzyTime
from novelwriter.core.tohtml import ToHtml
from novelwriter.core.docbuild import NWBuildDocument
from novelwriter.core.tokenizer import HeadingFormatter
from novelwriter.core.buildsettings import BuildCollection, BuildSettings
from novelwriter.tools.manusbuild import GuiManuscriptBuild
from novelwriter.tools.manussettings import GuiBuildSettings
@@ -127,10 +129,6 @@ class GuiManuscript(QDialog):
self.lblBuilds = QLabel("<b>{0}</b>".format(self.tr("Builds")))
self.buildList = QListWidget()
self.buildList.setIconSize(QSize(iPx, iPx))
self.buildList.doubleClicked.connect(self._editSelectedBuild)
self.listToolBox = QHBoxLayout()
self.listToolBox.addWidget(self.lblBuilds)
self.listToolBox.addStretch(1)
@@ -139,6 +137,27 @@ class GuiManuscript(QDialog):
self.listToolBox.addWidget(self.tbEdit)
self.listToolBox.setSpacing(0)
# Builds
# ======
self.buildList = QListWidget()
self.buildList.setIconSize(QSize(iPx, iPx))
self.buildList.doubleClicked.connect(self._editSelectedBuild)
self.buildList.currentItemChanged.connect(self._updateBuildDetails)
self.buildDetails = _DetailsWidget(self)
self.buildDetails.setColumnWidth(
CONFIG.pxInt(pOptions.getInt("GuiManuscript", "detailsWidth", 100)),
)
self.buildSplit = QSplitter(Qt.Orientation.Vertical, self)
self.buildSplit.addWidget(self.buildList)
self.buildSplit.addWidget(self.buildDetails)
self.buildSplit.setSizes([
CONFIG.pxInt(pOptions.getInt("GuiManuscript", "listHeight", 50)),
CONFIG.pxInt(pOptions.getInt("GuiManuscript", "detailsHeight", 50)),
])
# Process Controls
# ================
@@ -167,7 +186,7 @@ class GuiManuscript(QDialog):
self.controlBox = QVBoxLayout()
self.controlBox.addLayout(self.listToolBox, 0)
self.controlBox.addWidget(self.buildList, 1)
self.controlBox.addWidget(self.buildSplit, 1)
self.controlBox.addLayout(self.processBox, 0)
self.controlBox.setContentsMargins(0, 0, 0, 0)
@@ -206,8 +225,13 @@ class GuiManuscript(QDialog):
build = BuildSettings()
build.setName(self.tr("My Manuscript"))
self._builds.setBuild(build)
selected = build.buildID
else:
selected = self._builds.lastBuild
self._updateBuildsList()
if selected in self._buildMap:
self.buildList.setCurrentItem(self._buildMap[selected])
logger.debug("Loading build cache")
cache = CONFIG.dataPath("cache") / f"build_{SHARED.project.data.uuid}.json"
@@ -263,6 +287,15 @@ class GuiManuscript(QDialog):
self._openSettingsDialog(build)
return
@pyqtSlot("QListWidgetItem*", "QListWidgetItem*")
def _updateBuildDetails(self, current: QListWidgetItem, previous: QListWidgetItem) -> None:
"""Process change of build selection to update the details."""
if isinstance(current, QListWidgetItem):
build = self._builds.getBuild(current.data(self.D_KEY))
if build is not None:
self.buildDetails.updateInfo(build)
return
@pyqtSlot()
def _deleteSelectedBuild(self):
"""Delete the currently selected build settings entry."""
@@ -278,6 +311,9 @@ class GuiManuscript(QDialog):
"""Process new build settings from the settings dialog."""
self._builds.setBuild(build)
self._updateBuildItem(build)
current = self.buildList.currentItem()
if isinstance(current, QListWidgetItem) and current.data(self.D_KEY) == build.buildID:
self._updateBuildDetails(current, current)
return
@pyqtSlot()
@@ -363,8 +399,6 @@ class GuiManuscript(QDialog):
self.docPreview.setJustify(
build.getBool("format.justifyText")
)
if build.buildID and build.buildID in self._buildMap:
self._buildMap[build.buildID].setSelected(True)
return
def _getSelectedBuild(self) -> BuildSettings | None:
@@ -383,6 +417,10 @@ class GuiManuscript(QDialog):
"""Save the user GUI settings."""
logger.debug("Saving GuiManuscript settings")
current = self.buildList.currentItem()
if isinstance(current, QListWidgetItem):
self._builds.setLastBuild(current.data(self.D_KEY))
winWidth = CONFIG.rpxInt(self.width())
winHeight = CONFIG.rpxInt(self.height())
@@ -390,11 +428,21 @@ class GuiManuscript(QDialog):
optsWidth = CONFIG.rpxInt(mainSplit[0])
viewWidth = CONFIG.rpxInt(mainSplit[1])
buildSplit = self.buildSplit.sizes()
listHeight = CONFIG.rpxInt(buildSplit[0])
detailsHeight = CONFIG.rpxInt(buildSplit[1])
detailsWidth = CONFIG.rpxInt(self.buildDetails.getColumnWidth())
detailsExpanded = self.buildDetails.getExpandedState()
pOptions = SHARED.project.options
pOptions.setValue("GuiManuscript", "winWidth", winWidth)
pOptions.setValue("GuiManuscript", "winHeight", winHeight)
pOptions.setValue("GuiManuscript", "optsWidth", optsWidth)
pOptions.setValue("GuiManuscript", "viewWidth", viewWidth)
pOptions.setValue("GuiManuscript", "listHeight", listHeight)
pOptions.setValue("GuiManuscript", "detailsHeight", detailsHeight)
pOptions.setValue("GuiManuscript", "detailsWidth", detailsWidth)
pOptions.setValue("GuiManuscript", "detailsExpanded", detailsExpanded)
pOptions.saveSettings()
return
@@ -444,9 +492,151 @@ class GuiManuscript(QDialog):
# END Class GuiManuscript
class _DetailsWidget(QWidget):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
self._initExpanded = True
# Tree Vidget
self.listView = QTreeWidget(self)
self.listView.setHeaderLabels([self.tr("Setting"), self.tr("Value")])
self.listView.setIndentation(SHARED.theme.baseIconSize)
self.listView.setSelectionMode(QAbstractItemView.NoSelection)
# Assemble
self.outerBox = QVBoxLayout()
self.outerBox.addWidget(self.listView)
self.outerBox.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.outerBox)
return
##
# Getters
##
def getColumnWidth(self) -> int:
"""Get the width of the first column."""
return self.listView.columnWidth(0)
def getExpandedState(self) -> list[bool]:
"""Get the expanded state of each top level item."""
state = []
for i in range(self.listView.topLevelItemCount()):
item = self.listView.topLevelItem(i)
if isinstance(item, QTreeWidgetItem):
state.append(item.isExpanded())
return state
##
# Setters
##
def setColumnWidth(self, value: int) -> None:
"""Set the width of the first column."""
self.listView.setColumnWidth(0, value)
return
def setExpandedState(self, state: list[bool]) -> None:
"""Set the expanded state of each top level item."""
count = len(state)
for i in range(self.listView.topLevelItemCount()):
item = self.listView.topLevelItem(i)
if isinstance(item, QTreeWidgetItem):
item.setExpanded((state[i] if i < count else True) and item.childCount() > 0)
return
##
# Methods
##
def updateInfo(self, build: BuildSettings) -> None:
"""Load the build settings info into the table."""
if self._initExpanded:
previous = SHARED.project.options.getValue("GuiManuscript", "detailsExpanded", [])
expanded = [bool(s) for s in previous]
self._initExpanded = False
else:
expanded = self.getExpandedState()
self.listView.clear()
on = SHARED.theme.getIcon("bullet-on")
off = SHARED.theme.getIcon("bullet-off")
# Name
item = QTreeWidgetItem()
item.setText(0, self.tr("Name"))
item.setText(1, build.name)
self.listView.addTopLevelItem(item)
# Selection
item = QTreeWidgetItem()
item.setText(0, self.tr("Selection"))
item.setText(1, "")
self.listView.addTopLevelItem(item)
for tHandle, nwItem in SHARED.project.tree.iterRoots(None):
if not nwItem.isInactiveClass():
sub = QTreeWidgetItem()
sub.setText(0, nwItem.itemName)
sub.setIcon(1, on if build.isRootAllowed(tHandle) else off)
item.addChild(sub)
# Headings
hFmt = HeadingFormatter(SHARED.project)
hFmt.incChapter()
hFmt.incScene()
hFmt.resetScene()
hFmt.incScene()
title = self.tr("Title")
item = QTreeWidgetItem()
item.setText(0, build.getLabel("headings"))
item.setText(1, "")
self.listView.addTopLevelItem(item)
entries = [
"headings.fmtTitle", "headings.fmtChapter", "headings.fmtUnnumbered",
"headings.fmtScene", "headings.fmtSection"
]
for key in entries:
sub = QTreeWidgetItem()
sub.setText(0, build.getLabel(key))
sub.setText(1, hFmt.apply(build.getStr(key), title))
item.addChild(sub)
for key in ["headings.hideScene", "headings.hideSection"]:
sub = QTreeWidgetItem()
sub.setText(0, build.getLabel(key))
sub.setIcon(1, on if build.getBool(key) else off)
item.addChild(sub)
# Text Content
item = QTreeWidgetItem()
item.setText(0, build.getLabel("text.grpContent"))
item.setText(1, "")
self.listView.addTopLevelItem(item)
entries = [
"text.includeSynopsis", "text.includeComments",
"text.includeKeywords", "text.includeBodyText",
]
for key in entries:
sub = QTreeWidgetItem()
sub.setText(0, build.getLabel(key))
sub.setIcon(1, on if build.getBool(key) else off)
item.addChild(sub)
# Restore expanded state
self.setExpandedState(expanded)
return
# END Class _DetailsWidget
class _PreviewWidget(QTextBrowser):
def __init__(self, parent: QWidget):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
self._docTime = 0
@@ -512,13 +702,13 @@ class _PreviewWidget(QTextBrowser):
# Setters
##
def setBuildName(self, name: str):
def setBuildName(self, name: str) -> None:
"""Set the build name for the document label."""
self._buildName = name
self._updateBuildAge()
return
def setJustify(self, state: bool):
def setJustify(self, state: bool) -> None:
"""Enable/disable the justify text option."""
pOptions = self.document().defaultTextOption()
if state:
@@ -528,7 +718,7 @@ class _PreviewWidget(QTextBrowser):
self.document().setDefaultTextOption(pOptions)
return
def setTextFont(self, family: str, size: int):
def setTextFont(self, family: str, size: int) -> None:
"""Set the text font properties."""
if family:
font = QFont()
@@ -541,7 +731,7 @@ class _PreviewWidget(QTextBrowser):
# Methods
##
def beginNewBuild(self, length: int):
def beginNewBuild(self, length: int) -> None:
"""Clear the document and show the progress bar."""
self.buildProgress.setMaximum(length)
self.buildProgress.setValue(0)
@@ -551,13 +741,13 @@ class _PreviewWidget(QTextBrowser):
self.clear()
return
def buildStep(self, value: int):
def buildStep(self, value: int) -> None:
"""Update the progress bar value."""
self.buildProgress.setValue(value)
qApp.processEvents()
return
def setContent(self, data: dict):
def setContent(self, data: dict) -> None:
"""Set the content of the preview widget."""
sPos = self.verticalScrollBar().value()
qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
@@ -602,7 +792,7 @@ class _PreviewWidget(QTextBrowser):
# Events
##
def resizeEvent(self, event: QResizeEvent):
def resizeEvent(self, event: QResizeEvent) -> None:
"""Capture resize and update the document margins."""
super().resizeEvent(event)
self._updateDocMargins()
@@ -613,7 +803,7 @@ class _PreviewWidget(QTextBrowser):
##
@pyqtSlot("QPrinter*")
def printPreview(self, printer: QPrinter):
def printPreview(self, printer: QPrinter) -> None:
"""Connect the print preview painter to the document viewer."""
qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
printer.setOrientation(QPrinter.Portrait)
@@ -626,7 +816,7 @@ class _PreviewWidget(QTextBrowser):
##
@pyqtSlot()
def _updateBuildAge(self):
def _updateBuildAge(self) -> None:
"""Update the build time and the fuzzy age."""
if self._docTime > 0:
strBuildTime = "%s (%s)" % (
@@ -642,7 +832,7 @@ class _PreviewWidget(QTextBrowser):
return
@pyqtSlot()
def _hideProgress(self):
def _hideProgress(self) -> None:
"""Clean up the build progress bar."""
self.buildProgress.setVisible(False)
return
@@ -651,7 +841,7 @@ class _PreviewWidget(QTextBrowser):
# Internal Functions
##
def _updateDocMargins(self):
def _updateDocMargins(self) -> None:
"""Automatically adjust the header to fill the top of the
document within the viewport.
"""
+13 -3
View File
@@ -377,6 +377,8 @@ def testCoreBuildSettings_Collection(monkeypatch, mockGUI, fncPath: Path, mockRn
builds = BuildCollection(project)
assert len(builds) == 0
assert not buildsFile.exists()
assert builds.lastBuild == ""
assert builds.defaultBuild == ""
# Create a default build
buildOne = BuildSettings()
@@ -412,7 +414,9 @@ def testCoreBuildSettings_Collection(monkeypatch, mockGUI, fncPath: Path, mockRn
# Check the file content
data = json.loads(buildsFile.read_text(encoding="utf-8"))
assert list(data["novelWriter.builds"].keys()) == [buildIDOne, buildIDTwo]
assert list(data["novelWriter.builds"].keys()) == [
"lastBuild", "defaultBuild", buildIDOne, buildIDTwo
]
# Remove a build
builds.removeBuild(buildIDOne)
@@ -423,12 +427,16 @@ def testCoreBuildSettings_Collection(monkeypatch, mockGUI, fncPath: Path, mockRn
# Check the file content
data = json.loads(buildsFile.read_text(encoding="utf-8"))
assert list(data["novelWriter.builds"].keys()) == [buildIDTwo]
assert list(data["novelWriter.builds"].keys()) == [
"lastBuild", "defaultBuild", buildIDTwo
]
builds.setBuild(buildOne)
assert list(builds.builds()) == [
(buildIDTwo, "Build Two"),
(buildIDOne, "Build One"),
]
builds.setLastBuild(buildIDOne)
builds.setDefaultBuild(buildIDTwo)
# Check errors: No valid path
with monkeypatch.context() as mp:
@@ -447,7 +455,7 @@ def testCoreBuildSettings_Collection(monkeypatch, mockGUI, fncPath: Path, mockRn
buildsFile.write_text("foobar")
assert builds._loadCollection() is False
# Check errors: Valid jason file, but list instead of object
# Check errors: Valid json file, but list instead of object
buildsFile.write_text("[]")
assert builds._loadCollection() is False
buildsFile.unlink()
@@ -459,5 +467,7 @@ def testCoreBuildSettings_Collection(monkeypatch, mockGUI, fncPath: Path, mockRn
(buildIDTwo, "Build Two"),
(buildIDOne, "Build One"),
]
assert another.lastBuild == buildIDOne
assert another.defaultBuild == buildIDTwo
# END Test testCoreBuildSettings_Collection