From 95145785546d80d61c6f96d08ebe9cd373d68f5b Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 3 Jun 2023 16:51:45 +0200
Subject: [PATCH] Add the build preview functionality from the old build tool
---
novelwriter/common.py | 27 ++---
novelwriter/tools/manuscript.py | 187 ++++++++++++++++++++++++++++++--
2 files changed, 189 insertions(+), 25 deletions(-)
diff --git a/novelwriter/common.py b/novelwriter/common.py
index 5813ec1e..3ccdd751 100644
--- a/novelwriter/common.py
+++ b/novelwriter/common.py
@@ -22,7 +22,6 @@ 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 .
"""
-
from __future__ import annotations
import json
@@ -31,6 +30,7 @@ import hashlib
import logging
import xml.etree.ElementTree as ET
+from typing import Any
from pathlib import Path
from datetime import datetime
from configparser import ConfigParser
@@ -49,9 +49,8 @@ logger = logging.getLogger(__name__)
# Checker Functions
# =============================================================================================== #
-def checkStringNone(value, default):
- """Check if a variable is a string or a None.
- """
+def checkStringNone(value: Any, default: str | None) -> str | None:
+ """Check if a variable is a string or a None."""
if value is None or value == "None":
return None
if isinstance(value, str):
@@ -59,35 +58,31 @@ def checkStringNone(value, default):
return default
-def checkString(value, default):
- """Check if a variable is a string.
- """
+def checkString(value: Any, default: str) -> str:
+ """Check if a variable is a string."""
if isinstance(value, str):
return str(value)
return default
-def checkInt(value, default):
- """Check if a variable is an integer.
- """
+def checkInt(value: Any, default: int) -> int:
+ """Check if a variable is an integer."""
try:
return int(value)
except Exception:
return default
-def checkFloat(value, default):
- """Check if a variable is a float.
- """
+def checkFloat(value: Any, default: float) -> float:
+ """Check if a variable is a float."""
try:
return float(value)
except Exception:
return default
-def checkBool(value, default):
- """Check if a variable is a boolean.
- """
+def checkBool(value: Any, default: bool) -> bool:
+ """Check if a variable is a boolean."""
if isinstance(value, bool):
return value
elif isinstance(value, str):
diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py
index f8ed9d34..2260650a 100644
--- a/novelwriter/tools/manuscript.py
+++ b/novelwriter/tools/manuscript.py
@@ -28,16 +28,19 @@ import logging
from time import time
from typing import TYPE_CHECKING
+from datetime import datetime
-from PyQt5.QtGui import QCursor
-from PyQt5.QtCore import Qt, pyqtSlot
+from PyQt5.QtGui import QColor, QCursor, QFont, QPalette, QResizeEvent
+from PyQt5.QtCore import QTimer, Qt, pyqtSlot
from PyQt5.QtWidgets import (
- QDialog, QHBoxLayout, QListWidget, QListWidgetItem, QMenu, QProgressBar,
- QPushButton, QSplitter, QTextBrowser, QVBoxLayout, QWidget, qApp
+ QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMenu,
+ QProgressBar, QPushButton, QSplitter, QTextBrowser, QVBoxLayout, QWidget,
+ qApp
)
from novelwriter import CONFIG
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.buildsettings import BuildCollection, BuildSettings
@@ -178,6 +181,21 @@ class GuiManuscript(QDialog):
"""Load dialog content from project data."""
self._builds.loadCollection()
self._updateBuildsList()
+
+ logger.debug("Loading build cache")
+ cache = CONFIG.dataPath("cache") / f"build_{self.theProject.data.uuid}.json"
+ if cache.is_file():
+ try:
+ with open(cache, mode="r", encoding="utf-8") as fObj:
+ data = json.load(fObj)
+ build = self._builds.getBuild(data.get("uuid", ""))
+ if isinstance(build, BuildSettings):
+ self._updatePreview(data, build)
+ except Exception:
+ logger.error("Failed to save build cache")
+ logException()
+ return
+
return
##
@@ -245,13 +263,16 @@ class GuiManuscript(QDialog):
buildObj = docBuild.lastBuild
assert isinstance(buildObj, ToHtml)
result = {
+ "uuid": build.buildID,
"time": int(time()),
- "style": buildObj.getStyleSheet(),
+ "styles": buildObj.getStyleSheet(),
"html": buildObj.fullHTML,
}
+ self._updatePreview(result, build)
+
logger.debug("Saving build cache")
- cache = CONFIG.dataPath("cache") / f"build_{build.buildID}.json"
+ cache = CONFIG.dataPath("cache") / f"build_{self.theProject.data.uuid}.json"
try:
with open(cache, mode="w+", encoding="utf-8") as outFile:
outFile.write(json.dumps(result, indent=2))
@@ -260,8 +281,6 @@ class GuiManuscript(QDialog):
logException()
return
- self.manPreview.setContent(result)
-
return
@pyqtSlot()
@@ -274,6 +293,19 @@ class GuiManuscript(QDialog):
# Internal Functions
##
+ def _updatePreview(self, data: dict, build: BuildSettings):
+ """Update the preview widget and set relevant values."""
+ self.manPreview.setContent(data)
+ self.manPreview.setBuildName(build.name)
+ self.manPreview.setTextFont(
+ build.getStr("format.textFont"),
+ build.getInt("format.textSize")
+ )
+ self.manPreview.setJustify(
+ build.getBool("format.justifyText")
+ )
+ return
+
def _getSelectedBuild(self) -> BuildSettings | None:
"""Get the currently selected build."""
bItems = self.buildList.selectedItems()
@@ -350,26 +382,163 @@ class _PreviewWidget(QTextBrowser):
self.mainTheme = mainGui.mainTheme
self.theProject = mainGui.theProject
+ self._docTime = 0
+ self._buildName = ""
+
+ # Document Setup
+ dPalette = self.palette()
+ dPalette.setColor(QPalette.Base, QColor(255, 255, 255))
+ dPalette.setColor(QPalette.Text, QColor(0, 0, 0))
+ self.setPalette(dPalette)
+
+ self.setMinimumWidth(40*self.mainGui.mainTheme.textNWidth)
+ self.setTextFont(CONFIG.textFont, CONFIG.textSize)
+ self.setTabStopDistance(CONFIG.getTabWidth())
+ self.setOpenExternalLinks(False)
+
+ self.document().setDocumentMargin(CONFIG.getTextMargin())
+ self.setPlaceholderText(self.tr(
+ "Press the \"Build Preview\" button to generate ..."
+ ))
+
+ # Document Age
+ aPalette = self.palette()
+ aPalette.setColor(QPalette.Background, aPalette.toolTipBase().color())
+ aPalette.setColor(QPalette.Foreground, aPalette.toolTipText().color())
+
+ aFont = self.font()
+ aFont.setPointSizeF(0.9*self.mainTheme.fontPointSize)
+
+ self.ageLabel = QLabel("", self)
+ self.ageLabel.setIndent(0)
+ self.ageLabel.setFont(aFont)
+ self.ageLabel.setPalette(aPalette)
+ self.ageLabel.setAutoFillBackground(True)
+ self.ageLabel.setAlignment(Qt.AlignCenter)
+ self.ageLabel.setFixedHeight(int(2.1*self.mainTheme.fontPixelSize))
+
+ self._updateDocMargins()
+ self._updateBuildAge()
+
+ # Age Timer
+ self.ageTimer = QTimer()
+ self.ageTimer.setInterval(10)
+ self.ageTimer.timeout.connect(self._updateBuildAge)
+ self.ageTimer.start()
+
return
+ ##
+ # Setters
+ ##
+
+ def setBuildName(self, name: str):
+ """Set the build name for the document label."""
+ self._buildName = name
+ self._updateBuildAge()
+ return
+
+ def setJustify(self, state: bool):
+ """Enable/disable the justify text option."""
+ options = self.document().defaultTextOption()
+ if state:
+ options.setAlignment(Qt.AlignJustify)
+ else:
+ options.setAlignment(Qt.AlignAbsolute)
+ self.document().setDefaultTextOption(options)
+ return
+
+ def setTextFont(self, family: str, size: int):
+ """Set the text font properties."""
+ font = QFont()
+ font.setFamily(family)
+ font.setPointSize(size)
+ self.setFont(font)
+ return
+
+ ##
+ # Methods
+ ##
+
def setContent(self, data: dict):
"""Set the content of the preview widget."""
sPos = self.verticalScrollBar().value()
qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
+ styles = "\n".join(data.get("styles", [
+ "h1, h2 {color: rgb(66, 113, 174);}",
+ "h3, h4 {color: rgb(50, 50, 50);}",
+ "a {color: rgb(66, 113, 174);}",
+ ".tags {color: rgb(245, 135, 31); font-weight: bold;}",
+ ]))
+ self.document().setDefaultStyleSheet(styles)
+
html = "".join(data.get("html", []))
html = html.replace("\t", "!!tab!!")
html = html.replace("", "")
html = html.replace("", "")
self.setHtml(html)
qApp.processEvents()
-
while self.find("!!tab!!"):
theCursor = self.textCursor()
theCursor.insertText("\t")
self.verticalScrollBar().setValue(sPos)
+ self._docTime = checkInt(data.get("time"), 0)
+ self._updateBuildAge()
+
+ # Since we change the content while it may still be rendering, we mark
+ # the document dirty again to make sure it's re-rendered properly.
+ self.document().markContentsDirty(0, self.document().characterCount())
+ qApp.restoreOverrideCursor()
return
+ ##
+ # Events
+ ##
+
+ def resizeEvent(self, event: QResizeEvent):
+ """Capture resize and update the document margins."""
+ super().resizeEvent(event)
+ self._updateDocMargins()
+ return
+
+ ##
+ # Private Slots
+ ##
+
+ @pyqtSlot()
+ def _updateBuildAge(self):
+ """Update the build time and the fuzzy age."""
+ if self._docTime > 0:
+ strBuildTime = "%s (%s)" % (
+ datetime.fromtimestamp(self._docTime).strftime("%x %X"),
+ fuzzyTime(time() - self._docTime)
+ )
+ else:
+ strBuildTime = self.tr("Unknown")
+ text = "{0} {1}".format(self.tr("Built"), strBuildTime)
+ if self._buildName:
+ text = "{0}
{1}".format(self._buildName, text)
+ self.ageLabel.setText(text)
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _updateDocMargins(self):
+ """Automatically adjust the header to fill the top of the
+ document within the viewport.
+ """
+ vBar = self.verticalScrollBar()
+ sW = vBar.width() if vBar.isVisible() else 0
+ tB = self.frameWidth()
+ tW = self.width() - 2*tB - sW
+ tH = self.ageLabel.height()
+ self.ageLabel.setGeometry(tB, tB, tW, tH)
+ self.setViewportMargins(0, tH, 0, 0)
+ return
+
# END Class _PreviewWidget