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