Cache spell check errors for usage in the editor when correcting

This commit is contained in:
Veronica Berglyd Olsen
2023-09-10 21:27:34 +02:00
parent 3f91f5c506
commit f1e28e21f8
3 changed files with 153 additions and 125 deletions
+59 -99
View File
@@ -318,16 +318,16 @@ class GuiDocEditor(QPlainTextEdit):
self.setViewportMargins(self._vpMargin, self._vpMargin, self._vpMargin, self._vpMargin) self.setViewportMargins(self._vpMargin, self._vpMargin, self._vpMargin, self._vpMargin)
# Also set the document text options for the document text flow # Also set the document text options for the document text flow
theOpt = QTextOption() options = QTextOption()
if CONFIG.doJustify: if CONFIG.doJustify:
theOpt.setAlignment(Qt.AlignJustify) options.setAlignment(Qt.AlignJustify)
if CONFIG.showTabsNSpaces: if CONFIG.showTabsNSpaces:
theOpt.setFlags(theOpt.flags() | QTextOption.ShowTabsAndSpaces) options.setFlags(options.flags() | QTextOption.ShowTabsAndSpaces)
if CONFIG.showLineEndings: 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 # Scroll bars
if CONFIG.hideVScroll: if CONFIG.hideVScroll:
@@ -378,11 +378,9 @@ class GuiDocEditor(QPlainTextEdit):
qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
self._docHandle = tHandle self._docHandle = tHandle
tStart = time()
self._allowAutoReplace(False) self._allowAutoReplace(False)
self._qDocument.setTextContent(docText, tHandle) self._qDocument.setTextContent(docText, tHandle)
self._allowAutoReplace(True) self._allowAutoReplace(True)
logger.debug("Document text set in %.3f ms", 1000*(time() - tStart))
qApp.processEvents() qApp.processEvents()
self._lastEdit = time() self._lastEdit = time()
@@ -1003,100 +1001,63 @@ class GuiDocEditor(QPlainTextEdit):
"""Triggered by right click to open the context menu. Also """Triggered by right click to open the context menu. Also
triggered by the Ctrl+. shortcut. triggered by the Ctrl+. shortcut.
""" """
userCursor = self.textCursor() uCursor = self.textCursor()
userSelection = userCursor.hasSelection() pCursor = self.cursorForPosition(pos)
posCursor = 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): # Cut, Copy and Paste
mnuTag = QAction(self.tr("Follow Tag"), mnuContext) if uCursor.hasSelection():
mnuTag.triggered.connect(lambda: self._followTag(cursor=posCursor)) aCut = ctxMenu.addAction(self.tr("Cut"))
mnuContext.addAction(mnuTag) aCut.triggered.connect(lambda: self.docAction(nwDocAction.CUT))
mnuContext.addSeparator() aCopy = ctxMenu.addAction(self.tr("Copy"))
aCopy.triggered.connect(lambda: self.docAction(nwDocAction.COPY))
if userSelection: aPaste = ctxMenu.addAction(self.tr("Paste"))
mnuCut = QAction(self.tr("Cut"), mnuContext) aPaste.triggered.connect(lambda: self.docAction(nwDocAction.PASTE))
mnuCut.triggered.connect(lambda: self.docAction(nwDocAction.CUT)) ctxMenu.addSeparator()
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()
# Selections # Selections
# ========== aSAll = ctxMenu.addAction(self.tr("Select All"))
aSAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL))
mnuSelAll = QAction(self.tr("Select All"), mnuContext) aSWrd = ctxMenu.addAction(self.tr("Select Word"))
mnuSelAll.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL)) aSWrd.triggered.connect(lambda: self._makePosSelection(QTextCursor.WordUnderCursor, pos))
mnuContext.addAction(mnuSelAll) aSPar = ctxMenu.addAction(self.tr("Select Paragraph"))
aSPar.triggered.connect(lambda: self._makePosSelection(QTextCursor.BlockUnderCursor, pos))
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)
# Spell Checking # 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) ctxMenu.addSeparator()
spellCheck = self._spellCheck aAdd = QAction(self.tr("Add Word to Dictionary"), ctxMenu)
theWord = "" aAdd.triggered.connect(lambda: self._addWord(word, block))
ctxMenu.addAction(aAdd)
if posCursor.block().text().startswith("@"): # Execute the context menu
spellCheck = False ctxMenu.exec_(self.viewport().mapToGlobal(pos))
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))
return return
@@ -1105,24 +1066,23 @@ class GuiDocEditor(QPlainTextEdit):
"""Slot for the spell check context menu triggering the """Slot for the spell check context menu triggering the
replacement of a word with the word from the dictionary. replacement of a word with the word from the dictionary.
""" """
xPos = cursor.selectionStart() pos = cursor.selectionStart()
cursor.beginEditBlock() cursor.beginEditBlock()
cursor.removeSelectedText() cursor.removeSelectedText()
cursor.insertText(word) cursor.insertText(word)
cursor.endEditBlock() cursor.endEditBlock()
cursor.setPosition(xPos) cursor.setPosition(pos)
self.setTextCursor(cursor) self.setTextCursor(cursor)
return return
@pyqtSlot("QTextCursor") @pyqtSlot(str, "QTextBlock")
def _addWord(self, cursor: QTextCursor) -> None: def _addWord(self, word: str, block: QTextBlock) -> None:
"""Slot for the spell check context menu triggered when the user """Slot for the spell check context menu triggered when the user
wants to add a word to the project dictionary. wants to add a word to the project dictionary.
""" """
theWord = cursor.selectedText().strip().strip(self._nonWord) logger.debug("Added '%s' to project dictionary", word)
logger.debug("Added '%s' to project dictionary", theWord) SHARED.spelling.addWord(word)
SHARED.spelling.addWord(theWord) self._qDocument.syntaxHighlighter.rehighlightBlock(block)
self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
return return
@pyqtSlot() @pyqtSlot()
+46 -22
View File
@@ -29,7 +29,8 @@ from time import time
from PyQt5.QtCore import Qt, QRegularExpression from PyQt5.QtCore import Qt, QRegularExpression
from PyQt5.QtGui import ( from PyQt5.QtGui import (
QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush, QTextDocument QBrush, QColor, QFont, QSyntaxHighlighter, QTextBlockUserData,
QTextCharFormat, QTextDocument
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
@@ -38,6 +39,9 @@ from novelwriter.constants import nwRegEx, nwUnicode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—]+\b")
SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
class GuiDocHighlighter(QSyntaxHighlighter): class GuiDocHighlighter(QSyntaxHighlighter):
@@ -54,7 +58,6 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._tItem = None self._tItem = None
self._tHandle = None self._tHandle = None
self._spellCheck = False self._spellCheck = False
self._spellRx = QRegularExpression()
self._hRules: list[tuple[str, dict]] = [] self._hRules: list[tuple[str, dict]] = []
self._hStyles: dict[str, QTextCharFormat] = {} self._hStyles: dict[str, QTextCharFormat] = {}
@@ -223,13 +226,6 @@ class GuiDocHighlighter(QSyntaxHighlighter):
hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
self.rxRules.append((hReg, regRules)) 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 return
## ##
@@ -383,19 +379,17 @@ class GuiDocHighlighter(QSyntaxHighlighter):
if not self._spellCheck: if not self._spellCheck:
return return
rxSpell = self._spellRx.globalMatch(text.replace("_", " "), 0) data = self.currentBlockUserData()
while rxSpell.hasNext(): if not isinstance(data, TextBlockData):
rxMatch = rxSpell.next() data = TextBlockData()
if not SHARED.spelling.checkWord(rxMatch.captured(0)): self.setCurrentBlockUserData(data)
if not rxMatch.captured(0).isalpha() or rxMatch.captured(0).isupper():
continue for xPos, xLen in data.spellCheck(text):
xPos = rxMatch.capturedStart(0) for x in range(xPos, xPos+xLen):
xLen = rxMatch.capturedLength(0) spFmt = self.format(x)
for x in range(xPos, xPos+xLen): spFmt.setUnderlineColor(self._colSpell)
spFmt = self.format(x) spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
spFmt.setUnderlineColor(self._colSpell) self.setFormat(x, 1, spFmt)
spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
self.setFormat(x, 1, spFmt)
return return
@@ -435,3 +429,33 @@ class GuiDocHighlighter(QSyntaxHighlighter):
return charFormat return charFormat
# END Class GuiDocHighlighter # 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
+48 -4
View File
@@ -24,12 +24,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations from __future__ import annotations
import logging import logging
from time import time
from PyQt5.QtGui import QTextCursor, QTextDocument
from PyQt5.QtCore import QObject from PyQt5.QtCore import QObject
from PyQt5.QtWidgets import QPlainTextDocumentLayout, qApp
from novelwriter import SHARED
from PyQt5.QtGui import QTextDocument from novelwriter.gui.dochighlight import GuiDocHighlighter, TextBlockData
from PyQt5.QtWidgets import QPlainTextDocumentLayout
from novelwriter.gui.dochighlight import GuiDocHighlighter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -67,7 +70,48 @@ class GuiTextDocument(QTextDocument):
def setTextContent(self, text: str, tHandle: str) -> None: def setTextContent(self, text: str, tHandle: str) -> None:
"""Set the text content of the document.""" """Set the text content of the document."""
self._syntax.setHandle(tHandle) self._syntax.setHandle(tHandle)
self.blockSignals(True)
self.setUndoRedoEnabled(False)
self.clear()
tStart = time()
self.setPlainText(text) 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 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 # END Class GuiTextDocument