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 0d3bf0cc..a3051647 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -55,11 +55,11 @@ 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 -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 @@ -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) @@ -185,18 +186,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 +250,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 @@ -259,7 +260,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 +364,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() @@ -397,8 +398,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() @@ -408,8 +409,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: @@ -432,7 +433,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: @@ -548,8 +549,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() @@ -991,8 +992,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 @@ -1033,8 +1034,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 +1085,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 +1180,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 +1192,10 @@ class GuiDocEditor(QPlainTextEdit): if time() - self._lastEdit < 25.0: logger.debug("Running word counter") SHARED.runInThreadPool(self.wCounterDoc) + self.docHeader.setOutline({ + block.blockNumber(): block.text() + for block in self._qDocument.iterBlockByType(BLOCK_TITLE, maxCount=30) + }) return @@ -1214,10 +1217,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 +1244,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() @@ -1303,6 +1306,7 @@ class GuiDocEditor(QPlainTextEdit): self._docHandle, wrapAround=self.docSearch.doLoop ) self.beginSearch() + self.setFocus() return cursor = self.textCursor() @@ -1323,6 +1327,7 @@ class GuiDocEditor(QPlainTextEdit): self._docHandle, wrapAround=self.docSearch.doLoop ) self.beginSearch() + self.setFocus() return else: resIdx = 0 if doLoop else maxIdx @@ -1856,7 +1861,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 = "" @@ -1884,13 +1892,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 @@ -2704,11 +2708,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() @@ -2799,9 +2801,9 @@ class GuiDocEditHeader(QWidget): logger.debug("Create: GuiDocEditHeader") self.docEditor = docEditor - self.mainGui = docEditor.mainGui self._docHandle = None + self._docOutline: dict[int, str] = {} fPx = int(0.9*SHARED.theme.fontPixelSize) mPx = CONFIG.pxInt(8) @@ -2824,6 +2826,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) @@ -2834,6 +2839,16 @@ class GuiDocEditHeader(QWidget): self.tbButton.setToolTip(self.tr("Toggle Tool Bar")) self.tbButton.clicked.connect(lambda: self.toggleToolBarRequest.emit()) + 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.searchButton = QToolButton(self) self.searchButton.setContentsMargins(0, 0, 0, 0) self.searchButton.setIconSize(iconSize) @@ -2865,14 +2880,19 @@ class GuiDocEditHeader(QWidget): self.outerBox = QHBoxLayout() self.outerBox.setSpacing(hSp) self.outerBox.addWidget(self.tbButton, 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) self.outerBox.addWidget(self.closeButton, 0) self.outerBox.setContentsMargins(mPx, mPx, mPx, mPx) 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) @@ -2888,9 +2908,38 @@ 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.outlineButton.setVisible(False) + self.searchButton.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: + 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.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")) @@ -2900,8 +2949,10 @@ 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.outlineButton.setStyleSheet(buttonStyleMenu) self.searchButton.setStyleSheet(buttonStyle) self.minmaxButton.setStyleSheet(buttonStyle) self.closeButton.setStyleSheet(buttonStyle) @@ -2924,18 +2975,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.closeButton.setVisible(False) - self.minmaxButton.setVisible(False) - return if CONFIG.showFullPath: self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed( @@ -2946,22 +2990,12 @@ class GuiDocEditHeader(QWidget): self.tbButton.setVisible(True) self.searchButton.setVisible(True) + self.outlineButton.setVisible(True) self.closeButton.setVisible(True) self.minmaxButton.setVisible(True) 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 ## @@ -2969,11 +3003,20 @@ 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.closeButton.setVisible(False) - 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 + + @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 ## 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/docviewer.py b/novelwriter/gui/docviewer.py index 7b56e838..d3352720 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 ( @@ -44,13 +43,10 @@ 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 -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,11 @@ 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, nwHeaders.H_LEVEL.get(hItem.level, 0)) + 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 +279,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() @@ -340,11 +331,19 @@ 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 + @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 ## @@ -626,35 +625,50 @@ class GuiDocViewHeader(QWidget): logger.debug("Create: GuiDocViewHeader") self.docViewer = docViewer - self.mainGui = docViewer.mainGui # Internal Variables self._docHandle = None + self._docOutline: dict[int, tuple[str, int]] = {} 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) # 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) # 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(QSize(fPx, fPx)) + self.backButton.setIconSize(iconSize) self.backButton.setFixedSize(fPx, fPx) self.backButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.backButton.setVisible(False) @@ -663,7 +677,7 @@ 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) @@ -672,7 +686,7 @@ class GuiDocViewHeader(QWidget): 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 +695,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) @@ -691,19 +705,21 @@ 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.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) + 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,8 +732,42 @@ class GuiDocViewHeader(QWidget): # Methods ## + def clearHeader(self) -> None: + """Clear the header.""" + self._docHandle = None + self._docOutline = {} + + self.itemTitle.setText("") + self.outlineMenu.clear() + self.outlineButton.setVisible(False) + self.backButton.setVisible(False) + self.forwardButton.setVisible(False) + self.closeButton.setVisible(False) + self.refreshButton.setVisible(False) + return + + def setOutline(self, data: dict[int, tuple[str, int]]) -> None: + """Set the document outline dataset.""" + if data != self._docOutline: + self.outlineMenu.clear() + 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[:30]: + indent = " "*(level - minLevel) + action = self.outlineMenu.addAction(f"{indent}{text}") + action.triggered.connect( + lambda _, title=title: self.docViewer.navigateTo(f"#{title}") + ) + self._docOutline = data + return + 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.refreshButton.setIcon(SHARED.theme.getIcon("refresh")) @@ -728,7 +778,9 @@ 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.outlineButton.setStyleSheet(buttonStyleMenu) self.backButton.setStyleSheet(buttonStyle) self.forwardButton.setStyleSheet(buttonStyle) self.refreshButton.setStyleSheet(buttonStyle) @@ -747,31 +799,25 @@ 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.closeButton.setVisible(False) - self.refreshButton.setVisible(False) - 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) + self.outlineButton.setVisible(True) self.closeButton.setVisible(True) self.refreshButton.setVisible(True) @@ -790,15 +836,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 ## @@ -829,7 +874,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/editordocument.py b/novelwriter/gui/editordocument.py index 537363b7..421e89a0 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,16 @@ class GuiTextDocument(QTextDocument): return word, cPos, cLen, SHARED.spelling.suggestWords(word) return "", -1, -1, [] + 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 count < maxCount and block.isValid() and block.userState() & cType > 0: + count += 1 + yield block + return None + ## # Public Slots ## 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/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 f2a3e8ff..baf881c7 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) @@ -275,9 +273,12 @@ 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) + 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 +402,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() @@ -523,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 self.isFocusMode: - self.toggleFocusMode() - - 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: @@ -604,13 +600,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 +635,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,80 +709,71 @@ 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 + 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 - 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 and sTitle: + if hItem := SHARED.project.index.getItemHeading(tHandle, sTitle): + tLine = hItem.line + if tHandle: + self.openDocument(tHandle, tLine=tLine, changeFocus=False, doScroll=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 + return - if tHandle is not None: - self.openDocument(tHandle, tLine=tLine, changeFocus=False, doScroll=False) - - return True - - 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 self.isFocusMode): - 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 @@ -896,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 @@ -922,7 +908,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(): @@ -943,7 +929,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,32 +977,40 @@ 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, + """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) @@ -1025,7 +1019,6 @@ class GuiMain(QMainWindow): if cursorVisible: self.docEditor.ensureCursorVisibleNoCentre() - return @pyqtSlot(nwWidget) @@ -1154,6 +1147,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.""" @@ -1266,8 +1268,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_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") == ( diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index d7475952..000bfdc6 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 @@ -47,7 +49,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() @@ -58,6 +60,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 +87,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) @@ -116,8 +124,8 @@ 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 - assert nwGUI.closeDocument() is True + nwGUI.saveDocument() + nwGUI.closeDocument() # Invalid handle assert nwGUI.docEditor.loadText("abcdefghijklm") is False @@ -132,7 +140,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() == "" @@ -1478,7 +1486,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() @@ -1508,7 +1516,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 @@ -1521,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 @@ -1547,7 +1560,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 @@ -1686,13 +1699,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 +1727,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 +1736,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() 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..8b8a1bd7 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -49,15 +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.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 # END Test testGuiMain_ProjectBlocker @@ -109,7 +104,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) @@ -121,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) @@ -133,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) @@ -145,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() @@ -238,7 +234,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 +261,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 +283,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 +315,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 +520,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 +529,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" @@ -575,7 +571,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 # ==========