From 16e2e0cc83571d29e4df5fa83ccbfd185f14b6bf Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 7 Sep 2023 19:20:00 +0200 Subject: [PATCH] Add a custom text document in the editor --- novelwriter/gui/doceditor.py | 99 +++++++++++++++---------------- novelwriter/gui/dochighlight.py | 48 +++++++-------- novelwriter/gui/editordocument.py | 64 ++++++++++++++++++++ novelwriter/guimain.py | 4 +- sample/content/636b6aa9b697b.nwd | 6 +- sample/nwProject.nwx | 28 ++++----- 6 files changed, 154 insertions(+), 95 deletions(-) create mode 100644 novelwriter/gui/editordocument.py diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 9b81c765..bc526608 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -56,6 +56,7 @@ from novelwriter.common import minmax, transferCase from novelwriter.constants import nwKeyWords, nwUnicode from novelwriter.core.index import countWords from novelwriter.gui.dochighlight import GuiDocHighlighter +from novelwriter.gui.editordocument import GuiTextDocument from novelwriter.extensions.wheeleventfilter import WheelEventFilter if TYPE_CHECKING: # pragma: no cover @@ -120,9 +121,12 @@ class GuiDocEditor(QPlainTextEdit): self._typPadBefore = "" self._typPadAfter = "" - # Core Elements and Signals - qDoc = self.document() - qDoc.contentsChange.connect(self._docChange) + # Create Custom Document + self._qDocument = GuiTextDocument(self) + self.setDocument(self._qDocument) + + # Connect Signals + self._qDocument.contentsChange.connect(self._docChange) self.selectionChanged.connect(self._updateSelectedStatus) # Document Title @@ -130,9 +134,6 @@ class GuiDocEditor(QPlainTextEdit): self.docFooter = GuiDocEditFooter(self) self.docSearch = GuiDocEditSearch(self) - # Syntax - self.highLight = GuiDocHighlighter(qDoc) - # Context Menu self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._openContextMenu) @@ -212,7 +213,7 @@ class GuiDocEditor(QPlainTextEdit): @property def isEmpty(self) -> bool: """Check if the current document is empty.""" - return self.document().isEmpty() + return self._qDocument.isEmpty() ## # Methods @@ -266,7 +267,7 @@ class GuiDocEditor(QPlainTextEdit): self.docHeader.matchColours() self.docFooter.matchColours() - self.highLight.initHighlighter() + self._qDocument.syntaxHighlighter.initHighlighter() return @@ -312,8 +313,7 @@ class GuiDocEditor(QPlainTextEdit): # Due to cursor visibility, a part of the margin must be # allocated to the document itself. See issue #1112. cW = 2*self.cursorWidth() - qDoc = self.document() - qDoc.setDocumentMargin(cW) + self._qDocument.setDocumentMargin(cW) self._vpMargin = max(CONFIG.getTextMargin() - cW, 0) self.setViewportMargins(self._vpMargin, self._vpMargin, self._vpMargin, self._vpMargin) @@ -327,7 +327,7 @@ class GuiDocEditor(QPlainTextEdit): if CONFIG.showLineEndings: theOpt.setFlags(theOpt.flags() | QTextOption.ShowLineAndParagraphSeparators) - qDoc.setDefaultTextOption(theOpt) + self._qDocument.setDefaultTextOption(theOpt) # Scroll bars if CONFIG.hideVScroll: @@ -377,13 +377,12 @@ class GuiDocEditor(QPlainTextEdit): qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) self._docHandle = tHandle - self.highLight.setHandle(tHandle) tStart = time() self._allowAutoReplace(False) - self.setPlainText(docText) + self._qDocument.setTextContent(docText, tHandle) self._allowAutoReplace(True) - logger.debug("Document text loaded in %.3f ms", 1000*(time() - tStart)) + logger.debug("Document text set in %.3f ms", 1000*(time() - tStart)) qApp.processEvents() self._lastEdit = time() @@ -404,14 +403,15 @@ class GuiDocEditor(QPlainTextEdit): self.docFooter.updateLineCount() # This is a hack to fix invisible cursor on an empty document - if self.document().characterCount() <= 1: + if self._qDocument.characterCount() <= 1: self.setPlainText("\n") self.setPlainText("") self.setCursorPosition(0) qApp.processEvents() - self.document().clearUndoRedoStacks() self.setDocumentChanged(False) + self._qDocument.clearUndoRedoStacks() + qApp.restoreOverrideCursor() # Update the status bar @@ -422,12 +422,12 @@ class GuiDocEditor(QPlainTextEdit): def updateTagHighLighting(self) -> None: """Rerun the syntax highlighter on all meta data lines.""" - self.highLight.rehighlightByType(GuiDocHighlighter.BLOCK_META) + self._qDocument.syntaxHighlighter.rehighlightByType(GuiDocHighlighter.BLOCK_META) return def redrawText(self) -> None: """Redraw the text by marking the document content as dirty.""" - self.document().markContentsDirty(0, self.document().characterCount()) + self._qDocument.markContentsDirty(0, self._qDocument.characterCount()) self.updateDocMargins() return @@ -561,7 +561,7 @@ class GuiDocEditor(QPlainTextEdit): paragraph and line separators though. See: https://doc.qt.io/qt-5/qtextdocument.html#toPlainText """ - text = self.document().toRawText() + text = self._qDocument.toRawText() text = text.replace(nwUnicode.U_LSEP, "\n") # Line separators text = text.replace(nwUnicode.U_PSEP, "\n") # Paragraph separators return text @@ -586,7 +586,7 @@ class GuiDocEditor(QPlainTextEdit): def setCursorPosition(self, position: int) -> None: """Move the cursor to a given position in the document.""" - nChars = self.document().characterCount() + nChars = self._qDocument.characterCount() if nChars > 1 and isinstance(position, int): cursor = self.textCursor() cursor.setPosition(minmax(position, 0, nChars-1)) @@ -605,7 +605,7 @@ class GuiDocEditor(QPlainTextEdit): def setCursorLine(self, line: int | None) -> None: """Move the cursor to a given line in the document.""" if isinstance(line, int) and line > 0: - block = self.document().findBlockByNumber(line - 1) + block = self._qDocument.findBlockByNumber(line - 1) if block: self.setCursorPosition(block.position()) logger.debug("Cursor moved to line %d", line) @@ -638,7 +638,7 @@ class GuiDocEditor(QPlainTextEdit): self._spellCheck = state self.mainGui.mainMenu.setSpellCheck(state) SHARED.project.data.setSpellCheck(state) - self.highLight.setSpellCheck(state) + self._qDocument.syntaxHighlighter.setSpellCheck(state) if state is False: self.spellCheckDocument() @@ -655,7 +655,7 @@ class GuiDocEditor(QPlainTextEdit): logger.debug("Running spell checker") start = time() qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) - self.highLight.rehighlight() + self._qDocument.syntaxHighlighter.rehighlight() qApp.restoreOverrideCursor() logger.debug("Document highlighted in %.3f ms", 1000*(time() - start)) self.statusMessage.emit(self.tr("Spell check complete")) @@ -994,7 +994,7 @@ class GuiDocEditor(QPlainTextEdit): self.wcTimerDoc.start() if self._doReplace and added == 1: - self._docAutoReplace(self.document().findBlock(pos)) + self._docAutoReplace(self._qDocument.findBlock(pos)) return @@ -1122,7 +1122,7 @@ class GuiDocEditor(QPlainTextEdit): theWord = cursor.selectedText().strip().strip(self._nonWord) logger.debug("Added '%s' to project dictionary", theWord) SHARED.spelling.addWord(theWord) - self.highLight.rehighlightBlock(cursor.block()) + self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block()) return @pyqtSlot() @@ -1418,8 +1418,8 @@ class GuiDocEditor(QPlainTextEdit): posS = theCursor.selectionStart() posE = theCursor.selectionEnd() - blockS = self.document().findBlock(posS) - blockE = self.document().findBlock(posE) + blockS = self._qDocument.findBlock(posS) + blockE = self._qDocument.findBlock(posE) if blockS != blockE: posE = blockS.position() + blockS.length() - 1 @@ -1430,14 +1430,14 @@ class GuiDocEditor(QPlainTextEdit): numB = 0 for n in range(fLen): - if self.document().characterAt(posS-n-1) == fChar: + if self._qDocument.characterAt(posS-n-1) == fChar: numB += 1 else: break numA = 0 for n in range(fLen): - if self.document().characterAt(posE+n) == fChar: + if self._qDocument.characterAt(posE+n) == fChar: numA += 1 else: break @@ -1487,9 +1487,8 @@ class GuiDocEditor(QPlainTextEdit): posS = theCursor.selectionStart() posE = theCursor.selectionEnd() - qDoc = self.document() - blockS = qDoc.findBlock(posS) - blockE = qDoc.findBlock(posE) + blockS = self._qDocument.findBlock(posS) + blockE = self._qDocument.findBlock(posE) if blockS != blockE: posE = blockS.position() + blockS.length() - 1 @@ -1690,16 +1689,15 @@ class GuiDocEditor(QPlainTextEdit): def _removeInParLineBreaks(self) -> None: """Strip line breaks within paragraphs in the selected text.""" - theCursor = self.textCursor() - theDoc = self.document() + cursor = self.textCursor() iS = 0 - iE = theDoc.blockCount() - 1 + iE = self._qDocument.blockCount() - 1 rS = 0 - rE = theDoc.characterCount() - if theCursor.hasSelection(): - sBlock = theDoc.findBlock(theCursor.selectionStart()) - eBlock = theDoc.findBlock(theCursor.selectionEnd()) + rE = self._qDocument.characterCount() + if cursor.hasSelection(): + sBlock = self._qDocument.findBlock(cursor.selectionStart()) + eBlock = self._qDocument.findBlock(cursor.selectionEnd()) iS = sBlock.blockNumber() iE = eBlock.blockNumber() rS = sBlock.position() @@ -1709,7 +1707,7 @@ class GuiDocEditor(QPlainTextEdit): currPar = [] cleanText = "" for i in range(iS, iE+1): - cBlock = theDoc.findBlockByNumber(i) + cBlock = self._qDocument.findBlockByNumber(i) cText = cBlock.text() if cText.strip() == "": if currPar: @@ -1726,12 +1724,12 @@ class GuiDocEditor(QPlainTextEdit): cleanText += " ".join(currPar) + "\n\n" # Replace the text with the cleaned up text - theCursor.beginEditBlock() - theCursor.clearSelection() - theCursor.setPosition(rS) - theCursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, rE-rS) - theCursor.insertText(cleanText.rstrip() + "\n") - theCursor.endEditBlock() + cursor.beginEditBlock() + cursor.clearSelection() + cursor.setPosition(rS) + cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, rE-rS) + cursor.insertText(cleanText.rstrip() + "\n") + cursor.endEditBlock() return @@ -1913,11 +1911,10 @@ class GuiDocEditor(QPlainTextEdit): # Underscore counts as a part of the word, so check that the # selection isn't wrapped in italics markers. reSelect = False - qDoc = self.document() - if qDoc.characterAt(posS) == "_": + if self._qDocument.characterAt(posS) == "_": posS += 1 reSelect = True - if qDoc.characterAt(posE) == "_": + if self._qDocument.characterAt(posE) == "_": posE -= 1 reSelect = True if reSelect: @@ -2838,7 +2835,7 @@ class GuiDocEditFooter(QWidget): else: theCursor = self.docEditor.textCursor() iLine = theCursor.blockNumber() + 1 - iDist = 100*iLine/self.docEditor.document().blockCount() + iDist = 100*iLine/self.docEditor._qDocument.blockCount() self.linesText.setText( self.tr("Line: {0} ({1})").format(f"{iLine:n}", f"{iDist:.0f} %") ) @@ -2869,7 +2866,7 @@ class GuiDocEditFooter(QWidget): self.tr("Words: {0} ({1})").format(f"{wCount:n}", f"{wDiff:+n}") ) - byteSize = self.docEditor.document().characterCount() + byteSize = self.docEditor._qDocument.characterCount() self.wordsText.setToolTip( self.tr("Document size is {0} bytes").format(f"{byteSize:n}") ) diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index 7cd67364..92b9d8ca 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -51,6 +51,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): logger.debug("Create: GuiDocHighlighter") + self._tItem = None self._tHandle = None self._spellCheck = False self._spellRx = QRegularExpression() @@ -79,11 +80,6 @@ class GuiDocHighlighter(QSyntaxHighlighter): return - @property - def spellCheck(self) -> bool: - """Check if spell checking is enabled.""" - return self._spellCheck - def initHighlighter(self) -> None: """Initialise the syntax highlighter, setting all the colour rules and building the RegExes. @@ -248,6 +244,11 @@ class GuiDocHighlighter(QSyntaxHighlighter): def setHandle(self, tHandle: str) -> None: """Set the handle of the currently highlighted document.""" self._tHandle = tHandle + self._tItem = SHARED.project.tree[tHandle] + logger.debug( + "Syntax highlighter %s for item '%s'", + "enabled" if self._tItem else "disabled", tHandle + ) return ## @@ -284,27 +285,24 @@ class GuiDocHighlighter(QSyntaxHighlighter): if text.startswith("@"): # Keywords and commands self.setCurrentBlockState(self.BLOCK_META) - pIndex = SHARED.project.index - tItem = SHARED.project.tree[self._tHandle] - if tItem is None: - return - - isValid, theBits, thePos = pIndex.scanThis(text) - isGood = pIndex.checkThese(theBits, tItem) - if isValid: - for n, theBit in enumerate(theBits): - xPos = thePos[n] - xLen = len(theBit) - if isGood[n]: - if n == 0: - self.setFormat(xPos, xLen, self._hStyles["keyword"]) + if self._tItem: + pIndex = SHARED.project.index + isValid, theBits, thePos = pIndex.scanThis(text) + isGood = pIndex.checkThese(theBits, self._tItem) + if isValid: + for n, theBit in enumerate(theBits): + xPos = thePos[n] + xLen = len(theBit) + if isGood[n]: + if n == 0: + self.setFormat(xPos, xLen, self._hStyles["keyword"]) + else: + self.setFormat(xPos, xLen, self._hStyles["value"]) else: - self.setFormat(xPos, xLen, self._hStyles["value"]) - else: - kwFmt = self.format(xPos) - kwFmt.setUnderlineColor(self._colError) - kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) - self.setFormat(xPos, xLen, kwFmt) + kwFmt = self.format(xPos) + kwFmt.setUnderlineColor(self._colError) + kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) + self.setFormat(xPos, xLen, kwFmt) # We never want to run the spell checker on keyword/values, # so we force a return here diff --git a/novelwriter/gui/editordocument.py b/novelwriter/gui/editordocument.py new file mode 100644 index 00000000..40b15288 --- /dev/null +++ b/novelwriter/gui/editordocument.py @@ -0,0 +1,64 @@ +""" +novelWriter – GUI Text Document +=============================== + +File History: +Created: 2023-09-07 [2.2b1] + +This file is a part of novelWriter +Copyright 2018–2023, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import logging +from PyQt5.QtCore import QObject + +from PyQt5.QtGui import QTextDocument +from PyQt5.QtWidgets import QPlainTextDocumentLayout + +from novelwriter.gui.dochighlight import GuiDocHighlighter + +logger = logging.getLogger(__name__) + + +class GuiTextDocument(QTextDocument): + + def __init__(self, parent: QObject) -> None: + super().__init__(parent=parent) + + self._handle = None + self._syntax = GuiDocHighlighter(self) + self.setDocumentLayout(QPlainTextDocumentLayout(self)) + + logger.debug("Ready: GuiTextDocument") + + return + + def __del__(self): # pragma: no cover + logger.debug("Delete: GuiTextDocument") + return + + @property + def syntaxHighlighter(self) -> GuiDocHighlighter: + return self._syntax + + def setTextContent(self, text: str, tHandle: str) -> None: + """Set the text content of the document.""" + self._syntax.setHandle(tHandle) + self.setPlainText(text) + return + +# END Class GuiTextDocument diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 8f89a9ed..feafec69 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -621,10 +621,10 @@ class GuiMain(QMainWindow): break if nHandle is not None: - self.openDocument(nHandle, tLine=0, doScroll=True) + self.openDocument(nHandle, tLine=1, doScroll=True) return True elif wrapAround: - self.openDocument(fHandle, tLine=0, doScroll=True) + self.openDocument(fHandle, tLine=1, doScroll=True) return False return False diff --git a/sample/content/636b6aa9b697b.nwd b/sample/content/636b6aa9b697b.nwd index f726f89d..de7f32d7 100644 --- a/sample/content/636b6aa9b697b.nwd +++ b/sample/content/636b6aa9b697b.nwd @@ -1,8 +1,8 @@ %%~name: Making a Scene %%~path: 6a2d6d5f4f401/636b6aa9b697b %%~kind: NOVEL/DOCUMENT -%%~hash: 053cc65631403c15ddc112849dc7fcae44eb9d63 -%%~date: Unknown/2023-08-25 16:56:11 +%%~hash: 7aae771de46c3cab06d8be0e860dc0bd383860a5 +%%~date: Unknown/2023-09-07 19:00:10 ### Making a Scene @pov: Jane @@ -15,7 +15,7 @@ Each paragraph in the scene is separated by a blank line. The text supports mini In addition, the editor supports automatic formatting of “quotes”, both double and ‘single’. Depending on the syntax highlighter settings and colour theme, these can be in different colours. “You can of course use **bold** and _italic_ text inside of quotes too.” -If you have the need for it, you can also add text that can be automatically replaced by other text when you generate a preview or export the project. Now, let’s auto-replace this A with , and this C with . While is just . Press Ctrl+R to see what this looks like in the view pane. The list of auto-replaced text is sett in Project Settings. +If you have the need for it, you can also add text that can be automatically replaced by other text when you generate a preview or export the project. Now, let’s auto-replace this A with , and this C with . While is just . Press Ctrl+R to see what this looks like in the view pane. The list of auto-replaced text is set in Project Settings. The editor also supports non breaking spaces, and the spell checker accepts long dashes—like this—as valid word separators. Regular dashes are also supported – and can be automatically inserted when typing two hyphens. diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 8b65ed19..3128d766 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Sample Project Sample Project Jane Smith @@ -58,27 +58,27 @@ Chapter One - + Making a Scene - + Another Scene - + Interlude - + A Note on Structure - + Chapter Two - + We Found John! @@ -90,7 +90,7 @@ Title Page - + Chapter One @@ -102,11 +102,11 @@ Main Characters - + John Smith - + Jane Smith @@ -114,15 +114,15 @@ Locations - + Earth - + Space - + Mars