diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 9b4cd827..dc9b93af 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -318,16 +318,16 @@ class GuiDocEditor(QPlainTextEdit): self.setViewportMargins(self._vpMargin, self._vpMargin, self._vpMargin, self._vpMargin) # Also set the document text options for the document text flow - theOpt = QTextOption() + options = QTextOption() if CONFIG.doJustify: - theOpt.setAlignment(Qt.AlignJustify) + options.setAlignment(Qt.AlignJustify) if CONFIG.showTabsNSpaces: - theOpt.setFlags(theOpt.flags() | QTextOption.ShowTabsAndSpaces) + options.setFlags(options.flags() | QTextOption.ShowTabsAndSpaces) if CONFIG.showLineEndings: - theOpt.setFlags(theOpt.flags() | QTextOption.ShowLineAndParagraphSeparators) + options.setFlags(options.flags() | QTextOption.ShowLineAndParagraphSeparators) - self._qDocument.setDefaultTextOption(theOpt) + self._qDocument.setDefaultTextOption(options) # Scroll bars if CONFIG.hideVScroll: @@ -378,11 +378,9 @@ class GuiDocEditor(QPlainTextEdit): qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) self._docHandle = tHandle - tStart = time() self._allowAutoReplace(False) self._qDocument.setTextContent(docText, tHandle) self._allowAutoReplace(True) - logger.debug("Document text set in %.3f ms", 1000*(time() - tStart)) qApp.processEvents() self._lastEdit = time() @@ -1003,100 +1001,63 @@ class GuiDocEditor(QPlainTextEdit): """Triggered by right click to open the context menu. Also triggered by the Ctrl+. shortcut. """ - userCursor = self.textCursor() - userSelection = userCursor.hasSelection() - posCursor = self.cursorForPosition(pos) + uCursor = self.textCursor() + pCursor = self.cursorForPosition(pos) - mnuContext = QMenu() + ctxMenu = QMenu() - # Follow, Cut, Copy and Paste - # =========================== + # Follow + if self._followTag(cursor=pCursor, loadTag=False): + aTag = ctxMenu.addAction(self.tr("Follow Tag")) + aTag.triggered.connect(lambda: self._followTag(cursor=pCursor)) + ctxMenu.addSeparator() - if self._followTag(cursor=posCursor, loadTag=False): - mnuTag = QAction(self.tr("Follow Tag"), mnuContext) - mnuTag.triggered.connect(lambda: self._followTag(cursor=posCursor)) - mnuContext.addAction(mnuTag) - mnuContext.addSeparator() + # Cut, Copy and Paste + if uCursor.hasSelection(): + aCut = ctxMenu.addAction(self.tr("Cut")) + aCut.triggered.connect(lambda: self.docAction(nwDocAction.CUT)) + aCopy = ctxMenu.addAction(self.tr("Copy")) + aCopy.triggered.connect(lambda: self.docAction(nwDocAction.COPY)) - if userSelection: - mnuCut = QAction(self.tr("Cut"), mnuContext) - mnuCut.triggered.connect(lambda: self.docAction(nwDocAction.CUT)) - mnuContext.addAction(mnuCut) - - mnuCopy = QAction(self.tr("Copy"), mnuContext) - mnuCopy.triggered.connect(lambda: self.docAction(nwDocAction.COPY)) - mnuContext.addAction(mnuCopy) - - mnuPaste = QAction(self.tr("Paste"), mnuContext) - mnuPaste.triggered.connect(lambda: self.docAction(nwDocAction.PASTE)) - mnuContext.addAction(mnuPaste) - - mnuContext.addSeparator() + aPaste = ctxMenu.addAction(self.tr("Paste")) + aPaste.triggered.connect(lambda: self.docAction(nwDocAction.PASTE)) + ctxMenu.addSeparator() # Selections - # ========== - - mnuSelAll = QAction(self.tr("Select All"), mnuContext) - mnuSelAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL)) - mnuContext.addAction(mnuSelAll) - - mnuSelWord = QAction(self.tr("Select Word"), mnuContext) - mnuSelWord.triggered.connect( - lambda: self._makePosSelection(QTextCursor.WordUnderCursor, pos) - ) - mnuContext.addAction(mnuSelWord) - - mnuSelPara = QAction(self.tr("Select Paragraph"), mnuContext) - mnuSelPara.triggered.connect( - lambda: self._makePosSelection(QTextCursor.BlockUnderCursor, pos) - ) - mnuContext.addAction(mnuSelPara) + aSAll = ctxMenu.addAction(self.tr("Select All")) + aSAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL)) + aSWrd = ctxMenu.addAction(self.tr("Select Word")) + aSWrd.triggered.connect(lambda: self._makePosSelection(QTextCursor.WordUnderCursor, pos)) + aSPar = ctxMenu.addAction(self.tr("Select Paragraph")) + aSPar.triggered.connect(lambda: self._makePosSelection(QTextCursor.BlockUnderCursor, pos)) # Spell Checking - # ============== + if self._spellCheck: + word, cPos, cLen, suggest = self._qDocument.spellErrorAtPos(pCursor.position()) + if word and cPos >= 0 and cLen > 0: + logger.debug("Word '%s' is misspelled", word) + block = pCursor.block() + sCursor = self.textCursor() + sCursor.setPosition(block.position() + cPos) + sCursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, cLen) + if suggest: + ctxMenu.addSeparator() + ctxMenu.addAction(self.tr("Spelling Suggestion(s)")) + for option in suggest: + aFix = ctxMenu.addAction(f"{nwUnicode.U_ENDASH} {option}") + aFix.triggered.connect( + lambda _, option=option: self._correctWord(sCursor, option) + ) + else: + ctxMenu.addAction("%s %s" % (nwUnicode.U_ENDASH, self.tr("No Suggestions"))) - posCursor = self.cursorForPosition(pos) - spellCheck = self._spellCheck - theWord = "" + ctxMenu.addSeparator() + aAdd = QAction(self.tr("Add Word to Dictionary"), ctxMenu) + aAdd.triggered.connect(lambda: self._addWord(word, block)) + ctxMenu.addAction(aAdd) - if posCursor.block().text().startswith("@"): - spellCheck = False - - if spellCheck: - posCursor.select(QTextCursor.WordUnderCursor) - theWord = posCursor.selectedText().strip().strip(self._nonWord) - spellCheck &= theWord != "" - - if spellCheck: - logger.debug("Looking up '%s' in the dictionary", theWord) - spellCheck &= not SHARED.spelling.checkWord(theWord) - - if spellCheck: - mnuContext.addSeparator() - mnuHead = QAction(self.tr("Spelling Suggestion(s)"), mnuContext) - mnuContext.addAction(mnuHead) - - theSuggest = SHARED.spelling.suggestWords(theWord)[:15] - if len(theSuggest) > 0: - for aWord in theSuggest: - mnuWord = QAction("%s %s" % (nwUnicode.U_ENDASH, aWord), mnuContext) - mnuWord.triggered.connect( - lambda thePos, aWord=aWord: self._correctWord(posCursor, aWord) - ) - mnuContext.addAction(mnuWord) - else: - mnuHead = QAction( - "%s %s" % (nwUnicode.U_ENDASH, self.tr("No Suggestions")), mnuContext - ) - mnuContext.addAction(mnuHead) - - mnuContext.addSeparator() - mnuAdd = QAction(self.tr("Add Word to Dictionary"), mnuContext) - mnuAdd.triggered.connect(lambda thePos: self._addWord(posCursor)) - mnuContext.addAction(mnuAdd) - - # Open the context menu - mnuContext.exec_(self.viewport().mapToGlobal(pos)) + # Execute the context menu + ctxMenu.exec_(self.viewport().mapToGlobal(pos)) return @@ -1105,24 +1066,23 @@ class GuiDocEditor(QPlainTextEdit): """Slot for the spell check context menu triggering the replacement of a word with the word from the dictionary. """ - xPos = cursor.selectionStart() + pos = cursor.selectionStart() cursor.beginEditBlock() cursor.removeSelectedText() cursor.insertText(word) cursor.endEditBlock() - cursor.setPosition(xPos) + cursor.setPosition(pos) self.setTextCursor(cursor) return - @pyqtSlot("QTextCursor") - def _addWord(self, cursor: QTextCursor) -> None: + @pyqtSlot(str, "QTextBlock") + def _addWord(self, word: str, block: QTextBlock) -> None: """Slot for the spell check context menu triggered when the user wants to add a word to the project dictionary. """ - theWord = cursor.selectedText().strip().strip(self._nonWord) - logger.debug("Added '%s' to project dictionary", theWord) - SHARED.spelling.addWord(theWord) - self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block()) + logger.debug("Added '%s' to project dictionary", word) + SHARED.spelling.addWord(word) + self._qDocument.syntaxHighlighter.rehighlightBlock(block) return @pyqtSlot() diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index 92b9d8ca..9bc8d953 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -29,7 +29,8 @@ from time import time from PyQt5.QtCore import Qt, QRegularExpression from PyQt5.QtGui import ( - QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush, QTextDocument + QBrush, QColor, QFont, QSyntaxHighlighter, QTextBlockUserData, + QTextCharFormat, QTextDocument ) from novelwriter import CONFIG, SHARED @@ -38,6 +39,9 @@ from novelwriter.constants import nwRegEx, nwUnicode logger = logging.getLogger(__name__) +SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—]+\b") +SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) + class GuiDocHighlighter(QSyntaxHighlighter): @@ -54,7 +58,6 @@ class GuiDocHighlighter(QSyntaxHighlighter): self._tItem = None self._tHandle = None self._spellCheck = False - self._spellRx = QRegularExpression() self._hRules: list[tuple[str, dict]] = [] self._hStyles: dict[str, QTextCharFormat] = {} @@ -223,13 +226,6 @@ class GuiDocHighlighter(QSyntaxHighlighter): hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) self.rxRules.append((hReg, regRules)) - # Build a QRegExp for the spell checker - # Include additional characters that the highlighter should - # consider to be word separators - uCode = nwUnicode.U_ENDASH + nwUnicode.U_EMDASH - self._spellRx = QRegularExpression(r"\b[^\s\-\+\/" + uCode + r"]+\b") - self._spellRx.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) - return ## @@ -383,19 +379,17 @@ class GuiDocHighlighter(QSyntaxHighlighter): if not self._spellCheck: return - rxSpell = self._spellRx.globalMatch(text.replace("_", " "), 0) - while rxSpell.hasNext(): - rxMatch = rxSpell.next() - if not SHARED.spelling.checkWord(rxMatch.captured(0)): - if not rxMatch.captured(0).isalpha() or rxMatch.captured(0).isupper(): - continue - xPos = rxMatch.capturedStart(0) - xLen = rxMatch.capturedLength(0) - for x in range(xPos, xPos+xLen): - spFmt = self.format(x) - spFmt.setUnderlineColor(self._colSpell) - spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) - self.setFormat(x, 1, spFmt) + data = self.currentBlockUserData() + if not isinstance(data, TextBlockData): + data = TextBlockData() + self.setCurrentBlockUserData(data) + + for xPos, xLen in data.spellCheck(text): + for x in range(xPos, xPos+xLen): + spFmt = self.format(x) + spFmt.setUnderlineColor(self._colSpell) + spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) + self.setFormat(x, 1, spFmt) return @@ -435,3 +429,33 @@ class GuiDocHighlighter(QSyntaxHighlighter): return charFormat # END Class GuiDocHighlighter + + +class TextBlockData(QTextBlockUserData): + + __slots__ = ("_spellErrors") + + def __init__(self) -> None: + super().__init__() + self._spellErrors: list[tuple[int, int]] = [] + return + + @property + def spellErrors(self) -> list[tuple[int, int]]: + """Return spell error data from last check.""" + return self._spellErrors + + def spellCheck(self, text: str) -> list[tuple[int, int]]: + """Run the spell checker and cache the result, and return the + list of spell check errors. + """ + self._spellErrors = [] + rxSpell = SPELLRX.globalMatch(text.replace("_", " "), 0) + while rxSpell.hasNext(): + rxMatch = rxSpell.next() + if not SHARED.spelling.checkWord(rxMatch.captured(0)): + if rxMatch.captured(0).isalpha() and not rxMatch.captured(0).isupper(): + self._spellErrors.append((rxMatch.capturedStart(0), rxMatch.capturedLength(0))) + return self._spellErrors + +# END Class TextBlockData diff --git a/novelwriter/gui/editordocument.py b/novelwriter/gui/editordocument.py index 434f3914..14d7f4fd 100644 --- a/novelwriter/gui/editordocument.py +++ b/novelwriter/gui/editordocument.py @@ -24,12 +24,15 @@ along with this program. If not, see . from __future__ import annotations import logging + +from time import time + +from PyQt5.QtGui import QTextCursor, QTextDocument from PyQt5.QtCore import QObject +from PyQt5.QtWidgets import QPlainTextDocumentLayout, qApp +from novelwriter import SHARED -from PyQt5.QtGui import QTextDocument -from PyQt5.QtWidgets import QPlainTextDocumentLayout - -from novelwriter.gui.dochighlight import GuiDocHighlighter +from novelwriter.gui.dochighlight import GuiDocHighlighter, TextBlockData logger = logging.getLogger(__name__) @@ -67,7 +70,48 @@ class GuiTextDocument(QTextDocument): def setTextContent(self, text: str, tHandle: str) -> None: """Set the text content of the document.""" self._syntax.setHandle(tHandle) + + self.blockSignals(True) + self.setUndoRedoEnabled(False) + self.clear() + + tStart = time() + self.setPlainText(text) + count = self.lineCount() + + tMid = time() + + self.setUndoRedoEnabled(True) + self.blockSignals(False) + self._syntax.rehighlight() + qApp.processEvents() + + tEnd = time() + + logger.debug("Loaded %d text blocks in %.3f ms", count, 1000*(tMid - tStart)) + logger.debug("Highlighted document in %.3f ms", 1000*(tEnd - tMid)) + return + def spellErrorAtPos(self, pos: int) -> tuple[str, int, int, list[str]]: + """Check if there is a misspelled word at a given position in + the document, and if so, return it. + """ + cursor = QTextCursor(self) + cursor.setPosition(pos) + block = cursor.block() + if block.isValid(): + data = block.userData() + if isinstance(data, TextBlockData): + text = block.text() + check = pos - block.position() + if check >= 0: + for cPos, cLen in data.spellErrors: + cEnd = cPos + cLen + if cPos <= check <= cEnd: + word = text[cPos:cEnd] + return word, cPos, cLen, SHARED.spelling.suggestWords(word) + return "", -1, -1, [] + # END Class GuiTextDocument