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)
# 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()
+46 -22
View File
@@ -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
+48 -4
View File
@@ -24,12 +24,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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