diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 316c2541..b213acb2 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -31,7 +31,7 @@ from typing import TYPE_CHECKING from datetime import datetime from PyQt5.QtGui import QCloseEvent, QColor, QCursor, QFont, QPalette, QResizeEvent -from PyQt5.QtCore import QSize, QTimer, Qt, pyqtSlot +from PyQt5.QtCore import QSize, QTimer, QUrl, Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QAbstractItemView, QDialog, QFormLayout, QGridLayout, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy, QSplitter, @@ -58,6 +58,14 @@ if TYPE_CHECKING: # pragma: no cover logger = logging.getLogger(__name__) +OUTLINE_MAP = { + "TT": 0, + "H1": 1, + "H2": 2, + "H3": 3, + "H4": 4, +} + class GuiManuscript(QDialog): """GUI Tools: Manuscript Tool @@ -151,8 +159,11 @@ class GuiManuscript(QDialog): CONFIG.pxInt(pOptions.getInt("GuiManuscript", "detailsWidth", 100)), ) + self.buildOutline = _OutlineWidget(self) + self.detailsTabs = QTabWidget(self) self.detailsTabs.addTab(self.buildDetails, self.tr("Build")) + self.detailsTabs.addTab(self.buildOutline, self.tr("Outline")) self.detailsTabs.setStyleSheet(SHARED.theme.getStyleSheet(STYLES_FLAT_TABS)) self.buildSplit = QSplitter(Qt.Orientation.Vertical, self) @@ -225,6 +236,9 @@ class GuiManuscript(QDialog): self.setLayout(self.outerBox) self.setSizeGripEnabled(True) + # Signals + self.buildOutline.outlineEntryClicked.connect(self.docPreview.navigateTo) + logger.debug("Ready: GuiManuscript") return @@ -354,6 +368,7 @@ class GuiManuscript(QDialog): "uuid": build.buildID, "time": int(time()), "stats": buildObj.textStats, + "outline": buildObj.navigationMap, "styles": buildObj.getStyleSheet(), "html": buildObj.fullHTML, } @@ -410,6 +425,7 @@ class GuiManuscript(QDialog): build.getBool("format.justifyText") ) self.docStats.updateStats(data.get("stats", {})) + self.buildOutline.updateOutline(data.get("outline", {})) return def _getSelectedBuild(self) -> BuildSettings | None: @@ -519,7 +535,7 @@ class _DetailsWidget(QWidget): self.listView = QTreeWidget(self) self.listView.setHeaderLabels([self.tr("Setting"), self.tr("Value")]) self.listView.setIndentation(SHARED.theme.baseIconSize) - self.listView.setSelectionMode(QAbstractItemView.NoSelection) + self.listView.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) # Assemble self.outerBox = QVBoxLayout() @@ -651,6 +667,80 @@ class _DetailsWidget(QWidget): # END Class _DetailsWidget +class _OutlineWidget(QWidget): + + D_LINE = Qt.ItemDataRole.UserRole + + outlineEntryClicked = pyqtSignal(str) + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + self._outline = {} + + # Tree Widget + self.listView = QTreeWidget(self) + self.listView.setHeaderHidden(True) + self.listView.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.listView.itemClicked.connect(self._onItemClick) + + # Assemble + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.listView) + self.outerBox.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.outerBox) + + return + + def updateOutline(self, data: dict[str, str]) -> None: + """Update the outline.""" + if isinstance(data, dict) and data != self._outline: + self.listView.clear() + + tFont = self.font() + tFont.setBold(True) + tBrush = self.palette().highlight() + + hFont = self.font() + hFont.setBold(True) + hFont.setUnderline(True) + + root = self.listView.invisibleRootItem() + parent = root + for anchor, text in data.items(): + level = OUTLINE_MAP.get(text[:2], -1) + text = text[3:] + if 0 <= level < 4: + item = QTreeWidgetItem([text]) + item.setData(0, self.D_LINE, anchor) + if level == 0: + item.setFont(0, tFont) + item.setForeground(0, tBrush) + elif level == 1: + item.setFont(0, hFont) + + if level < 3: + root.addChild(item) + parent = item + elif parent: + parent.addChild(item) + + self._outline = data + + return + + ## + # Private Slots + ## + + def _onItemClick(self, item: QTreeWidgetItem) -> None: + """Process tree item click.""" + self.outlineEntryClicked.emit(str(item.data(0, self.D_LINE))) + return + +# END Class _OutlineWidget + + class _PreviewWidget(QTextBrowser): def __init__(self, parent: QWidget) -> None: @@ -690,7 +780,7 @@ class _PreviewWidget(QTextBrowser): self.ageLabel.setFont(aFont) self.ageLabel.setPalette(aPalette) self.ageLabel.setAutoFillBackground(True) - self.ageLabel.setAlignment(Qt.AlignCenter) + self.ageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.ageLabel.setFixedHeight(int(2.1*SHARED.theme.fontPixelSize)) # Progress @@ -708,7 +798,7 @@ class _PreviewWidget(QTextBrowser): self._updateBuildAge() # Age Timer - self.ageTimer = QTimer() + self.ageTimer = QTimer(self) self.ageTimer.setInterval(10) self.ageTimer.timeout.connect(self._updateBuildAge) self.ageTimer.start() @@ -729,9 +819,9 @@ class _PreviewWidget(QTextBrowser): """Enable/disable the justify text option.""" pOptions = self.document().defaultTextOption() if state: - pOptions.setAlignment(Qt.AlignJustify) + pOptions.setAlignment(Qt.AlignmentFlag.AlignJustify) else: - pOptions.setAlignment(Qt.AlignAbsolute) + pOptions.setAlignment(Qt.AlignmentFlag.AlignAbsolute) self.document().setDefaultTextOption(pOptions) return @@ -767,7 +857,7 @@ class _PreviewWidget(QTextBrowser): def setContent(self, data: dict) -> None: """Set the content of the preview widget.""" sPos = self.verticalScrollBar().value() - qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) + qApp.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) self.buildProgress.setCentreText(self.tr("Processing ...")) qApp.processEvents() @@ -777,8 +867,6 @@ class _PreviewWidget(QTextBrowser): 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!!"): @@ -823,6 +911,13 @@ class _PreviewWidget(QTextBrowser): qApp.restoreOverrideCursor() return + @pyqtSlot(str) + def navigateTo(self, anchor: str) -> None: + """Go to a specific #link in the document.""" + logger.debug("Moving to anchor '#%s'", anchor) + self.setSource(QUrl(f"#{anchor}")) + return + ## # Private Slots ##