Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve spell checking #2030

Merged
merged 2 commits into from
Sep 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion novelwriter/core/coretools.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ def searchText(self, text: str) -> tuple[list[tuple[int, int, str]], bool]:
count = 0
capped = False
results = []
for match in re.finditer(self._regEx, text):
for match in self._regEx.finditer(text):
pos = match.start(0)
num = len(match.group(0))
lim = text[:pos].rfind("\n") + 1
Expand Down
23 changes: 9 additions & 14 deletions novelwriter/core/spellcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,21 +125,16 @@ def suggestWords(self, word: str) -> list[str]:
except Exception:
return []

def addWord(self, word: str) -> bool:
def addWord(self, word: str, save: bool = True) -> None:
"""Add a word to the project dictionary."""
word = word.strip()
if not word:
return False
try:
self._enchant.add_to_session(word)
except Exception:
return False

added = self._userDict.add(word)
if added:
self._userDict.save()

return added
if word := word.strip():
try:
self._enchant.add_to_session(word)
except Exception:
return
if save and self._userDict.add(word):
self._userDict.save()
return

def listDictionaries(self) -> list[tuple[str, str]]:
"""List available dictionaries."""
Expand Down
8 changes: 4 additions & 4 deletions novelwriter/core/tokenizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1109,14 +1109,14 @@ def _extractFormats(

# Match Markdown
for regEx, fmts in self._rxMarkdown:
for match in re.finditer(regEx, text):
for match in regEx.finditer(text):
temp.extend(
(match.start(n), match.end(n), fmt, "")
for n, fmt in enumerate(fmts) if fmt > 0
)

# Match Shortcodes
for match in re.finditer(REGEX_PATTERNS.shortcodePlain, text):
for match in REGEX_PATTERNS.shortcodePlain.finditer(text):
temp.append((
match.start(1), match.end(1),
self._shortCodeFmt.get(match.group(1).lower(), 0),
Expand All @@ -1125,7 +1125,7 @@ def _extractFormats(

# Match Shortcode w/Values
tHandle = self._handle or ""
for match in re.finditer(REGEX_PATTERNS.shortcodeValue, text):
for match in REGEX_PATTERNS.shortcodeValue.finditer(text):
kind = self._shortCodeVals.get(match.group(1).lower(), 0)
temp.append((
match.start(0), match.end(0),
Expand All @@ -1136,7 +1136,7 @@ def _extractFormats(
# Match Dialogue
if self._rxDialogue and hDialog:
for regEx, fmtB, fmtE in self._rxDialogue:
for match in re.finditer(regEx, text):
for match in regEx.finditer(text):
temp.append((match.start(0), 0, fmtB, ""))
temp.append((match.end(0), 0, fmtE, ""))

Expand Down
50 changes: 25 additions & 25 deletions novelwriter/gui/doceditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1164,39 +1164,17 @@ def _openContextMenu(self, pos: QPoint) -> None:
ctxMenu.addAction(f"{nwUnicode.U_ENDASH} {trNone}")

ctxMenu.addSeparator()
action = ctxMenu.addAction(self.tr("Ignore Word"))
action.triggered.connect(lambda: self._addWord(word, block, False))
action = ctxMenu.addAction(self.tr("Add Word to Dictionary"))
action.triggered.connect(lambda: self._addWord(word, block))
action.triggered.connect(lambda: self._addWord(word, block, True))

# Execute the context menu
ctxMenu.exec(self.viewport().mapToGlobal(pos))
ctxMenu.deleteLater()

return

@pyqtSlot("QTextCursor", str)
def _correctWord(self, cursor: QTextCursor, word: str) -> None:
"""Slot for the spell check context menu triggering the
replacement of a word with the word from the dictionary.
"""
pos = cursor.selectionStart()
cursor.beginEditBlock()
cursor.removeSelectedText()
cursor.insertText(word)
cursor.endEditBlock()
cursor.setPosition(pos)
self.setTextCursor(cursor)
return

@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.
"""
logger.debug("Added '%s' to project dictionary", word)
SHARED.spelling.addWord(word)
self._qDocument.syntaxHighlighter.rehighlightBlock(block)
return

@pyqtSlot()
def _runDocumentTasks(self) -> None:
"""Run timer document tasks."""
Expand Down Expand Up @@ -1875,6 +1853,28 @@ def _insertCommentStructure(self, style: nwComment) -> None:
# Internal Functions
##

def _correctWord(self, cursor: QTextCursor, word: str) -> None:
"""Slot for the spell check context menu triggering the
replacement of a word with the word from the dictionary.
"""
pos = cursor.selectionStart()
cursor.beginEditBlock()
cursor.removeSelectedText()
cursor.insertText(word)
cursor.endEditBlock()
cursor.setPosition(pos)
self.setTextCursor(cursor)
return

def _addWord(self, word: str, block: QTextBlock, save: bool) -> None:
"""Slot for the spell check context menu triggered when the user
wants to add a word to the project dictionary.
"""
logger.debug("Added '%s' to project dictionary, %s", word, "saved" if save else "unsaved")
SHARED.spelling.addWord(word, save=save)
self._qDocument.syntaxHighlighter.rehighlightBlock(block)
return

def _processTag(self, cursor: QTextCursor | None = None,
follow: bool = True, create: bool = False) -> nwTrinary:
"""Activated by Ctrl+Enter. Checks that we're in a block
Expand Down
15 changes: 7 additions & 8 deletions novelwriter/gui/dochighlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,20 +483,19 @@ def spellCheck(self, text: str, offset: int) -> list[tuple[int, int]]:
"""
if "[" in text:
# Strip shortcodes
for rX in [RX_FMT_SC, RX_FMT_SV]:
for match in re.finditer(rX, text[offset:]):
iS = match.start(0) + offset
iE = match.end(0) + offset
if iS >= 0 and iE >= 0:
text = text[:iS] + " "*(iE - iS) + text[iE:]
for regEx in [RX_FMT_SC, RX_FMT_SV]:
for match in regEx.finditer(text, offset):
if (s := match.start(0)) >= 0 and (e := match.end(0)) >= 0:
pad = " "*(e - s)
text = f"{text[:s]}{pad}{text[e:]}"

self._spellErrors = []
checker = SHARED.spelling
for match in re.finditer(RX_WORDS, text[offset:].replace("_", " ")):
for match in RX_WORDS.finditer(text.replace("_", " ")):
if (
(word := match.group(0))
and not (word.isnumeric() or word.isupper() or checker.checkWord(word))
):
self._spellErrors.append((match.start(0) + offset, match.end(0) + offset))
self._spellErrors.append((match.start(0), match.end(0)))

return self._spellErrors
12 changes: 9 additions & 3 deletions tests/test_core/test_core_spellcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,21 @@ def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
assert isinstance(spChk._enchant, FakeEnchant)
assert spChk.checkWord("word") is True
assert spChk.suggestWords("word") == []
assert spChk.addWord("word") is True
spChk.addWord("word")
assert "word" in spChk._userDict

# Set the dict to None, and check enchant error handling
spChk = NWSpellEnchant(project)
spChk._enchant = None # type: ignore
assert spChk.checkWord("word") is True
assert spChk.suggestWords("word") == []
assert spChk.addWord("word") is False
assert spChk.addWord("\n\t ") is False

spChk.addWord("word")
assert "word" not in spChk._userDict

spChk.addWord("\n\t ")
assert "\n\t " not in spChk._userDict

assert spChk.describeDict() == ("", "")

# Load the proper enchant package (twice)
Expand Down
6 changes: 5 additions & 1 deletion tests/test_gui/test_gui_doceditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,9 +453,13 @@ def testGuiEditor_SpellChecking(qtbot, monkeypatch, nwGUI, projPath, ipsumText,
ctxMenu = getMenuForPos(docEditor, 16)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert "Ignore Word" in actions
assert "Add Word to Dictionary" in actions

assert "Lorax" not in SHARED.spelling._userDict
ctxMenu.actions()[7].trigger()
ctxMenu.actions()[7].trigger() # Ignore
assert "Lorax" not in SHARED.spelling._userDict
ctxMenu.actions()[8].trigger() # Add
assert "Lorax" in SHARED.spelling._userDict
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_text/test_text_patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
def allMatches(regEx: re.Pattern, text: str) -> list[list[str]]:
"""Get all matches for a regex."""
result = []
for match in re.finditer(regEx, text):
for match in regEx.finditer(text):
result.append([
(match.group(n), match.start(n), match.end(n))
for n in range((match.lastindex or 0) + 1)
Expand Down