From f7ed133b2ea9832bdf82038f6dc786665692d67b Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 24 Aug 2023 20:17:23 +0200 Subject: [PATCH] Move the spell check instance to the shared class --- novelwriter/core/spellcheck.py | 70 +++++++++++---------- novelwriter/dialogs/preferences.py | 6 +- novelwriter/dialogs/projsettings.py | 4 +- novelwriter/gui/doceditor.py | 42 ++++++------- novelwriter/gui/dochighlight.py | 10 ++- novelwriter/shared.py | 18 +++++- tests/test_core/test_core_spellcheck.py | 27 +++----- tests/test_dialogs/test_dlg_preferences.py | 4 +- tests/test_dialogs/test_dlg_projsettings.py | 12 ++-- 9 files changed, 95 insertions(+), 98 deletions(-) diff --git a/novelwriter/core/spellcheck.py b/novelwriter/core/spellcheck.py index 0ebfe654..7f9d7f4f 100644 --- a/novelwriter/core/spellcheck.py +++ b/novelwriter/core/spellcheck.py @@ -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 diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 21bcb95d..642b6362 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -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) diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index dac33288..861c36b6 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -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() diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index cbe7f260..7a2d8ee1 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -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 diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index 9c9c1079..d47d3d48 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -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) diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 262d5727..09427760 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -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: diff --git a/tests/test_core/test_core_spellcheck.py b/tests/test_core/test_core_spellcheck.py index 6b161bca..d20269b5 100644 --- a/tests/test_core/test_core_spellcheck.py +++ b/tests/test_core/test_core_spellcheck.py @@ -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 diff --git a/tests/test_dialogs/test_dlg_preferences.py b/tests/test_dialogs/test_dlg_preferences.py index da1eb274..09a858fc 100644 --- a/tests/test_dialogs/test_dlg_preferences.py +++ b/tests/test_dialogs/test_dlg_preferences.py @@ -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) diff --git a/tests/test_dialogs/test_dlg_projsettings.py b/tests/test_dialogs/test_dlg_projsettings.py index bafdadc7..ddf426b3 100644 --- a/tests/test_dialogs/test_dlg_projsettings.py +++ b/tests/test_dialogs/test_dlg_projsettings.py @@ -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()