Move the spell check instance to the shared class

This commit is contained in:
Veronica Berglyd Olsen
2023-08-24 20:17:23 +02:00
parent 4507489f0b
commit f7ed133b2e
9 changed files with 95 additions and 98 deletions
+36 -34
View File
@@ -47,11 +47,15 @@ class NWSpellEnchant:
def __init__(self, project: NWProject) -> None:
self._project = project
self._dictObj = FakeEnchant()
self._enchant = FakeEnchant()
self._userDict = UserDictionary(project)
self._language = None
self._broker = None
logger.debug("Enchant spell checking activated")
logger.debug("Ready: NWSpellEnchant")
return
def __del__(self): # pragma: no cover
logger.debug("Delete: NWSpellEnchant")
return
##
@@ -72,7 +76,7 @@ class NWSpellEnchant:
crash. Note that enchant will allow loading an empty string as
a tag, but this will fail later on. See issue #1096.
"""
self._dictObj = FakeEnchant()
self._enchant = FakeEnchant()
self._broker = None
self._language = None
@@ -81,7 +85,7 @@ class NWSpellEnchant:
if language and enchant.dict_exists(language):
self._broker = enchant.Broker()
self._dictObj = self._broker.request_dict(language)
self._enchant = self._broker.request_dict(language)
self._language = language
logger.debug("Enchant spell checking for language '%s' loaded", language)
else:
@@ -90,12 +94,11 @@ class NWSpellEnchant:
except Exception:
logger.error("Failed to load enchant spell checking for language '%s'", language)
if self._dictObj is None:
self._dictObj = FakeEnchant()
if self._enchant is None:
self._enchant = FakeEnchant()
else:
self._userDict.load()
for pWord in self._userDict:
self._dictObj.add_to_session(pWord)
for word in self._userDict:
self._enchant.add_to_session(word)
return
@@ -106,14 +109,14 @@ class NWSpellEnchant:
def checkWord(self, word: str) -> bool:
"""Wrapper function for pyenchant."""
try:
return bool(self._dictObj.check(word))
return bool(self._enchant.check(word))
except Exception:
return True
def suggestWords(self, word: str) -> list[str]:
"""Wrapper function for pyenchant."""
try:
return self._dictObj.suggest(word)
return self._enchant.suggest(word)
except Exception:
return []
@@ -123,7 +126,7 @@ class NWSpellEnchant:
if not word:
return False
try:
self._dictObj.add_to_session(word)
self._enchant.add_to_session(word)
except Exception:
return False
@@ -133,6 +136,11 @@ class NWSpellEnchant:
return added
def loadUserWordList(self) -> None:
"""Load the user word list from the project."""
self._userDict.load()
return
def listDictionaries(self) -> list[tuple[str, str]]:
"""Wrapper function for pyenchant."""
retList = []
@@ -142,22 +150,18 @@ class NWSpellEnchant:
retList.append((spTag, spProvider.name))
except Exception:
logger.error("Failed to list languages for enchant spell checking")
return retList
def describeDict(self) -> tuple[str, str]:
"""Return the tag and provider of the currently loaded
dictionary.
"""
"""Describe the currently loaded dictionary."""
try:
tag = self._dictObj.tag
name = self._dictObj.provider.name # type: ignore
tag = self._enchant.tag
name = self._enchant.provider.name # type: ignore
except Exception:
logger.error("Failed to extract information about the dictionary")
logException()
tag = ""
name = ""
return tag, name
# END Class NWSpellEnchant
@@ -192,7 +196,6 @@ class UserDictionary:
def __init__(self, project: NWProject) -> None:
self._project = project
self._words = set()
self._path = None
return
def __contains__(self, word: str) -> bool:
@@ -212,13 +215,14 @@ class UserDictionary:
def load(self) -> None:
"""Load the user's dictionary."""
self._path = self._project.storage.getMetaFile(nwFiles.DICT_FILE)
self._words = set()
if isinstance(self._path, Path) and self._path.is_file():
wordList = self._project.storage.getMetaFile(nwFiles.DICT_FILE)
if isinstance(wordList, Path) and wordList.is_file():
try:
with open(self._path, mode="r", encoding="utf-8") as fObj:
with open(wordList, mode="r", encoding="utf-8") as fObj:
data = json.load(fObj)
self._words = set(data.get("novelWriter.userDict", []))
logger.info("Loaded: %s", nwFiles.DICT_FILE)
except Exception:
logger.error("Failed to load user dictionary")
logException()
@@ -226,17 +230,15 @@ class UserDictionary:
def save(self) -> None:
"""Save the user's dictionary."""
if self._path is None:
self._path = self._project.storage.getMetaFile(nwFiles.DICT_FILE)
if not isinstance(self._path, Path):
return
try:
with open(self._path, mode="w", encoding="utf-8") as fObj:
data = {"novelWriter.userDict": list(self._words)}
json.dump(data, fObj, indent=2)
except Exception:
logger.error("Failed to save user dictionary")
logException()
wordList = self._project.storage.getMetaFile(nwFiles.DICT_FILE)
if isinstance(wordList, Path):
try:
with open(wordList, mode="w", encoding="utf-8") as fObj:
data = {"novelWriter.userDict": list(self._words)}
json.dump(data, fObj, indent=2)
except Exception:
logger.error("Failed to save user dictionary")
logException()
return
# END Class UserDictionary
+1 -5
View File
@@ -49,8 +49,6 @@ class GuiPreferences(NPagedDialog):
logger.debug("Create: GuiPreferences")
self.setObjectName("GuiPreferences")
self.mainGui = mainGui
self.setWindowTitle(self.tr("Preferences"))
self.tabGeneral = GuiPreferencesGeneral(self)
@@ -645,8 +643,6 @@ class GuiPreferencesEditor(QWidget):
def __init__(self, prefsGui):
super().__init__(parent=prefsGui)
self.mainGui = prefsGui.mainGui
# The Form
self.mainForm = NConfigLayout()
self.mainForm.setHelpTextStyle(SHARED.theme.helpText)
@@ -662,7 +658,7 @@ class GuiPreferencesEditor(QWidget):
self.spellLanguage = QComboBox(self)
self.spellLanguage.setMaximumWidth(mW)
langAvail = self.mainGui.docEditor.spEnchant.listDictionaries()
langAvail = SHARED.spelling.listDictionaries()
if CONFIG.hasEnchant and langAvail:
for spTag, spProv in langAvail:
qLocal = QLocale(spTag)
+1 -3
View File
@@ -199,8 +199,6 @@ class GuiProjectEditMain(QWidget):
def __init__(self, projGui):
super().__init__(parent=projGui)
self.mainGui = projGui.mainGui
# The Form
self.mainForm = NConfigLayout()
self.mainForm.setHelpTextStyle(SHARED.theme.helpText)
@@ -244,7 +242,7 @@ class GuiProjectEditMain(QWidget):
self.spellLang = QComboBox(self)
self.spellLang.setMaximumWidth(xW)
self.spellLang.addItem(self.tr("Default"), "None")
langAvail = self.mainGui.docEditor.spEnchant.listDictionaries()
langAvail = SHARED.spelling.listDictionaries()
for spTag, spProv in langAvail:
qLocal = QLocale(spTag)
spLang = qLocal.nativeLanguageName().title()
+20 -22
View File
@@ -54,7 +54,6 @@ from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass
from novelwriter.common import minmax, transferCase
from novelwriter.constants import nwConst, nwKeyWords, nwUnicode
from novelwriter.core.index import countWords
from novelwriter.core.spellcheck import NWSpellEnchant
from novelwriter.gui.dochighlight import GuiDocHighlighter
if TYPE_CHECKING: # pragma: no cover
@@ -133,8 +132,7 @@ class GuiDocEditor(QTextEdit):
self.docSearch = GuiDocEditSearch(self)
# Syntax
self.spEnchant = NWSpellEnchant(SHARED.project)
self.highLight = GuiDocHighlighter(qDoc, self.spEnchant)
self.highLight = GuiDocHighlighter(qDoc)
# Context Menu
self.setContextMenuPolicy(Qt.CustomContextMenu)
@@ -702,8 +700,8 @@ class GuiDocEditor(QTextEdit):
else:
theLang = SHARED.project.data.spellLang
self.spEnchant.setLanguage(theLang)
_, theProvider = self.spEnchant.describeDict()
SHARED.spelling.setLanguage(theLang)
_, theProvider = SHARED.spelling.describeDict()
self.spellDictionaryChanged.emit(str(theLang), str(theProvider))
if not self._bigDoc:
@@ -711,37 +709,37 @@ class GuiDocEditor(QTextEdit):
return True
def toggleSpellCheck(self, theMode):
def toggleSpellCheck(self, state: bool) -> None:
"""This is the main spell check setting function, and this one
should call all other setSpellCheck functions in other classes.
If the spell check mode (theMode) is not defined (None), then
toggle the current status saved in this class.
"""
if theMode is None:
theMode = not self._spellCheck
if state is None:
state = not self._spellCheck
if not CONFIG.hasEnchant:
if theMode:
if state:
SHARED.info(self.tr(
"Spell checking requires the package PyEnchant. "
"It does not appear to be installed."
))
theMode = False
state = False
if self.spEnchant.spellLanguage is None:
theMode = False
if SHARED.spelling.spellLanguage is None:
state = False
self._spellCheck = theMode
self.mainGui.mainMenu.setSpellCheck(theMode)
SHARED.project.data.setSpellCheck(theMode)
self.highLight.setSpellCheck(theMode)
if not self._bigDoc or theMode is False:
self._spellCheck = state
self.mainGui.mainMenu.setSpellCheck(state)
SHARED.project.data.setSpellCheck(state)
self.highLight.setSpellCheck(state)
if not self._bigDoc or state is False:
# We don't run the spell checker automatically on big docs
self.spellCheckDocument()
logger.debug("Spell check is set to '%s'", str(theMode))
logger.debug("Spell check is set to '%s'", str(state))
return True
return
def spellCheckDocument(self) -> None:
"""Rerun the highlighter to update spell checking status of the
@@ -1193,14 +1191,14 @@ class GuiDocEditor(QTextEdit):
if spellCheck:
logger.debug("Looking up '%s' in the dictionary", theWord)
spellCheck &= not self.spEnchant.checkWord(theWord)
spellCheck &= not SHARED.spelling.checkWord(theWord)
if spellCheck:
mnuContext.addSeparator()
mnuHead = QAction(self.tr("Spelling Suggestion(s)"), mnuContext)
mnuContext.addAction(mnuHead)
theSuggest = self.spEnchant.suggestWords(theWord)[:15]
theSuggest = SHARED.spelling.suggestWords(theWord)[:15]
if len(theSuggest) > 0:
for aWord in theSuggest:
mnuWord = QAction("%s %s" % (nwUnicode.U_ENDASH, aWord), mnuContext)
@@ -1245,7 +1243,7 @@ class GuiDocEditor(QTextEdit):
"""
theWord = theCursor.selectedText().strip().strip(self._nonWord)
logger.debug("Added '%s' to project dictionary", theWord)
self.spEnchant.addWord(theWord)
SHARED.spelling.addWord(theWord)
self.highLight.rehighlightBlock(theCursor.block())
return
+4 -6
View File
@@ -29,7 +29,7 @@ from time import time
from PyQt5.QtCore import Qt, QRegularExpression
from PyQt5.QtGui import (
QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush
QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush, QTextDocument
)
from novelwriter import CONFIG, SHARED
@@ -46,13 +46,11 @@ class GuiDocHighlighter(QSyntaxHighlighter):
BLOCK_META = 2
BLOCK_TITLE = 4
def __init__(self, theDoc, spEnchant):
super().__init__(theDoc)
def __init__(self, document: QTextDocument) -> None:
super().__init__(document)
logger.debug("Create: GuiDocHighlighter")
self.theDoc = theDoc
self.spEnchant = spEnchant
self.theHandle = None
self.spellCheck = False
self.spellRx = None
@@ -387,7 +385,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
rxSpell = self.spellRx.globalMatch(theText.replace("_", " "), 0)
while rxSpell.hasNext():
rxMatch = rxSpell.next()
if not self.spEnchant.checkWord(rxMatch.captured(0)):
if not SHARED.spelling.checkWord(rxMatch.captured(0)):
if rxMatch.captured(0).isupper() or rxMatch.captured(0).isnumeric():
continue
xPos = rxMatch.capturedStart(0)
+16 -2
View File
@@ -32,6 +32,8 @@ from pathlib import Path
from PyQt5.QtCore import QObject, pyqtSignal
from PyQt5.QtWidgets import QMessageBox, QWidget
from novelwriter.core.spellcheck import NWSpellEnchant
if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain
from novelwriter.gui.theme import GuiTheme
@@ -43,18 +45,20 @@ logger = logging.getLogger(__name__)
class SharedData(QObject):
__slots__ = (
"_gui", "_theme", "_project", "_lockedBy", "_alert",
"_gui", "_theme", "_project", "_spelling", "_lockedBy", "_alert",
"_idleTime", "_idleRefTime",
)
projectStatusChanged = pyqtSignal(bool)
projectStatusMessage = pyqtSignal(str)
spellLanguageChanged = pyqtSignal(str, str)
def __init__(self) -> None:
super().__init__()
self._gui = None
self._theme = None
self._project = None
self._spelling = None
self._lockedBy = None
self._alert = None
self._idleTime = 0.0
@@ -82,6 +86,13 @@ class SharedData(QObject):
raise Exception("SharedData class not fully initialised")
return self._project
@property
def spelling(self) -> NWSpellEnchant:
"""Return the active NWProject instance."""
if self._spelling is None:
raise Exception("SharedData class not fully initialised")
return self._spelling
@property
def hasProject(self) -> bool:
"""Return True if the project instance is populated."""
@@ -130,6 +141,7 @@ class SharedData(QObject):
self._lockedBy = self.project.lockStatus
self._resetProject()
self.spelling.loadUserWordList()
self._resetIdleTimer()
return status
@@ -220,11 +232,13 @@ class SharedData(QObject):
##
def _resetProject(self) -> None:
"""Create a new project instance."""
"""Create a new project and spell checking instance."""
from novelwriter.core.project import NWProject
if isinstance(self._project, NWProject):
del self._project
del self._spelling
self._project = NWProject()
self._spelling = NWSpellEnchant(self._project)
return
def _resetIdleTimer(self) -> None:
+10 -17
View File
@@ -58,17 +58,14 @@ def testCoreSpell_UserDictionary(monkeypatch, mockGUI, fncPath):
assert sorted(userDict) == ["bar", "foo"]
# Save the file, but fail
assert userDict._path is None
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
userDict.save()
# There should be no file, but the file path should now be cached
assert userDict._path == dictFile
# There should be no file
assert not dictFile.exists()
# Break the path check
userDict._path = None
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
userDict.save()
@@ -85,23 +82,19 @@ def testCoreSpell_UserDictionary(monkeypatch, mockGUI, fncPath):
assert sorted(userDict) == []
# Load the file, but fail
userDict._path = None
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
userDict.load()
# Path is now set, but no words
assert userDict._path == dictFile
# No words loaded
assert sorted(userDict) == []
# Break the path check
userDict._path = None
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
userDict.load()
# Path is now None, and no words
assert userDict._path is None
# No words loaded
assert sorted(userDict) == []
# Load the words again, properly
@@ -122,18 +115,18 @@ def testCoreSpell_FakeEnchant(monkeypatch, mockGUI, fncPath):
mp.setitem(sys.modules, "enchant", None)
spChk = NWSpellEnchant(project)
spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, FakeEnchant)
assert isinstance(spChk._enchant, FakeEnchant)
# Request a non-existent dictionary
spChk = NWSpellEnchant(project)
spChk.setLanguage("whatchamajig")
assert isinstance(spChk._dictObj, FakeEnchant)
assert isinstance(spChk._enchant, FakeEnchant)
# Request an empty language string
# See issue https://github.com/vkbo/novelWriter/issues/1096
spChk = NWSpellEnchant(project)
spChk.setLanguage("")
assert isinstance(spChk._dictObj, FakeEnchant)
assert isinstance(spChk._enchant, FakeEnchant)
# FakeEnchant should handle requests
fkChk = FakeEnchant()
@@ -164,14 +157,14 @@ def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
assert spChk.spellLanguage is None
# Check that the FakeEnchant class is actually handling this
assert isinstance(spChk._dictObj, FakeEnchant)
assert isinstance(spChk._enchant, FakeEnchant)
assert spChk.checkWord("word") is True
assert spChk.suggestWords("word") == []
assert spChk.addWord("word") is True
# Set the dict to None, and check enchant error handling
spChk = NWSpellEnchant(project)
spChk._dictObj = None # type: ignore
spChk._enchant = None # type: ignore
assert spChk.checkWord("word") is True
assert spChk.suggestWords("word") == []
assert spChk.addWord("word") is False
@@ -182,7 +175,7 @@ def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
spChk = NWSpellEnchant(project)
spChk.setLanguage("en_US")
spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, enchant.Dict)
assert isinstance(spChk._enchant, enchant.Dict)
assert spChk.spellLanguage == "en_US"
assert spChk.listDictionaries() != []
assert spChk.describeDict() != ("", "")
@@ -194,6 +187,6 @@ def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
with monkeypatch.context() as mp:
mp.setattr("enchant.Broker.request_dict", lambda *a: None)
spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, FakeEnchant)
assert isinstance(spChk._enchant, FakeEnchant)
# END Test testCoreSpell_Enchant
+2 -2
View File
@@ -30,7 +30,7 @@ from PyQt5.QtWidgets import (
QDialogButtonBox, QDialog, QAction, QFileDialog, QFontDialog
)
from novelwriter import CONFIG
from novelwriter import CONFIG, SHARED
from novelwriter.dialogs.quotes import GuiQuoteSelect
from novelwriter.dialogs.preferences import GuiPreferences
@@ -42,7 +42,7 @@ def testDlgPreferences_Main(qtbot, monkeypatch, nwGUI, tstPaths):
"""Test the preferences dialog."""
monkeypatch.setattr(GuiPreferences, "exec_", lambda *a: None)
monkeypatch.setattr(GuiPreferences, "result", lambda *a: QDialog.Accepted)
monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")])
monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "none")])
with monkeypatch.context() as mp:
mp.setattr(GuiPreferences, "updateTheme", lambda *a: True)
+5 -7
View File
@@ -84,10 +84,9 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI):
@pytest.mark.gui
def testDlgProjSettings_Main(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd):
"""Test the main tab of the project settings dialog.
"""
"""Test the main tab of the project settings dialog."""
# Mock components
monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")])
monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "none")])
# Create new project
buildTestProject(nwGUI, projPath)
@@ -152,7 +151,7 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPat
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
# Mock components
monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")])
monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "none")])
# Create new project
mockRnd.reset()
@@ -348,12 +347,11 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPat
@pytest.mark.gui
def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd):
"""Test the auto-replace tab of the project settings dialog.
"""
"""Test the auto-replace tab of the project settings dialog."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
# Mock components
monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")])
monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "none")])
# Create new project
mockRnd.reset()