From 5453d2ada1e1112db1e3b23cf7d7491c74274794 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 17 Mar 2024 12:26:25 +0100 Subject: [PATCH 1/9] Add document outline generator for editor --- novelwriter/gui/doceditor.py | 58 +++++++++++++++++++------------ novelwriter/gui/dochighlight.py | 20 +++++------ novelwriter/gui/editordocument.py | 11 +++++- 3 files changed, 55 insertions(+), 34 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 0d3bf0cc..ed386b0f 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -59,7 +59,7 @@ from novelwriter.constants import nwKeyWords, nwLabels, nwShortcode, nwUnicode, from novelwriter.tools.lipsum import GuiLipsum from novelwriter.core.document import NWDocument from novelwriter.text.counting import standardCounter -from novelwriter.gui.dochighlight import GuiDocHighlighter +from novelwriter.gui.dochighlight import BLOCK_META, BLOCK_TITLE from novelwriter.gui.editordocument import GuiTextDocument from novelwriter.extensions.eventfilters import WheelEventFilter @@ -185,18 +185,18 @@ class GuiDocEditor(QPlainTextEdit): self.followTag2.activated.connect(self._processTag) # Set Up Document Word Counter - self.wcTimerDoc = QTimer() - self.wcTimerDoc.timeout.connect(self._runDocCounter) - self.wcTimerDoc.setInterval(5000) + self.timerDoc = QTimer(self) + self.timerDoc.timeout.connect(self._runDocumentTasks) + self.timerDoc.setInterval(5000) self.wCounterDoc = BackgroundWordCounter(self) self.wCounterDoc.setAutoDelete(False) self.wCounterDoc.signals.countsReady.connect(self._updateDocCounts) # Set Up Selection Word Counter - self.wcTimerSel = QTimer() - self.wcTimerSel.timeout.connect(self._runSelCounter) - self.wcTimerSel.setInterval(500) + self.timerSel = QTimer(self) + self.timerSel.timeout.connect(self._runSelCounter) + self.timerSel.setInterval(500) self.wCounterSel = BackgroundWordCounter(self, forSelection=True) self.wCounterSel.setAutoDelete(False) @@ -249,8 +249,8 @@ class GuiDocEditor(QPlainTextEdit): self._nwDocument = None self.setReadOnly(True) self.clear() - self.wcTimerDoc.stop() - self.wcTimerSel.stop() + self.timerDoc.stop() + self.timerSel.stop() self._docHandle = None self._lastEdit = 0.0 @@ -397,8 +397,8 @@ class GuiDocEditor(QPlainTextEdit): self._lastEdit = time() self._lastActive = time() - self._runDocCounter() - self.wcTimerDoc.start() + self._runDocumentTasks() + self.timerDoc.start() self.setReadOnly(False) self.updateDocMargins() @@ -432,7 +432,7 @@ class GuiDocEditor(QPlainTextEdit): def updateTagHighLighting(self) -> None: """Rerun the syntax highlighter on all meta data lines.""" - self._qDocument.syntaxHighlighter.rehighlightByType(GuiDocHighlighter.BLOCK_META) + self._qDocument.syntaxHighlighter.rehighlightByType(BLOCK_META) return def replaceText(self, text: str) -> None: @@ -1033,8 +1033,8 @@ class GuiDocEditor(QPlainTextEdit): if not self._docChanged: self.setDocumentChanged(removed != 0 or added != 0) - if not self.wcTimerDoc.isActive(): - self.wcTimerDoc.start() + if not self.timerDoc.isActive(): + self.timerDoc.start() if (block := self._qDocument.findBlock(pos)).isValid(): text = block.text() @@ -1084,7 +1084,7 @@ class GuiDocEditor(QPlainTextEdit): ctxMenu = QMenu(self) ctxMenu.setObjectName("ContextMenu") - if pBlock.userState() == GuiDocHighlighter.BLOCK_TITLE: + if pBlock.userState() == BLOCK_TITLE: action = ctxMenu.addAction(self.tr("Set as Document Name")) action.triggered.connect(lambda: self._emitRenameItem(pBlock)) @@ -1179,10 +1179,8 @@ class GuiDocEditor(QPlainTextEdit): return @pyqtSlot() - def _runDocCounter(self) -> None: - """Decide whether to run the word counter, or not due to - inactivity. - """ + def _runDocumentTasks(self) -> None: + """Run timer document tasks.""" if self._docHandle is None: return @@ -1193,6 +1191,7 @@ class GuiDocEditor(QPlainTextEdit): if time() - self._lastEdit < 25.0: logger.debug("Running word counter") SHARED.runInThreadPool(self.wCounterDoc) + self._updateOutline() return @@ -1214,10 +1213,10 @@ class GuiDocEditor(QPlainTextEdit): information to the footer, and start the selection word counter. """ if self.textCursor().hasSelection(): - if not self.wcTimerSel.isActive(): - self.wcTimerSel.start() + if not self.timerSel.isActive(): + self.timerSel.start() else: - self.wcTimerSel.stop() + self.timerSel.stop() self.docFooter.updateWordCount(0, False) return @@ -1241,7 +1240,7 @@ class GuiDocEditor(QPlainTextEdit): if self._docHandle and self._nwItem: logger.debug("User selected %d words", wCount) self.docFooter.updateWordCount(wCount, True) - self.wcTimerSel.stop() + self.timerSel.stop() return @pyqtSlot() @@ -1837,6 +1836,13 @@ class GuiDocEditor(QPlainTextEdit): # Internal Functions ## + def _updateOutline(self) -> None: + """Scan the text for headings and update the outline.""" + data = [(b.blockNumber(), b.text()) for b in self._qDocument.iterBlockByType(BLOCK_TITLE)] + logger.debug("Document contains %d heading(s)", len(data)) + self.docHeader.setOutline(data) + return + def _processTag(self, cursor: QTextCursor | None = None, follow: bool = True, create: bool = False) -> nwTrinary: """Activated by Ctrl+Enter. Checks that we're in a block @@ -2802,6 +2808,7 @@ class GuiDocEditHeader(QWidget): self.mainGui = docEditor.mainGui self._docHandle = None + self._docOutline: list[tuple[int, str]] = [] fPx = int(0.9*SHARED.theme.fontPixelSize) mPx = CONFIG.pxInt(8) @@ -2888,6 +2895,11 @@ class GuiDocEditHeader(QWidget): # Methods ## + def setOutline(self, data: list[tuple[int, str]]) -> None: + """Set the document outline dataset.""" + self._docOutline = data + return + def updateTheme(self) -> None: """Update theme elements.""" self.tbButton.setIcon(SHARED.theme.getIcon("menu")) diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index 6dd2322a..6e65ac0d 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -45,16 +45,16 @@ logger = logging.getLogger(__name__) SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—\[\]:]+\b") SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) +BLOCK_NONE = 0 +BLOCK_TEXT = 1 +BLOCK_META = 2 +BLOCK_TITLE = 4 + class GuiDocHighlighter(QSyntaxHighlighter): __slots__ = ("_tItem", "_tHandle", "_spellCheck", "_spellErr", "_hRules", "_hStyles") - BLOCK_NONE = 0 - BLOCK_TEXT = 1 - BLOCK_META = 2 - BLOCK_TITLE = 4 - def __init__(self, document: QTextDocument) -> None: super().__init__(document) @@ -272,12 +272,12 @@ class GuiDocHighlighter(QSyntaxHighlighter): is significantly faster than running the regex checks used for text paragraphs. """ - self.setCurrentBlockState(self.BLOCK_NONE) + self.setCurrentBlockState(BLOCK_NONE) if self._tHandle is None or not text: return if text.startswith("@"): # Keywords and commands - self.setCurrentBlockState(self.BLOCK_META) + self.setCurrentBlockState(BLOCK_META) index = SHARED.project.index isValid, bits, pos = index.scanThis(text) isGood = index.checkThese(bits, self._tHandle) @@ -301,7 +301,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): return elif text.startswith(("# ", "#! ", "## ", "##! ", "### ", "###! ", "#### ")): - self.setCurrentBlockState(self.BLOCK_TITLE) + self.setCurrentBlockState(BLOCK_TITLE) if text.startswith("# "): # Heading 1 self.setFormat(0, 1, self._hStyles["head1h"]) @@ -332,7 +332,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): self.setFormat(4, len(text), self._hStyles["header3"]) elif text.startswith("%"): # Comments - self.setCurrentBlockState(self.BLOCK_TEXT) + self.setCurrentBlockState(BLOCK_TEXT) cStyle, _, cPos = processComment(text) if cStyle == nwComment.PLAIN: self.setFormat(0, len(text), self._hStyles["hidden"]) @@ -357,7 +357,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): return # Regular Text - self.setCurrentBlockState(self.BLOCK_TEXT) + self.setCurrentBlockState(BLOCK_TEXT) for rX, xFmt in self.rxRules: rxItt = rX.globalMatch(text, 0) while rxItt.hasNext(): diff --git a/novelwriter/gui/editordocument.py b/novelwriter/gui/editordocument.py index 537363b7..bfefd027 100644 --- a/novelwriter/gui/editordocument.py +++ b/novelwriter/gui/editordocument.py @@ -23,11 +23,12 @@ along with this program. If not, see . """ from __future__ import annotations +from collections.abc import Generator import logging from time import time -from PyQt5.QtGui import QTextCursor, QTextDocument +from PyQt5.QtGui import QTextBlock, QTextCursor, QTextDocument from PyQt5.QtCore import QObject, pyqtSlot from PyQt5.QtWidgets import QPlainTextDocumentLayout, qApp from novelwriter import SHARED @@ -113,6 +114,14 @@ class GuiTextDocument(QTextDocument): return word, cPos, cLen, SHARED.spelling.suggestWords(word) return "", -1, -1, [] + def iterBlockByType(self, cType: int) -> Generator[QTextBlock]: + """Iterate over all text blocks of a given type.""" + for i in range(self.blockCount()): + block = self.findBlockByNumber(i) + if block.isValid() and block.userState() & cType > 0: + yield block + return None + ## # Public Slots ## From ca3337914c250dc29f041a16db6e48acd5fd9dc0 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 17 Mar 2024 13:04:54 +0100 Subject: [PATCH 2/9] Complete the editor outline feature and update tests --- novelwriter/gui/doceditor.py | 56 ++++++++++++++++++++++------ tests/test_gui/test_gui_doceditor.py | 16 +++++--- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index ed386b0f..eb6dec95 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -1838,9 +1838,10 @@ class GuiDocEditor(QPlainTextEdit): def _updateOutline(self) -> None: """Scan the text for headings and update the outline.""" - data = [(b.blockNumber(), b.text()) for b in self._qDocument.iterBlockByType(BLOCK_TITLE)] - logger.debug("Document contains %d heading(s)", len(data)) - self.docHeader.setOutline(data) + self.docHeader.setOutline({ + block.blockNumber(): block.text() + for block in self._qDocument.iterBlockByType(BLOCK_TITLE) + }) return def _processTag(self, cursor: QTextCursor | None = None, @@ -2710,11 +2711,9 @@ class GuiDocEditSearch(QFrame): @pyqtSlot() def _doSearch(self) -> None: """Call the search action function for the document editor.""" - modKey = qApp.keyboardModifiers() - if modKey == Qt.KeyboardModifier.ShiftModifier: - self.docEditor.findNext(goBack=True) - else: - self.docEditor.findNext() + self.docEditor.findNext(goBack=( + qApp.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier) + ) return @pyqtSlot() @@ -2808,7 +2807,7 @@ class GuiDocEditHeader(QWidget): self.mainGui = docEditor.mainGui self._docHandle = None - self._docOutline: list[tuple[int, str]] = [] + self._docOutline: dict[int, str] = {} fPx = int(0.9*SHARED.theme.fontPixelSize) mPx = CONFIG.pxInt(8) @@ -2831,6 +2830,9 @@ class GuiDocEditHeader(QWidget): lblFont.setPointSizeF(0.9*SHARED.theme.fontPointSize) self.itemTitle.setFont(lblFont) + # Other Widgets + self.outlineMenu = QMenu(self) + # Buttons self.tbButton = QToolButton(self) self.tbButton.setContentsMargins(0, 0, 0, 0) @@ -2850,6 +2852,16 @@ class GuiDocEditHeader(QWidget): self.searchButton.setToolTip(self.tr("Search")) self.searchButton.clicked.connect(self.docEditor.toggleSearch) + self.outlineButton = QToolButton(self) + self.outlineButton.setContentsMargins(0, 0, 0, 0) + self.outlineButton.setIconSize(iconSize) + self.outlineButton.setFixedSize(fPx, fPx) + self.outlineButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + self.outlineButton.setVisible(False) + self.outlineButton.setToolTip(self.tr("Outline")) + self.outlineButton.setMenu(self.outlineMenu) + self.outlineButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.minmaxButton = QToolButton(self) self.minmaxButton.setContentsMargins(0, 0, 0, 0) self.minmaxButton.setIconSize(iconSize) @@ -2873,7 +2885,9 @@ class GuiDocEditHeader(QWidget): self.outerBox.setSpacing(hSp) self.outerBox.addWidget(self.tbButton, 0) self.outerBox.addWidget(self.searchButton, 0) + self.outerBox.addWidget(self.outlineButton, 0) self.outerBox.addWidget(self.itemTitle, 1) + self.outerBox.addSpacing(fPx + hSp) self.outerBox.addWidget(self.minmaxButton, 0) self.outerBox.addWidget(self.closeButton, 0) self.outerBox.setContentsMargins(mPx, mPx, mPx, mPx) @@ -2895,15 +2909,25 @@ class GuiDocEditHeader(QWidget): # Methods ## - def setOutline(self, data: list[tuple[int, str]]) -> None: + def setOutline(self, data: dict[int, str]) -> None: """Set the document outline dataset.""" - self._docOutline = data + if data != self._docOutline: + tStart = time() + self.outlineMenu.clear() + for number, text in data.items(): + action = self.outlineMenu.addAction(text) + action.triggered.connect( + lambda _, number=number: self._gotoBlock(number) + ) + self._docOutline = data + logger.debug("Document outline updated in %.3f ms", 1000*(time() - tStart)) return def updateTheme(self) -> None: """Update theme elements.""" self.tbButton.setIcon(SHARED.theme.getIcon("menu")) self.searchButton.setIcon(SHARED.theme.getIcon("search")) + self.outlineButton.setIcon(SHARED.theme.getIcon("list")) self.minmaxButton.setIcon(SHARED.theme.getIcon("maximise")) self.closeButton.setIcon(SHARED.theme.getIcon("close")) @@ -2912,9 +2936,11 @@ class GuiDocEditHeader(QWidget): "QToolButton {{border: none; background: transparent;}} " "QToolButton:hover {{border: none; background: rgba({0}, {1}, {2}, 0.2);}}" ).format(colText.red(), colText.green(), colText.blue()) + buttonStyleMenu = f"{buttonStyle} QToolButton::menu-indicator {{image: none;}}" self.tbButton.setStyleSheet(buttonStyle) self.searchButton.setStyleSheet(buttonStyle) + self.outlineButton.setStyleSheet(buttonStyleMenu) self.minmaxButton.setStyleSheet(buttonStyle) self.closeButton.setStyleSheet(buttonStyle) @@ -2945,6 +2971,7 @@ class GuiDocEditHeader(QWidget): self.itemTitle.setText("") self.tbButton.setVisible(False) self.searchButton.setVisible(False) + self.outlineButton.setVisible(False) self.closeButton.setVisible(False) self.minmaxButton.setVisible(False) return @@ -2958,6 +2985,7 @@ class GuiDocEditHeader(QWidget): self.tbButton.setVisible(True) self.searchButton.setVisible(True) + self.outlineButton.setVisible(True) self.closeButton.setVisible(True) self.minmaxButton.setVisible(True) @@ -2988,6 +3016,12 @@ class GuiDocEditHeader(QWidget): self.minmaxButton.setVisible(False) return + @pyqtSlot(int) + def _gotoBlock(self, blockNumber: int) -> None: + """Move cursor to a specific heading.""" + self.docEditor.setCursorLine(blockNumber + 1) + return + ## # Events ## diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index d7475952..34a25782 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -58,6 +58,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert nwGUI.docEditor.docHeader.itemTitle.text() == ( "Novel \u203a New Chapter \u203a New Scene" ) + assert nwGUI.docEditor.docHeader._docOutline == {0: "### New Scene"} # Check that editor handles settings CONFIG.textFont = "" @@ -84,6 +85,11 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): # Header # ====== + # Go to outline + nwGUI.docEditor.setCursorLine(3) + nwGUI.docEditor.docHeader.outlineMenu.actions()[0].trigger() + assert nwGUI.docEditor.getCursorPosition() == 0 + # Select item from header with qtbot.waitSignal(nwGUI.docEditor.requestProjectItemSelected, timeout=1000) as signal: qtbot.mouseClick(nwGUI.docEditor.docHeader, Qt.MouseButton.LeftButton) @@ -1686,13 +1692,13 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m threadPool = MockThreadPool() monkeypatch.setattr(QThreadPool, "globalInstance", lambda *a: threadPool) - nwGUI.docEditor.wcTimerDoc.blockSignals(True) - nwGUI.docEditor.wcTimerSel.blockSignals(True) + nwGUI.docEditor.timerDoc.blockSignals(True) + nwGUI.docEditor.timerSel.blockSignals(True) buildTestProject(nwGUI, projPath) # Run on an empty document - nwGUI.docEditor._runDocCounter() + nwGUI.docEditor._runDocumentTasks() assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" nwGUI.docEditor._updateDocCounts(0, 0, 0) assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" @@ -1714,7 +1720,7 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m # Check that a busy counter is blocked with monkeypatch.context() as mp: mp.setattr(nwGUI.docEditor.wCounterDoc, "isRunning", lambda *a: True) - nwGUI.docEditor._runDocCounter() + nwGUI.docEditor._runDocumentTasks() assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" with monkeypatch.context() as mp: @@ -1723,7 +1729,7 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" # Run the full word counter - nwGUI.docEditor._runDocCounter() + nwGUI.docEditor._runDocumentTasks() assert threadPool.objectID() == id(nwGUI.docEditor.wCounterDoc) nwGUI.docEditor.wCounterDoc.run() From add499af89ee53d0b12889cf2268cbbbc0c24a81 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 17 Mar 2024 14:04:11 +0100 Subject: [PATCH 3/9] Add outline navigation to viewer --- novelwriter/core/index.py | 15 ++++---- novelwriter/gui/doceditor.py | 5 +++ novelwriter/gui/docviewer.py | 70 ++++++++++++++++++++++++++++-------- 3 files changed, 70 insertions(+), 20 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 9b5f25db..f91c31e5 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -519,11 +519,16 @@ class NWIndex: def getItemHeading(self, tHandle: str, sTitle: str) -> IndexHeading | None: """Get the heading entry for a specific item and heading.""" - tItem = self._itemIndex[tHandle] - if isinstance(tItem, IndexItem): + if tItem := self._itemIndex[tHandle]: return tItem[sTitle] return None + def iterItemHeadings(self, tHandle: str) -> Iterator[str, IndexHeading]: + """Get all headings for a specific item.""" + if tItem := self._itemIndex[tHandle]: + yield from tItem.items() + return [] + def novelStructure( self, rootHandle: str | None = None, activeOnly: bool = True ) -> Iterator[tuple[str, str, str, IndexHeading]]: @@ -900,13 +905,11 @@ class ItemIndex: if rHandle is None: for sTitle in self._items[tHandle].headings(): - hItem = self._items[tHandle][sTitle] - if hItem: + if hItem := self._items[tHandle][sTitle]: yield tHandle, sTitle, hItem elif tItem.itemRoot == rHandle: for sTitle in self._items[tHandle].headings(): - hItem = self._items[tHandle][sTitle] - if hItem: + if hItem := self._items[tHandle][sTitle]: yield tHandle, sTitle, hItem return diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index eb6dec95..2787c593 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -2974,6 +2974,8 @@ class GuiDocEditHeader(QWidget): self.outlineButton.setVisible(False) self.closeButton.setVisible(False) self.minmaxButton.setVisible(False) + self.outlineMenu.clear() + self._docOutline = {} return if CONFIG.showFullPath: @@ -3012,8 +3014,11 @@ class GuiDocEditHeader(QWidget): self.closeDocumentRequest.emit() self.tbButton.setVisible(False) self.searchButton.setVisible(False) + self.outlineButton.setVisible(False) self.closeButton.setVisible(False) self.minmaxButton.setVisible(False) + self.outlineMenu.clear() + self._docOutline = {} return @pyqtSlot(int) diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 7b56e838..8b3882b2 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -240,6 +240,10 @@ class GuiDocViewer(QTextBrowser): self._docHandle = tHandle SHARED.project.data.setLastHandle(tHandle, "viewer") self.docHeader.setTitleFromHandle(self._docHandle) + self.docHeader.setOutline({ + sTitle: hItem.title + for sTitle, hItem in SHARED.project.index.iterItemHeadings(tHandle) + }) self.updateDocMargins() # Since we change the content while it may still be rendering, we mark @@ -281,13 +285,6 @@ class GuiDocViewer(QTextBrowser): return False return True - def navigateTo(self, tAnchor: str) -> None: - """Go to a specific #link in the document.""" - if isinstance(tAnchor, str) and tAnchor.startswith("#"): - logger.debug("Moving to anchor '%s'", tAnchor) - self.setSource(QUrl(tAnchor)) - return - def clearNavHistory(self) -> None: """Clear the navigation history.""" self.docHistory.clear() @@ -345,6 +342,14 @@ class GuiDocViewer(QTextBrowser): self.updateDocMargins() return + @pyqtSlot(str) + def navigateTo(self, anchor: str) -> None: + """Go to a specific #link in the document.""" + if isinstance(anchor, str) and anchor.startswith("#"): + logger.debug("Moving to anchor '%s'", anchor) + self.setSource(QUrl(anchor)) + return + ## # Private Slots ## @@ -630,9 +635,12 @@ class GuiDocViewHeader(QWidget): # Internal Variables self._docHandle = None + self._docOutline: dict[int, str] = {} fPx = int(0.9*SHARED.theme.fontPixelSize) hSp = CONFIG.pxInt(6) + mPx = CONFIG.pxInt(8) + iconSize = QSize(fPx, fPx) # Main Widget Settings self.setAutoFillBackground(True) @@ -651,10 +659,13 @@ class GuiDocViewHeader(QWidget): lblFont.setPointSizeF(0.9*SHARED.theme.fontPointSize) self.docTitle.setFont(lblFont) + # Other Widgets + self.outlineMenu = QMenu(self) + # Buttons self.backButton = QToolButton(self) self.backButton.setContentsMargins(0, 0, 0, 0) - self.backButton.setIconSize(QSize(fPx, fPx)) + self.backButton.setIconSize(iconSize) self.backButton.setFixedSize(fPx, fPx) self.backButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.backButton.setVisible(False) @@ -663,16 +674,26 @@ class GuiDocViewHeader(QWidget): self.forwardButton = QToolButton(self) self.forwardButton.setContentsMargins(0, 0, 0, 0) - self.forwardButton.setIconSize(QSize(fPx, fPx)) + self.forwardButton.setIconSize(iconSize) self.forwardButton.setFixedSize(fPx, fPx) self.forwardButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.forwardButton.setVisible(False) self.forwardButton.setToolTip(self.tr("Go Forward")) self.forwardButton.clicked.connect(self.docViewer.navForward) + self.outlineButton = QToolButton(self) + self.outlineButton.setContentsMargins(0, 0, 0, 0) + self.outlineButton.setIconSize(iconSize) + self.outlineButton.setFixedSize(fPx, fPx) + self.outlineButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + self.outlineButton.setVisible(False) + self.outlineButton.setToolTip(self.tr("Outline")) + self.outlineButton.setMenu(self.outlineMenu) + self.outlineButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.refreshButton = QToolButton(self) self.refreshButton.setContentsMargins(0, 0, 0, 0) - self.refreshButton.setIconSize(QSize(fPx, fPx)) + self.refreshButton.setIconSize(iconSize) self.refreshButton.setFixedSize(fPx, fPx) self.refreshButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.refreshButton.setVisible(False) @@ -681,7 +702,7 @@ class GuiDocViewHeader(QWidget): self.closeButton = QToolButton(self) self.closeButton.setContentsMargins(0, 0, 0, 0) - self.closeButton.setIconSize(QSize(fPx, fPx)) + self.closeButton.setIconSize(iconSize) self.closeButton.setFixedSize(fPx, fPx) self.closeButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.closeButton.setVisible(False) @@ -693,17 +714,19 @@ class GuiDocViewHeader(QWidget): self.outerBox.setSpacing(hSp) self.outerBox.addWidget(self.backButton, 0) self.outerBox.addWidget(self.forwardButton, 0) + self.outerBox.addWidget(self.outlineButton, 0) self.outerBox.addWidget(self.docTitle, 1) + self.outerBox.addSpacing(fPx + hSp) self.outerBox.addWidget(self.refreshButton, 0) self.outerBox.addWidget(self.closeButton, 0) + self.setLayout(self.outerBox) # Fix Margins and Size # This is needed for high DPI systems. See issue #499. - cM = CONFIG.pxInt(8) self.setContentsMargins(0, 0, 0, 0) - self.outerBox.setContentsMargins(cM, cM, cM, cM) - self.setMinimumHeight(fPx + 2*cM) + self.outerBox.setContentsMargins(mPx, mPx, mPx, mPx) + self.setMinimumHeight(fPx + 2*mPx) # Fix the Colours self.updateTheme() @@ -716,10 +739,23 @@ class GuiDocViewHeader(QWidget): # Methods ## + def setOutline(self, data: dict[int, str]) -> None: + """Set the document outline dataset.""" + if data != self._docOutline: + self.outlineMenu.clear() + for title, text in data.items(): + action = self.outlineMenu.addAction(text) + action.triggered.connect( + lambda _, title=title: self.docViewer.navigateTo(f"#{title}") + ) + self._docOutline = data + return + def updateTheme(self) -> None: """Update theme elements.""" self.backButton.setIcon(SHARED.theme.getIcon("backward")) self.forwardButton.setIcon(SHARED.theme.getIcon("forward")) + self.outlineButton.setIcon(SHARED.theme.getIcon("list")) self.refreshButton.setIcon(SHARED.theme.getIcon("refresh")) self.closeButton.setIcon(SHARED.theme.getIcon("close")) @@ -728,9 +764,11 @@ class GuiDocViewHeader(QWidget): "QToolButton {{border: none; background: transparent;}} " "QToolButton:hover {{border: none; background: rgba({0}, {1}, {2}, 0.2);}}" ).format(colText.red(), colText.green(), colText.blue()) + buttonStyleMenu = f"{buttonStyle} QToolButton::menu-indicator {{image: none;}}" self.backButton.setStyleSheet(buttonStyle) self.forwardButton.setStyleSheet(buttonStyle) + self.outlineButton.setStyleSheet(buttonStyleMenu) self.refreshButton.setStyleSheet(buttonStyle) self.closeButton.setStyleSheet(buttonStyle) @@ -759,8 +797,11 @@ class GuiDocViewHeader(QWidget): self.docTitle.setText("") self.backButton.setVisible(False) self.forwardButton.setVisible(False) + self.outlineButton.setVisible(False) self.closeButton.setVisible(False) self.refreshButton.setVisible(False) + self.outlineMenu.clear() + self._docOutline = {} return if CONFIG.showFullPath: @@ -772,6 +813,7 @@ class GuiDocViewHeader(QWidget): self.backButton.setVisible(True) self.forwardButton.setVisible(True) + self.outlineButton.setVisible(True) self.closeButton.setVisible(True) self.refreshButton.setVisible(True) From b156eee209da92f8e20cb4d99278b136ca497ad9 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 17 Mar 2024 15:02:42 +0100 Subject: [PATCH 4/9] Clean up signals and passing variables --- novelwriter/gui/doceditor.py | 46 +++++++-------- novelwriter/gui/docviewer.py | 87 +++++++++++++--------------- novelwriter/gui/mainmenu.py | 6 +- novelwriter/guimain.py | 85 +++++++++++++++------------ tests/test_gui/test_gui_doceditor.py | 12 ++-- tests/test_gui/test_gui_docviewer.py | 4 +- tests/test_gui/test_gui_guimain.py | 19 +++--- 7 files changed, 132 insertions(+), 127 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 2787c593..5112d54a 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -259,7 +259,7 @@ class GuiDocEditor(QPlainTextEdit): self._doReplace = False self.setDocumentChanged(False) - self.docHeader.setTitleFromHandle(self._docHandle) + self.docHeader.clearHeader() self.docFooter.setHandle(self._docHandle) self.docToolBar.setVisible(False) @@ -363,7 +363,7 @@ class GuiDocEditor(QPlainTextEdit): # which makes it read only. if self._docHandle: self._qDocument.syntaxHighlighter.rehighlight() - self.docHeader.setTitleFromHandle(self._docHandle) + self.docHeader.setHandle(self._docHandle) else: self.clearEditor() @@ -408,8 +408,8 @@ class GuiDocEditor(QPlainTextEdit): elif isinstance(tLine, int): self.setCursorLine(tLine) - self.docHeader.setTitleFromHandle(self._docHandle) - self.docFooter.setHandle(self._docHandle) + self.docHeader.setHandle(tHandle) + self.docFooter.setHandle(tHandle) # This is a hack to fix invisible cursor on an empty document if self._qDocument.characterCount() <= 1: @@ -991,8 +991,8 @@ class GuiDocEditor(QPlainTextEdit): """Called when an item label is changed to check if the document title bar needs updating, """ - if tHandle == self._docHandle: - self.docHeader.setTitleFromHandle(self._docHandle) + if tHandle and tHandle == self._docHandle: + self.docHeader.setHandle(tHandle) self.docFooter.updateInfo() self.updateDocMargins() return @@ -2909,6 +2909,20 @@ class GuiDocEditHeader(QWidget): # Methods ## + def clearHeader(self) -> None: + """Clear the header.""" + self._docHandle = None + self._docOutline = {} + + self.itemTitle.setText("") + self.outlineMenu.clear() + self.tbButton.setVisible(False) + self.searchButton.setVisible(False) + self.outlineButton.setVisible(False) + self.closeButton.setVisible(False) + self.minmaxButton.setVisible(False) + return + def setOutline(self, data: dict[int, str]) -> None: """Set the document outline dataset.""" if data != self._docOutline: @@ -2962,21 +2976,11 @@ class GuiDocEditHeader(QWidget): return - def setTitleFromHandle(self, tHandle: str | None) -> None: + def setHandle(self, tHandle: str) -> None: """Set the document title from the handle, or alternatively, set the whole document path within the project. """ self._docHandle = tHandle - if tHandle is None: - self.itemTitle.setText("") - self.tbButton.setVisible(False) - self.searchButton.setVisible(False) - self.outlineButton.setVisible(False) - self.closeButton.setVisible(False) - self.minmaxButton.setVisible(False) - self.outlineMenu.clear() - self._docOutline = {} - return if CONFIG.showFullPath: self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed( @@ -3011,14 +3015,8 @@ class GuiDocEditHeader(QWidget): @pyqtSlot() def _closeDocument(self) -> None: """Trigger the close editor on the main window.""" + self.clearHeader() self.closeDocumentRequest.emit() - self.tbButton.setVisible(False) - self.searchButton.setVisible(False) - self.outlineButton.setVisible(False) - self.closeButton.setVisible(False) - self.minmaxButton.setVisible(False) - self.outlineMenu.clear() - self._docOutline = {} return @pyqtSlot(int) diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 8b3882b2..0dcb2048 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -29,7 +29,6 @@ from __future__ import annotations import logging from enum import Enum -from typing import TYPE_CHECKING from PyQt5.QtCore import pyqtSignal, pyqtSlot, QPoint, QSize, Qt, QUrl from PyQt5.QtGui import ( @@ -48,9 +47,6 @@ from novelwriter.constants import nwUnicode from novelwriter.core.tohtml import ToHtml from novelwriter.extensions.eventfilters import WheelEventFilter -if TYPE_CHECKING: # pragma: no cover - from novelwriter.guimain import GuiMain - logger = logging.getLogger(__name__) @@ -58,17 +54,16 @@ class GuiDocViewer(QTextBrowser): documentLoaded = pyqtSignal(str) loadDocumentTagRequest = pyqtSignal(str, Enum) + closeDocumentRequest = pyqtSignal() + reloadDocumentRequest = pyqtSignal() togglePanelVisibility = pyqtSignal() requestProjectItemSelected = pyqtSignal(str, bool) - def __init__(self, mainGui: GuiMain) -> None: - super().__init__(parent=mainGui) + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) logger.debug("Create: GuiDocViewer") - # Class Variables - self.mainGui = mainGui - # Internal Variables self._docHandle = None @@ -128,7 +123,7 @@ class GuiDocViewer(QTextBrowser): self.clear() self.setSearchPaths([""]) self._docHandle = None - self.docHeader.setTitleFromHandle(self._docHandle) + self.docHeader.clearHeader() return def updateTheme(self) -> None: @@ -184,8 +179,7 @@ class GuiDocViewer(QTextBrowser): self.setTabStopDistance(CONFIG.getTabWidth()) # If we have a document open, we should reload it in case the font changed - if self._docHandle is not None: - self.reloadText() + self.reloadText() return @@ -239,7 +233,7 @@ class GuiDocViewer(QTextBrowser): self._docHandle = tHandle SHARED.project.data.setLastHandle(tHandle, "viewer") - self.docHeader.setTitleFromHandle(self._docHandle) + self.docHeader.setHandle(tHandle) self.docHeader.setOutline({ sTitle: hItem.title for sTitle, hItem in SHARED.project.index.iterItemHeadings(tHandle) @@ -337,8 +331,8 @@ class GuiDocViewer(QTextBrowser): @pyqtSlot(str) def updateDocInfo(self, tHandle: str) -> None: """Update the header title bar if needed.""" - if tHandle == self._docHandle: - self.docHeader.setTitleFromHandle(self._docHandle) + if tHandle and tHandle == self._docHandle: + self.docHeader.setHandle(tHandle) self.updateDocMargins() return @@ -631,7 +625,6 @@ class GuiDocViewHeader(QWidget): logger.debug("Create: GuiDocViewHeader") self.docViewer = docViewer - self.mainGui = docViewer.mainGui # Internal Variables self._docHandle = None @@ -646,18 +639,18 @@ class GuiDocViewHeader(QWidget): self.setAutoFillBackground(True) # Title Label - self.docTitle = QLabel() - self.docTitle.setText("") - self.docTitle.setIndent(0) - self.docTitle.setMargin(0) - self.docTitle.setContentsMargins(0, 0, 0, 0) - self.docTitle.setAutoFillBackground(True) - self.docTitle.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) - self.docTitle.setFixedHeight(fPx) + self.itemTitle = QLabel() + self.itemTitle.setText("") + self.itemTitle.setIndent(0) + self.itemTitle.setMargin(0) + self.itemTitle.setContentsMargins(0, 0, 0, 0) + self.itemTitle.setAutoFillBackground(True) + self.itemTitle.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) + self.itemTitle.setFixedHeight(fPx) - lblFont = self.docTitle.font() + lblFont = self.itemTitle.font() lblFont.setPointSizeF(0.9*SHARED.theme.fontPointSize) - self.docTitle.setFont(lblFont) + self.itemTitle.setFont(lblFont) # Other Widgets self.outlineMenu = QMenu(self) @@ -715,7 +708,7 @@ class GuiDocViewHeader(QWidget): self.outerBox.addWidget(self.backButton, 0) self.outerBox.addWidget(self.forwardButton, 0) self.outerBox.addWidget(self.outlineButton, 0) - self.outerBox.addWidget(self.docTitle, 1) + self.outerBox.addWidget(self.itemTitle, 1) self.outerBox.addSpacing(fPx + hSp) self.outerBox.addWidget(self.refreshButton, 0) self.outerBox.addWidget(self.closeButton, 0) @@ -739,6 +732,20 @@ class GuiDocViewHeader(QWidget): # Methods ## + def clearHeader(self) -> None: + """Clear the header.""" + self._docHandle = None + self._docOutline = {} + + self.itemTitle.setText("") + self.outlineMenu.clear() + self.backButton.setVisible(False) + self.forwardButton.setVisible(False) + self.outlineButton.setVisible(False) + self.closeButton.setVisible(False) + self.refreshButton.setVisible(False) + return + def setOutline(self, data: dict[int, str]) -> None: """Set the document outline dataset.""" if data != self._docOutline: @@ -785,31 +792,21 @@ class GuiDocViewHeader(QWidget): palette.setColor(QPalette.ColorRole.WindowText, SHARED.theme.colText) palette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText) self.setPalette(palette) - self.docTitle.setPalette(palette) + self.itemTitle.setPalette(palette) return - def setTitleFromHandle(self, tHandle: str | None) -> None: + def setHandle(self, tHandle: str) -> None: """Sets the document title from the handle, or alternatively, set the whole document path. """ self._docHandle = tHandle - if tHandle is None: - self.docTitle.setText("") - self.backButton.setVisible(False) - self.forwardButton.setVisible(False) - self.outlineButton.setVisible(False) - self.closeButton.setVisible(False) - self.refreshButton.setVisible(False) - self.outlineMenu.clear() - self._docOutline = {} - return if CONFIG.showFullPath: - self.docTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed( + self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed( [name for name in SHARED.project.tree.getItemPath(tHandle, asName=True)] ))) else: - self.docTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "") + self.itemTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "") self.backButton.setVisible(True) self.forwardButton.setVisible(True) @@ -832,15 +829,14 @@ class GuiDocViewHeader(QWidget): @pyqtSlot() def _closeDocument(self) -> None: """Trigger the close editor/viewer on the main window.""" - self.mainGui.closeDocViewer() + self.clearHeader() + self.docViewer.closeDocumentRequest.emit() return @pyqtSlot() def _refreshDocument(self) -> None: """Reload the content of the document.""" - if self.docViewer.docHandle == self.mainGui.docEditor.docHandle: - self.mainGui.saveDocument() - self.docViewer.reloadText() + self.docViewer.reloadDocumentRequest.emit() return ## @@ -871,7 +867,6 @@ class GuiDocViewFooter(QWidget): logger.debug("Create: GuiDocViewFooter") self.docViewer = docViewer - self.mainGui = docViewer.mainGui # Internal Variables self._docHandle = None diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 86cbd3d8..98f1fb9d 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -196,12 +196,12 @@ class GuiMainMenu(QMenuBar): # Document > Open self.aOpenDoc = self.docuMenu.addAction(self.tr("Open Document")) self.aOpenDoc.setShortcut("Ctrl+O") - self.aOpenDoc.triggered.connect(lambda: self.mainGui.openSelectedItem()) + self.aOpenDoc.triggered.connect(self.mainGui.openSelectedItem) # Document > Save self.aSaveDoc = self.docuMenu.addAction(self.tr("Save Document")) self.aSaveDoc.setShortcut("Ctrl+S") - self.aSaveDoc.triggered.connect(lambda: self.mainGui.saveDocument()) + self.aSaveDoc.triggered.connect(self.mainGui.saveDocument) # Document > Close self.aCloseDoc = self.docuMenu.addAction(self.tr("Close Document")) @@ -219,7 +219,7 @@ class GuiMainMenu(QMenuBar): # Document > Close Preview self.aCloseView = self.docuMenu.addAction(self.tr("Close Document View")) self.aCloseView.setShortcut("Ctrl+Shift+R") - self.aCloseView.triggered.connect(lambda: self.mainGui.closeDocViewer()) + self.aCloseView.triggered.connect(self.mainGui.closeDocViewer) # Document > Separator self.docuMenu.addSeparator() diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index f2a3e8ff..5faf9a28 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -278,6 +278,8 @@ class GuiMain(QMainWindow): self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle) self.docViewer.loadDocumentTagRequest.connect(self._followTag) + self.docViewer.closeDocumentRequest.connect(self.closeDocViewer) + self.docViewer.reloadDocumentRequest.connect(self._reloadViewer) self.docViewer.togglePanelVisibility.connect(self._toggleViewerPanelVisibility) self.docViewer.requestProjectItemSelected.connect(self.projView.setSelectedHandle) @@ -401,7 +403,7 @@ class GuiMain(QMainWindow): if saveOK: self.closeDocument() self.docViewer.clearNavHistory() - self.closeDocViewer(byUser=False) + self.closeViewerPanel(byUser=False) self.docViewerPanel.closeProjectTasks() self.outlineView.closeProjectTasks() @@ -604,13 +606,12 @@ class GuiMain(QMainWindow): return False - def saveDocument(self) -> bool: + @pyqtSlot() + def saveDocument(self) -> None: """Save the current documents.""" - if not SHARED.hasProject: - logger.error("No project open") - return False - self.docEditor.saveText() - return True + if SHARED.hasProject: + self.docEditor.saveText() + return def viewDocument(self, tHandle: str | None = None, sTitle: str | None = None) -> bool: """Load a document for viewing in the view panel.""" @@ -640,7 +641,8 @@ class GuiMain(QMainWindow): self._changeView(nwView.EDITOR) logger.debug("Viewing document with handle '%s'", tHandle) - if self.docViewer.loadText(tHandle): + updateHistory = tHandle != self.docViewer.docHandle + if self.docViewer.loadText(tHandle, updateHistory=updateHistory): if not self.splitView.isVisible(): cursorVisible = self.docEditor.cursorIsVisible() bPos = self.splitMain.sizes() @@ -713,37 +715,32 @@ class GuiMain(QMainWindow): # Tree Item Actions ## - def openSelectedItem(self) -> bool: + @pyqtSlot() + def openSelectedItem(self) -> None: """Open the selected item from the tree that is currently active. It is not checked that the item is actually a document. That should be handled by the openDocument function. """ - if not SHARED.hasProject: - logger.error("No project open") - return False - - tHandle = None - sTitle = None - tLine = None - if self.projView.treeHasFocus(): - tHandle = self.projView.getSelectedHandle() - elif self.novelView.treeHasFocus(): - tHandle, sTitle = self.novelView.getSelectedHandle() - elif self.outlineView.treeHasFocus(): - tHandle, sTitle = self.outlineView.getSelectedHandle() - else: - logger.warning("No item selected") - return False - - if tHandle is not None and sTitle is not None: - hItem = SHARED.project.index.getItemHeading(tHandle, sTitle) - if hItem is not None: - tLine = hItem.line - - if tHandle is not None: - self.openDocument(tHandle, tLine=tLine, changeFocus=False, doScroll=False) - - return True + if SHARED.hasProject: + tHandle = None + sTitle = None + tLine = None + if self.projView.treeHasFocus(): + tHandle = self.projView.getSelectedHandle() + elif self.novelView.treeHasFocus(): + tHandle, sTitle = self.novelView.getSelectedHandle() + elif self.outlineView.treeHasFocus(): + tHandle, sTitle = self.outlineView.getSelectedHandle() + else: + logger.warning("No item selected") + return False + if tHandle is not None and sTitle is not None: + hItem = SHARED.project.index.getItemHeading(tHandle, sTitle) + if hItem is not None: + tLine = hItem.line + if tHandle is not None: + self.openDocument(tHandle, tLine=tLine, changeFocus=False, doScroll=False) + return def editItemLabel(self, tHandle: str | None = None) -> bool: """Open the edit item dialog.""" @@ -943,7 +940,7 @@ class GuiMain(QMainWindow): return True - def closeDocViewer(self, byUser: bool = True) -> bool: + def closeViewerPanel(self, byUser: bool = True) -> bool: """Close the document view panel.""" self.docViewer.clearViewer() if byUser: @@ -991,6 +988,13 @@ class GuiMain(QMainWindow): SHARED.project.data.setLastHandle(None, "editor") return + @pyqtSlot() + def closeDocViewer(self) -> None: + """Close the document viewer.""" + self.closeViewerPanel() + SHARED.project.data.setLastHandle(None, "viewer") + return + @pyqtSlot() def toggleFocusMode(self) -> None: """Handle toggle focus mode. The Main GUI Focus Mode hides tree, @@ -1154,6 +1158,15 @@ class GuiMain(QMainWindow): self.viewDocument(tHandle=tHandle, sTitle=sTitle) return + @pyqtSlot() + def _reloadViewer(self) -> None: + """Reload the document in the viewer.""" + if self.docEditor.docChanged and self.docEditor.docHandle == self.docViewer.docHandle: + # If the two panels have the same document, save any changes in the editor + self.saveDocument() + self.docViewer.reloadText() + return + @pyqtSlot(nwView) def _changeView(self, view: nwView) -> None: """Handle the requested change of view from the GuiViewBar.""" diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 34a25782..60f75fe8 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -47,7 +47,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert nwGUI.openDocument(C.hSceneDoc) nwGUI.docEditor.setPlainText("### Lorem Ipsum\n\n%s" % ipsumText[0]) - assert nwGUI.saveDocument() + nwGUI.saveDocument() # Check Defaults qDoc = nwGUI.docEditor.document() @@ -122,7 +122,7 @@ def testGuiEditor_LoadText(qtbot, nwGUI, projPath, ipsumText, mockRnd): longText = "### Lorem Ipsum\n\n%s" % "\n\n".join(ipsumText*20) nwGUI.docEditor.replaceText(longText) - assert nwGUI.saveDocument() is True + nwGUI.saveDocument() assert nwGUI.closeDocument() is True # Invalid handle @@ -138,7 +138,7 @@ def testGuiEditor_LoadText(qtbot, nwGUI, projPath, ipsumText, mockRnd): # Load empty document nwGUI.docEditor.replaceText("") - assert nwGUI.saveDocument() is True + nwGUI.saveDocument() assert nwGUI.docEditor.loadText(C.hSceneDoc) is True assert nwGUI.docEditor.toPlainText() == "" @@ -1484,7 +1484,7 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): cHandle = SHARED.project.newFile("Jane Doe", C.hCharRoot) assert nwGUI.openDocument(cHandle) is True nwGUI.docEditor.replaceText(text) - assert nwGUI.saveDocument() is True + nwGUI.saveDocument() assert nwGUI.projView.projTree.revealNewTreeItem(cHandle) nwGUI.docEditor.updateTagHighLighting() @@ -1514,7 +1514,7 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert nwGUI.docViewer._docHandle is None assert nwGUI.docEditor._processTag(follow=True) is nwTrinary.POSITIVE assert nwGUI.docViewer._docHandle == cHandle - assert nwGUI.closeDocViewer() is True + assert nwGUI.closeViewerPanel() is True assert nwGUI.docViewer._docHandle is None # On Unknown Tag, Create It @@ -1553,7 +1553,7 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd): cHandle = SHARED.project.newFile("People", C.hCharRoot) assert nwGUI.openDocument(cHandle) is True nwGUI.docEditor.replaceText(text) - assert nwGUI.saveDocument() is True + nwGUI.saveDocument() assert nwGUI.projView.projTree.revealNewTreeItem(cHandle) docEditor = nwGUI.docEditor diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py index 761e3e56..47825510 100644 --- a/tests/test_gui/test_gui_docviewer.py +++ b/tests/test_gui/test_gui_docviewer.py @@ -185,12 +185,12 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum): nwItem.setName("Test Title") # type: ignore assert nwItem.itemName == "Test Title" # type: ignore docViewer.updateDocInfo("4c4f28287af27") - assert docViewer.docHeader.docTitle.text() == "Characters \u203a Test Title" + assert docViewer.docHeader.itemTitle.text() == "Characters \u203a Test Title" # Title without full path CONFIG.showFullPath = False docViewer.updateDocInfo("4c4f28287af27") - assert docViewer.docHeader.docTitle.text() == "Test Title" + assert docViewer.docHeader.itemTitle.text() == "Test Title" CONFIG.showFullPath = True # Document footer show/hide synopsis diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 68c6aa55..8d9d50fd 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -52,10 +52,8 @@ def testGuiMain_ProjectBlocker(nwGUI): assert nwGUI.closeDocument() is False assert nwGUI.openDocument(None) is False assert nwGUI.openNextDocument(None) is False - assert nwGUI.saveDocument() is False assert nwGUI.viewDocument(None) is False assert nwGUI.importDocument() is False - assert nwGUI.openSelectedItem() is False assert nwGUI.editItemLabel() is False assert nwGUI.rebuildIndex() is False @@ -109,7 +107,8 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): buildTestProject(nwGUI, projPath) sHandle = "000000000000f" - assert nwGUI.openSelectedItem() is False + nwGUI.openSelectedItem() + assert nwGUI.docEditor.docHandle is None # Project Tree has focus nwGUI._changeView(nwView.PROJECT) @@ -238,7 +237,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): nwGUI.projView.projTree.clearSelection() nwGUI.projView.projTree._getTreeItem(C.hCharRoot).setSelected(True) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True) - assert nwGUI.openSelectedItem() + nwGUI.openSelectedItem() # Text Editor # =========== @@ -265,7 +264,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): nwGUI.projView.projTree.clearSelection() nwGUI.projView.projTree._getTreeItem(C.hPlotRoot).setSelected(True) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True) - assert nwGUI.openSelectedItem() + nwGUI.openSelectedItem() # Type something into the document nwGUI.switchFocus(nwWidget.EDITOR) @@ -287,7 +286,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): nwGUI.projView.projTree.clearSelection() nwGUI.projView.projTree._getTreeItem(C.hWorldRoot).setSelected(True) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True) - assert nwGUI.openSelectedItem() + nwGUI.openSelectedItem() # Add Some Text docEditor.replaceText("Hello World!") @@ -319,7 +318,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): nwGUI.projView.projTree._getTreeItem(C.hNovelRoot).setExpanded(True) nwGUI.projView.projTree._getTreeItem(C.hChapterDir).setExpanded(True) nwGUI.projView.projTree._getTreeItem(C.hSceneDoc).setSelected(True) - assert nwGUI.openSelectedItem() + nwGUI.openSelectedItem() # Type something into the document nwGUI.switchFocus(nwWidget.EDITOR) @@ -524,8 +523,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): # Save the document assert docEditor.docChanged - assert nwGUI.saveDocument() - assert not docEditor.docChanged + nwGUI.saveDocument() + assert docEditor.docChanged is False nwGUI.rebuildIndex() # Open and view the edited document @@ -533,7 +532,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): assert nwGUI.openDocument(C.hSceneDoc) assert nwGUI.viewDocument(C.hSceneDoc) assert nwGUI.saveProject() - assert nwGUI.closeDocViewer() + assert nwGUI.closeViewerPanel() # Check the files projFile = projPath / "nwProject.nwx" From 8afe3ed039518f2cba170744101f45cfc3ca3696 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 17 Mar 2024 15:21:42 +0100 Subject: [PATCH 5/9] Let the shared class handle focus mode change to disconnect editor header from main GUI --- novelwriter/gui/doceditor.py | 24 +++++++++---------- novelwriter/guimain.py | 38 ++++++++++++++---------------- novelwriter/shared.py | 19 +++++++++++++++ tests/test_gui/test_gui_guimain.py | 2 +- 4 files changed, 49 insertions(+), 34 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 5112d54a..910ae4a0 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -548,8 +548,8 @@ class GuiDocEditor(QPlainTextEdit): sH = hBar.height() if hBar.isVisible() else 0 tM = self._vpMargin - if CONFIG.textWidth > 0 or self.mainGui.isFocusMode: - tW = CONFIG.getTextWidth(self.mainGui.isFocusMode) + if CONFIG.textWidth > 0 or SHARED.focusMode: + tW = CONFIG.getTextWidth(SHARED.focusMode) tM = max((wW - sW - tW)//2, self._vpMargin) tB = self.frameWidth() @@ -2894,6 +2894,9 @@ class GuiDocEditHeader(QWidget): self.setLayout(self.outerBox) + # Other Signals + SHARED.focusModeChanged.connect(self._focusModeChanged) + # Fix Margins and Size # This is needed for high DPI systems. See issue #499. self.setContentsMargins(0, 0, 0, 0) @@ -2997,17 +3000,6 @@ class GuiDocEditHeader(QWidget): return - def updateFocusMode(self) -> None: - """Update the minimise/maximise icon of the Focus Mode button. - This function is called by the GuiMain class via the - toggleFocusMode function and should not be activated directly. - """ - if self.mainGui.isFocusMode: - self.minmaxButton.setIcon(SHARED.theme.getIcon("minimise")) - else: - self.minmaxButton.setIcon(SHARED.theme.getIcon("maximise")) - return - ## # Private Slots ## @@ -3025,6 +3017,12 @@ class GuiDocEditHeader(QWidget): self.docEditor.setCursorLine(blockNumber + 1) return + @pyqtSlot(bool) + def _focusModeChanged(self, focusMode: bool) -> None: + """Update minimise/maximise icon of the Focus Mode button.""" + self.minmaxButton.setIcon(SHARED.theme.getIcon("minimise" if focusMode else "maximise")) + return + ## # Events ## diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 5faf9a28..c6ea31f2 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -104,9 +104,6 @@ class GuiMain(QMainWindow): # Initialise UserData Instance SHARED.initSharedData(self, GuiTheme()) - # Core Settings - self.isFocusMode = False - # Prepare Main Window self.resize(*CONFIG.mainWinSize) self._updateWindowTitle() @@ -233,6 +230,7 @@ class GuiMain(QMainWindow): SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus) SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage) SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage) + SHARED.focusModeChanged.connect(self._focusModeChanged) SHARED.indexChangedTags.connect(self.docViewerPanel.updateChangedTags) SHARED.indexScannedText.connect(self.docViewerPanel.projectItemChanged) SHARED.indexScannedText.connect(self.projView.updateItemValues) @@ -532,8 +530,8 @@ class GuiMain(QMainWindow): return False # Disable focus mode if it is active - if self.isFocusMode: - self.toggleFocusMode() + if SHARED.focusMode: + SHARED.setFocusMode(False) self.docEditor.saveCursorPosition() if self.docEditor.docChanged: @@ -747,7 +745,7 @@ class GuiMain(QMainWindow): if not SHARED.hasProject: logger.error("No project open") return False - if tHandle is None and (self.docEditor.anyFocus() or self.isFocusMode): + if tHandle is None and (self.docEditor.anyFocus() or SHARED.focusMode): tHandle = self.docEditor.docHandle self.projView.renameTreeItem(tHandle) return True @@ -919,7 +917,7 @@ class GuiMain(QMainWindow): logger.info("Exiting novelWriter") - if not self.isFocusMode: + if not SHARED.focusMode: CONFIG.setMainPanePos(self.splitMain.sizes()) CONFIG.setOutlinePanePos(self.outlineView.splitSizes()) if self.docViewerPanel.isVisible(): @@ -997,30 +995,31 @@ class GuiMain(QMainWindow): @pyqtSlot() def toggleFocusMode(self) -> None: - """Handle toggle focus mode. The Main GUI Focus Mode hides tree, + """Toggle focus mode.""" + if self.docEditor.docHandle: + SHARED.setFocusMode(not SHARED.focusMode) + return + + @pyqtSlot(bool) + def _focusModeChanged(self, focusMode: bool) -> None: + """Handle change of focus mode. The Main GUI Focus Mode hides tree, view, statusbar and menu. """ - if self.docEditor.docHandle is None: - logger.error("No document open, so not activating Focus Mode") - return - - self.isFocusMode = not self.isFocusMode - if self.isFocusMode: + if focusMode: logger.debug("Activating Focus Mode") self.switchFocus(nwWidget.EDITOR) else: logger.debug("Deactivating Focus Mode") cursorVisible = self.docEditor.cursorIsVisible() - isVisible = not self.isFocusMode + isVisible = not focusMode self.treePane.setVisible(isVisible) self.mainStatus.setVisible(isVisible) self.mainMenu.setVisible(isVisible) self.sideBar.setVisible(isVisible) - hideDocFooter = self.isFocusMode and CONFIG.hideFocusFooter + hideDocFooter = focusMode and CONFIG.hideFocusFooter self.docEditor.docFooter.setVisible(not hideDocFooter) - self.docEditor.docHeader.updateFocusMode() if self.splitView.isVisible(): self.splitView.setVisible(False) @@ -1029,7 +1028,6 @@ class GuiMain(QMainWindow): if cursorVisible: self.docEditor.ensureCursorVisibleNoCentre() - return @pyqtSlot(nwWidget) @@ -1279,8 +1277,8 @@ class GuiMain(QMainWindow): """Process escape keypress in the main window.""" if self.docEditor.docSearch.isVisible(): self.docEditor.closeSearch() - elif self.isFocusMode: - self.toggleFocusMode() + elif SHARED.focusMode: + SHARED.setFocusMode(False) return @pyqtSlot(int) diff --git a/novelwriter/shared.py b/novelwriter/shared.py index c7f7f70d..31b3c88a 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -57,6 +57,7 @@ class SharedData(QObject): projectStatusChanged = pyqtSignal(bool) projectStatusMessage = pyqtSignal(str) spellLanguageChanged = pyqtSignal(str, str) + focusModeChanged = pyqtSignal(bool) indexScannedText = pyqtSignal(str) indexChangedTags = pyqtSignal(list, list) indexCleared = pyqtSignal() @@ -76,6 +77,7 @@ class SharedData(QObject): self._lastAlert = "" self._idleTime = 0.0 self._idleRefTime = time() + self._focusMode = False return @@ -111,6 +113,11 @@ class SharedData(QObject): raise Exception("SharedData class not fully initialised") return self._spelling + @property + def focusMode(self) -> bool: + """Return the Focus Mode state.""" + return self._focusMode + @property def hasProject(self) -> bool: """Return True if the project instance is populated.""" @@ -131,6 +138,17 @@ class SharedData(QObject): """Return the last alert message.""" return self._lastAlert + ## + # Setters + ## + + def setFocusMode(self, state: bool) -> None: + """Set focus mode on or off.""" + if state is not self._focusMode: + self._focusMode = state + self.focusModeChanged.emit(state) + return + ## # Methods ## @@ -323,6 +341,7 @@ class SharedData(QObject): self._project = NWProject() self._spelling = NWSpellEnchant(self._project) self.updateSpellCheckLanguage() + self._focusMode = False return def _resetIdleTimer(self) -> None: diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 8d9d50fd..cc9097c7 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -574,7 +574,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): def testGuiMain_Features(qtbot, nwGUI, projPath, mockRnd): """Test various features of the main window.""" buildTestProject(nwGUI, projPath) - assert nwGUI.isFocusMode is False + assert SHARED.focusMode is False # Focus Mode # ========== From c26cfd53720e7ff17e7acca37abd8e9949ef19d2 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 17 Mar 2024 15:51:16 +0100 Subject: [PATCH 6/9] Use a signal to handle new note creation --- novelwriter/gui/doceditor.py | 19 +++++++++---------- novelwriter/gui/projtree.py | 21 +++++++++++++-------- novelwriter/guimain.py | 1 + tests/test_gui/test_gui_doceditor.py | 11 +++++++++-- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 910ae4a0..468bb16a 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -55,7 +55,7 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary from novelwriter.common import minmax, transferCase -from novelwriter.constants import nwKeyWords, nwLabels, nwShortcode, nwUnicode, trConst +from novelwriter.constants import nwKeyWords, nwShortcode, nwUnicode from novelwriter.tools.lipsum import GuiLipsum from novelwriter.core.document import NWDocument from novelwriter.text.counting import standardCounter @@ -99,6 +99,7 @@ class GuiDocEditor(QPlainTextEdit): toggleFocusModeRequest = pyqtSignal() requestProjectItemSelected = pyqtSignal(str, bool) requestProjectItemRenamed = pyqtSignal(str, str) + requestNewNoteCreation = pyqtSignal(str, nwItemClass) def __init__(self, mainGui: GuiMain) -> None: super().__init__(parent=mainGui) @@ -1863,7 +1864,10 @@ class GuiDocEditor(QPlainTextEdit): if text.startswith("@") and self._docHandle: isGood, tBits, tPos = SHARED.project.index.scanThis(text) - if not isGood or not tBits or tBits[0] == nwKeyWords.TAG_KEY: + if ( + not isGood or not tBits or tBits[0] == nwKeyWords.TAG_KEY + or tBits[0] not in nwKeyWords.VALID_KEYS + ): return nwTrinary.NEUTRAL tag = "" @@ -1891,13 +1895,9 @@ class GuiDocEditor(QPlainTextEdit): "Do you want to create a new project note for the tag '{0}'?" ).format(tag)): itemClass = nwKeyWords.KEY_CLASS.get(tBits[0], nwItemClass.NO_CLASS) - if SHARED.mainGui.projView.createNewNote(tag, itemClass): - self._qDocument.syntaxHighlighter.rehighlightBlock(block) - else: - SHARED.error(self.tr( - "Could not create note in a root folder for '{0}'. " - "If one doesn't exist, you must create one first." - ).format(trConst(nwLabels.CLASS_NAME[itemClass]))) + self.requestNewNoteCreation.emit(tag, itemClass) + qApp.processEvents() + self._qDocument.syntaxHighlighter.rehighlightBlock(block) return nwTrinary.POSITIVE if exist else nwTrinary.NEGATIVE @@ -2804,7 +2804,6 @@ class GuiDocEditHeader(QWidget): logger.debug("Create: GuiDocEditHeader") self.docEditor = docEditor - self.mainGui = docEditor.mainGui self._docHandle = None self._docOutline: dict[int, str] = {} diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 131bb398..674f9da2 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -142,7 +142,6 @@ class GuiProjectView(QWidget): self.requestDeleteItem = self.projTree.requestDeleteItem self.getSelectedHandle = self.projTree.getSelectedHandle self.changedSince = self.projTree.changedSince - self.createNewNote = self.projTree.createNewNote return @@ -240,6 +239,12 @@ class GuiProjectView(QWidget): self.projBar.buildQuickLinksMenu() return + @pyqtSlot(str, nwItemClass) + def createNewNote(self, tag: str, itemClass: nwItemClass) -> None: + """Process new not request.""" + self.projTree.createNewNote(tag, itemClass) + return + # END Class GuiProjectView @@ -606,18 +611,18 @@ class GuiProjectTree(QTreeWidget): self._timeChanged = 0.0 return - def createNewNote(self, tag: str, itemClass: nwItemClass | None) -> bool: + def createNewNote(self, tag: str, itemClass: nwItemClass) -> None: """Create a new note. This function is used by the document editor to create note files for unknown tags. """ - rHandle = SHARED.project.tree.findRoot(itemClass) - if rHandle: - tHandle = SHARED.project.newFile(tag, rHandle) - if tHandle: + if itemClass != nwItemClass.NO_CLASS: + if not (rHandle := SHARED.project.tree.findRoot(itemClass)): + self.newTreeItem(nwItemType.ROOT, itemClass) + rHandle = SHARED.project.tree.findRoot(itemClass) + if rHandle and (tHandle := SHARED.project.newFile(tag, rHandle)): SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n") self.revealNewTreeItem(tHandle, wordCount=True) - return True - return False + return def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None, hLevel: int = 1, isNote: bool = False, copyDoc: str | None = None) -> bool: diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index c6ea31f2..3be987a7 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -273,6 +273,7 @@ class GuiMain(QMainWindow): self.docEditor.toggleFocusModeRequest.connect(self.toggleFocusMode) self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle) self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem) + self.docEditor.requestNewNoteCreation.connect(self.projView.createNewNote) self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle) self.docViewer.loadDocumentTagRequest.connect(self._followTag) diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 60f75fe8..a1229b89 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -30,7 +30,9 @@ from PyQt5.QtCore import QThreadPool, Qt from PyQt5.QtWidgets import QAction, QMenu, qApp from novelwriter import CONFIG, SHARED -from novelwriter.enum import nwDocAction, nwDocInsert, nwItemLayout, nwTrinary, nwWidget +from novelwriter.enum import ( + nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwTrinary, nwWidget +) from novelwriter.constants import nwKeyWords, nwUnicode from novelwriter.gui.doceditor import GuiDocEditor, GuiDocToolBar from novelwriter.text.counting import standardCounter @@ -1527,7 +1529,12 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert "0000000000012" not in SHARED.project.tree nwGUI.docEditor.setCursorPosition(42) assert nwGUI.docEditor._processTag(create=True) is nwTrinary.NEGATIVE - assert "0000000000012" not in SHARED.project.tree + oHandle = SHARED.project.tree.findRoot(nwItemClass.OBJECT) + assert oHandle == "0000000000012" + + oItem = SHARED.project.tree["0000000000013"] + assert oItem is not None + assert oItem.itemParent == "0000000000012" nwGUI.docEditor.setCursorPosition(47) assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL From 6bb9ec0dc1f3a0534648b1325c8a07614624eea2 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 17 Mar 2024 16:10:27 +0100 Subject: [PATCH 7/9] Simplify a few more functions --- novelwriter/gui/doceditor.py | 13 ++-- novelwriter/guimain.py | 100 ++++++++++++--------------- tests/test_gui/test_gui_doceditor.py | 2 +- tests/test_gui/test_gui_guimain.py | 9 +-- 4 files changed, 53 insertions(+), 71 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 468bb16a..f401a9a3 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -1192,7 +1192,10 @@ class GuiDocEditor(QPlainTextEdit): if time() - self._lastEdit < 25.0: logger.debug("Running word counter") SHARED.runInThreadPool(self.wCounterDoc) - self._updateOutline() + self.docHeader.setOutline({ + block.blockNumber(): block.text() + for block in self._qDocument.iterBlockByType(BLOCK_TITLE) + }) return @@ -1837,14 +1840,6 @@ class GuiDocEditor(QPlainTextEdit): # Internal Functions ## - def _updateOutline(self) -> None: - """Scan the text for headings and update the outline.""" - self.docHeader.setOutline({ - block.blockNumber(): block.text() - for block in self._qDocument.iterBlockByType(BLOCK_TITLE) - }) - return - def _processTag(self, cursor: QTextCursor | None = None, follow: bool = True, create: bool = False) -> nwTrinary: """Activated by Ctrl+Enter. Checks that we're in a block diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 3be987a7..baf881c7 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -524,24 +524,19 @@ class GuiMain(QMainWindow): # Document Actions ## - def closeDocument(self, beforeOpen: bool = False) -> bool: + def closeDocument(self, beforeOpen: bool = False) -> None: """Close the document and clear the editor and title field.""" - if not SHARED.hasProject: - logger.error("No project open") - return False - - # Disable focus mode if it is active - if SHARED.focusMode: - SHARED.setFocusMode(False) - - self.docEditor.saveCursorPosition() - if self.docEditor.docChanged: - self.saveDocument() - self.docEditor.clearEditor() - if not beforeOpen: - self.novelView.setActiveHandle(None) - - return True + if SHARED.hasProject: + # Disable focus mode if it is active + if SHARED.focusMode: + SHARED.setFocusMode(False) + self.docEditor.saveCursorPosition() + if self.docEditor.docChanged: + self.saveDocument() + self.docEditor.clearEditor() + if not beforeOpen: + self.novelView.setActiveHandle(None) + return def openDocument(self, tHandle: str | None, tLine: int | None = None, changeFocus: bool = True, doScroll: bool = False) -> bool: @@ -732,57 +727,53 @@ class GuiMain(QMainWindow): tHandle, sTitle = self.outlineView.getSelectedHandle() else: logger.warning("No item selected") - return False - if tHandle is not None and sTitle is not None: - hItem = SHARED.project.index.getItemHeading(tHandle, sTitle) - if hItem is not None: + return + + if tHandle and sTitle: + if hItem := SHARED.project.index.getItemHeading(tHandle, sTitle): tLine = hItem.line - if tHandle is not None: + if tHandle: self.openDocument(tHandle, tLine=tLine, changeFocus=False, doScroll=False) + return - def editItemLabel(self, tHandle: str | None = None) -> bool: + def editItemLabel(self, tHandle: str | None = None) -> None: """Open the edit item dialog.""" - if not SHARED.hasProject: - logger.error("No project open") - return False - if tHandle is None and (self.docEditor.anyFocus() or SHARED.focusMode): - tHandle = self.docEditor.docHandle - self.projView.renameTreeItem(tHandle) - return True + if SHARED.hasProject: + if tHandle is None and (self.docEditor.anyFocus() or SHARED.focusMode): + tHandle = self.docEditor.docHandle + self.projView.renameTreeItem(tHandle) + return def rebuildTrees(self) -> None: """Rebuild the project tree.""" self.projView.populateTree() return - def rebuildIndex(self, beQuiet: bool = False) -> bool: + def rebuildIndex(self, beQuiet: bool = False) -> None: """Rebuild the entire index.""" - if not SHARED.hasProject: - logger.error("No project open") - return False + if SHARED.hasProject: + logger.info("Rebuilding index ...") + qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) + tStart = time() - logger.info("Rebuilding index ...") - qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) - tStart = time() + self.projView.saveProjectTasks() + SHARED.project.index.rebuildIndex() + self.projView.populateTree() + self.novelView.refreshTree() - self.projView.saveProjectTasks() - SHARED.project.index.rebuildIndex() - self.projView.populateTree() - self.novelView.refreshTree() + tEnd = time() + self.mainStatus.setStatusMessage( + self.tr("Indexing completed in {0} ms").format(f"{(tEnd - tStart)*1000.0:.1f}") + ) + self.docEditor.updateTagHighLighting() + self._updateStatusWordCount() + qApp.restoreOverrideCursor() - tEnd = time() - self.mainStatus.setStatusMessage( - self.tr("Indexing completed in {0} ms").format(f"{(tEnd - tStart)*1000.0:.1f}") - ) - self.docEditor.updateTagHighLighting() - self._updateStatusWordCount() - qApp.restoreOverrideCursor() + if not beQuiet: + SHARED.info(self.tr("The project index has been successfully rebuilt.")) - if not beQuiet: - SHARED.info(self.tr("The project index has been successfully rebuilt.")) - - return True + return ## # Main Dialogs @@ -892,15 +883,14 @@ class GuiMain(QMainWindow): SHARED.error(self.tr("Could not initialise the dialog.")) return - def reportConfErr(self) -> bool: + def reportConfErr(self) -> None: """Checks if the Config module has any errors to report, and let the user know if this is the case. The Config module caches errors since it is initialised before the GUI itself. """ if CONFIG.hasError: SHARED.error(CONFIG.errorText()) - return True - return False + return ## # Main Window Actions diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index a1229b89..000bfdc6 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -125,7 +125,7 @@ def testGuiEditor_LoadText(qtbot, nwGUI, projPath, ipsumText, mockRnd): longText = "### Lorem Ipsum\n\n%s" % "\n\n".join(ipsumText*20) nwGUI.docEditor.replaceText(longText) nwGUI.saveDocument() - assert nwGUI.closeDocument() is True + nwGUI.closeDocument() # Invalid handle assert nwGUI.docEditor.loadText("abcdefghijklm") is False diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index cc9097c7..8b8a1bd7 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -49,13 +49,10 @@ def testGuiMain_ProjectBlocker(nwGUI): # Test no-project blocking assert nwGUI.closeProject() is True assert nwGUI.saveProject() is False - assert nwGUI.closeDocument() is False assert nwGUI.openDocument(None) is False assert nwGUI.openNextDocument(None) is False assert nwGUI.viewDocument(None) is False assert nwGUI.importDocument() is False - assert nwGUI.editItemLabel() is False - assert nwGUI.rebuildIndex() is False # END Test testGuiMain_ProjectBlocker @@ -120,7 +117,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): nwGUI.projView.projTree._getTreeItem(sHandle).setSelected(True) nwGUI._keyPressReturn() assert nwGUI.docEditor.docHandle == sHandle - assert nwGUI.closeDocument() is True + nwGUI.closeDocument() # Novel Tree has focus nwGUI._changeView(nwView.NOVEL) @@ -132,7 +129,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): nwGUI.novelView.novelTree.setCurrentItem(selItem) nwGUI._keyPressReturn() assert nwGUI.docEditor.docHandle == sHandle - assert nwGUI.closeDocument() is True + nwGUI.closeDocument() # Project Outline has focus nwGUI._changeView(nwView.OUTLINE) @@ -144,7 +141,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): nwGUI.outlineView.outlineTree.setCurrentItem(selItem) nwGUI._keyPressReturn() assert nwGUI.docEditor.docHandle == sHandle - assert nwGUI.closeDocument() is True + nwGUI.closeDocument() # qtbot.stop() From 8133e374d17d06e39558c472e9d06fb5e011bd45 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 17 Mar 2024 16:57:28 +0100 Subject: [PATCH 8/9] Add indentation of viewer menu, and move location --- novelwriter/gui/doceditor.py | 26 ++++++++--------- novelwriter/gui/docviewer.py | 47 +++++++++++++++++------------- tests/test_core/test_core_index.py | 10 +++++++ 3 files changed, 50 insertions(+), 33 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index f401a9a3..b229827a 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -2837,15 +2837,6 @@ class GuiDocEditHeader(QWidget): self.tbButton.setToolTip(self.tr("Toggle Tool Bar")) self.tbButton.clicked.connect(lambda: self.toggleToolBarRequest.emit()) - self.searchButton = QToolButton(self) - self.searchButton.setContentsMargins(0, 0, 0, 0) - self.searchButton.setIconSize(iconSize) - self.searchButton.setFixedSize(fPx, fPx) - self.searchButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) - self.searchButton.setVisible(False) - self.searchButton.setToolTip(self.tr("Search")) - self.searchButton.clicked.connect(self.docEditor.toggleSearch) - self.outlineButton = QToolButton(self) self.outlineButton.setContentsMargins(0, 0, 0, 0) self.outlineButton.setIconSize(iconSize) @@ -2856,6 +2847,15 @@ class GuiDocEditHeader(QWidget): self.outlineButton.setMenu(self.outlineMenu) self.outlineButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.searchButton = QToolButton(self) + self.searchButton.setContentsMargins(0, 0, 0, 0) + self.searchButton.setIconSize(iconSize) + self.searchButton.setFixedSize(fPx, fPx) + self.searchButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + self.searchButton.setVisible(False) + self.searchButton.setToolTip(self.tr("Search")) + self.searchButton.clicked.connect(self.docEditor.toggleSearch) + self.minmaxButton = QToolButton(self) self.minmaxButton.setContentsMargins(0, 0, 0, 0) self.minmaxButton.setIconSize(iconSize) @@ -2878,8 +2878,8 @@ class GuiDocEditHeader(QWidget): self.outerBox = QHBoxLayout() self.outerBox.setSpacing(hSp) self.outerBox.addWidget(self.tbButton, 0) - self.outerBox.addWidget(self.searchButton, 0) self.outerBox.addWidget(self.outlineButton, 0) + self.outerBox.addWidget(self.searchButton, 0) self.outerBox.addWidget(self.itemTitle, 1) self.outerBox.addSpacing(fPx + hSp) self.outerBox.addWidget(self.minmaxButton, 0) @@ -2914,8 +2914,8 @@ class GuiDocEditHeader(QWidget): self.itemTitle.setText("") self.outlineMenu.clear() self.tbButton.setVisible(False) - self.searchButton.setVisible(False) self.outlineButton.setVisible(False) + self.searchButton.setVisible(False) self.closeButton.setVisible(False) self.minmaxButton.setVisible(False) return @@ -2937,8 +2937,8 @@ class GuiDocEditHeader(QWidget): def updateTheme(self) -> None: """Update theme elements.""" self.tbButton.setIcon(SHARED.theme.getIcon("menu")) - self.searchButton.setIcon(SHARED.theme.getIcon("search")) self.outlineButton.setIcon(SHARED.theme.getIcon("list")) + self.searchButton.setIcon(SHARED.theme.getIcon("search")) self.minmaxButton.setIcon(SHARED.theme.getIcon("maximise")) self.closeButton.setIcon(SHARED.theme.getIcon("close")) @@ -2950,8 +2950,8 @@ class GuiDocEditHeader(QWidget): buttonStyleMenu = f"{buttonStyle} QToolButton::menu-indicator {{image: none;}}" self.tbButton.setStyleSheet(buttonStyle) - self.searchButton.setStyleSheet(buttonStyle) self.outlineButton.setStyleSheet(buttonStyleMenu) + self.searchButton.setStyleSheet(buttonStyle) self.minmaxButton.setStyleSheet(buttonStyle) self.closeButton.setStyleSheet(buttonStyle) diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 0dcb2048..eab156ec 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -43,7 +43,7 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.enum import nwItemType, nwDocAction, nwDocMode from novelwriter.error import logException -from novelwriter.constants import nwUnicode +from novelwriter.constants import nwHeaders, nwUnicode from novelwriter.core.tohtml import ToHtml from novelwriter.extensions.eventfilters import WheelEventFilter @@ -235,7 +235,7 @@ class GuiDocViewer(QTextBrowser): SHARED.project.data.setLastHandle(tHandle, "viewer") self.docHeader.setHandle(tHandle) self.docHeader.setOutline({ - sTitle: hItem.title + sTitle: (hItem.title, nwHeaders.H_LEVEL.get(hItem.level, 0)) for sTitle, hItem in SHARED.project.index.iterItemHeadings(tHandle) }) self.updateDocMargins() @@ -628,7 +628,7 @@ class GuiDocViewHeader(QWidget): # Internal Variables self._docHandle = None - self._docOutline: dict[int, str] = {} + self._docOutline: dict[int, tuple[str, int]] = {} fPx = int(0.9*SHARED.theme.fontPixelSize) hSp = CONFIG.pxInt(6) @@ -656,6 +656,16 @@ class GuiDocViewHeader(QWidget): self.outlineMenu = QMenu(self) # Buttons + self.outlineButton = QToolButton(self) + self.outlineButton.setContentsMargins(0, 0, 0, 0) + self.outlineButton.setIconSize(iconSize) + self.outlineButton.setFixedSize(fPx, fPx) + self.outlineButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + self.outlineButton.setVisible(False) + self.outlineButton.setToolTip(self.tr("Outline")) + self.outlineButton.setMenu(self.outlineMenu) + self.outlineButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.backButton = QToolButton(self) self.backButton.setContentsMargins(0, 0, 0, 0) self.backButton.setIconSize(iconSize) @@ -674,16 +684,6 @@ class GuiDocViewHeader(QWidget): self.forwardButton.setToolTip(self.tr("Go Forward")) self.forwardButton.clicked.connect(self.docViewer.navForward) - self.outlineButton = QToolButton(self) - self.outlineButton.setContentsMargins(0, 0, 0, 0) - self.outlineButton.setIconSize(iconSize) - self.outlineButton.setFixedSize(fPx, fPx) - self.outlineButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) - self.outlineButton.setVisible(False) - self.outlineButton.setToolTip(self.tr("Outline")) - self.outlineButton.setMenu(self.outlineMenu) - self.outlineButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) - self.refreshButton = QToolButton(self) self.refreshButton.setContentsMargins(0, 0, 0, 0) self.refreshButton.setIconSize(iconSize) @@ -705,9 +705,9 @@ class GuiDocViewHeader(QWidget): # Assemble Layout self.outerBox = QHBoxLayout() self.outerBox.setSpacing(hSp) + self.outerBox.addWidget(self.outlineButton, 0) self.outerBox.addWidget(self.backButton, 0) self.outerBox.addWidget(self.forwardButton, 0) - self.outerBox.addWidget(self.outlineButton, 0) self.outerBox.addWidget(self.itemTitle, 1) self.outerBox.addSpacing(fPx + hSp) self.outerBox.addWidget(self.refreshButton, 0) @@ -739,19 +739,26 @@ class GuiDocViewHeader(QWidget): self.itemTitle.setText("") self.outlineMenu.clear() + self.outlineButton.setVisible(False) self.backButton.setVisible(False) self.forwardButton.setVisible(False) - self.outlineButton.setVisible(False) self.closeButton.setVisible(False) self.refreshButton.setVisible(False) return - def setOutline(self, data: dict[int, str]) -> None: + def setOutline(self, data: dict[int, tuple[str, int]]) -> None: """Set the document outline dataset.""" if data != self._docOutline: self.outlineMenu.clear() - for title, text in data.items(): - action = self.outlineMenu.addAction(text) + entries = [] + minLevel = 5 + for title, (text, level) in data.items(): + if title != "T0000": + entries.append((title, text, level)) + minLevel = min(minLevel, level) + for title, text, level in entries: + indent = " "*(level - minLevel) + action = self.outlineMenu.addAction(f"{indent}{text}") action.triggered.connect( lambda _, title=title: self.docViewer.navigateTo(f"#{title}") ) @@ -760,9 +767,9 @@ class GuiDocViewHeader(QWidget): def updateTheme(self) -> None: """Update theme elements.""" + self.outlineButton.setIcon(SHARED.theme.getIcon("list")) self.backButton.setIcon(SHARED.theme.getIcon("backward")) self.forwardButton.setIcon(SHARED.theme.getIcon("forward")) - self.outlineButton.setIcon(SHARED.theme.getIcon("list")) self.refreshButton.setIcon(SHARED.theme.getIcon("refresh")) self.closeButton.setIcon(SHARED.theme.getIcon("close")) @@ -773,9 +780,9 @@ class GuiDocViewHeader(QWidget): ).format(colText.red(), colText.green(), colText.blue()) buttonStyleMenu = f"{buttonStyle} QToolButton::menu-indicator {{image: none;}}" + self.outlineButton.setStyleSheet(buttonStyleMenu) self.backButton.setStyleSheet(buttonStyle) self.forwardButton.setStyleSheet(buttonStyle) - self.outlineButton.setStyleSheet(buttonStyleMenu) self.refreshButton.setStyleSheet(buttonStyle) self.closeButton.setStyleSheet(buttonStyle) diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 3ae976d4..13ba42dd 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -705,6 +705,16 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): index.getItemHeading(dHandle, "T0001") )] + # getItemHeading + # ============== + assert list(index.iterItemHeadings(cHandle)) == [ + ("T0001", index.getItemHeading(cHandle, "T0001")) + ] + assert list(index.iterItemHeadings(dHandle)) == [ + ("T0001", index.getItemHeading(dHandle, "T0001")) + ] + assert list(index.iterItemHeadings(C.hInvalid)) == [] + # getSingleTag # ============ assert index.getSingleTag("jane") == ( From 294bf51f8a9ab3a6a783618b114bcfef190cc374 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 17 Mar 2024 18:15:02 +0100 Subject: [PATCH 9/9] Cap outline to 30 items --- novelwriter/gui/doceditor.py | 4 +++- novelwriter/gui/docviewer.py | 2 +- novelwriter/gui/editordocument.py | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index b229827a..a3051647 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -1194,7 +1194,7 @@ class GuiDocEditor(QPlainTextEdit): SHARED.runInThreadPool(self.wCounterDoc) self.docHeader.setOutline({ block.blockNumber(): block.text() - for block in self._qDocument.iterBlockByType(BLOCK_TITLE) + for block in self._qDocument.iterBlockByType(BLOCK_TITLE, maxCount=30) }) return @@ -1306,6 +1306,7 @@ class GuiDocEditor(QPlainTextEdit): self._docHandle, wrapAround=self.docSearch.doLoop ) self.beginSearch() + self.setFocus() return cursor = self.textCursor() @@ -1326,6 +1327,7 @@ class GuiDocEditor(QPlainTextEdit): self._docHandle, wrapAround=self.docSearch.doLoop ) self.beginSearch() + self.setFocus() return else: resIdx = 0 if doLoop else maxIdx diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index eab156ec..d3352720 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -756,7 +756,7 @@ class GuiDocViewHeader(QWidget): if title != "T0000": entries.append((title, text, level)) minLevel = min(minLevel, level) - for title, text, level in entries: + for title, text, level in entries[:30]: indent = " "*(level - minLevel) action = self.outlineMenu.addAction(f"{indent}{text}") action.triggered.connect( diff --git a/novelwriter/gui/editordocument.py b/novelwriter/gui/editordocument.py index bfefd027..421e89a0 100644 --- a/novelwriter/gui/editordocument.py +++ b/novelwriter/gui/editordocument.py @@ -114,11 +114,13 @@ class GuiTextDocument(QTextDocument): return word, cPos, cLen, SHARED.spelling.suggestWords(word) return "", -1, -1, [] - def iterBlockByType(self, cType: int) -> Generator[QTextBlock]: + def iterBlockByType(self, cType: int, maxCount: int = 1000) -> Generator[QTextBlock]: """Iterate over all text blocks of a given type.""" + count = 0 for i in range(self.blockCount()): block = self.findBlockByNumber(i) - if block.isValid() and block.userState() & cType > 0: + if count < maxCount and block.isValid() and block.userState() & cType > 0: + count += 1 yield block return None