From 4507489f0b5dda29709c1bf05d7d257f6b117359 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 24 Aug 2023 19:45:08 +0200 Subject: [PATCH 1/6] Clean up a few things in project classes --- novelwriter/core/project.py | 22 ++++++------- novelwriter/core/tree.py | 25 ++++++++------- novelwriter/gui/projtree.py | 6 ++-- novelwriter/guimain.py | 4 +-- novelwriter/shared.py | 48 +++++++++++++---------------- tests/test_core/test_core_tree.py | 26 ++++++++-------- tests/test_gui/test_gui_guimain.py | 4 +-- tests/test_gui/test_gui_projtree.py | 4 +-- 8 files changed, 65 insertions(+), 74 deletions(-) diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index e86776ac..fc59f77f 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -31,7 +31,7 @@ from typing import TYPE_CHECKING, Iterator from pathlib import Path from functools import partial -from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal +from PyQt5.QtCore import QCoreApplication from novelwriter import CONFIG, SHARED, __version__, __hexversion__ from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout @@ -55,13 +55,9 @@ if TYPE_CHECKING: # pragma: no cover logger = logging.getLogger(__name__) -class NWProject(QObject): +class NWProject: - statusChanged = pyqtSignal(bool) - statusMessage = pyqtSignal(str) - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent=parent) + def __init__(self) -> None: # Core Elements self._options = OptionState(self) # Project-specific GUI options @@ -206,7 +202,7 @@ class NWProject(QObject): def trashFolder(self) -> str: """Add the special trash root folder to the project.""" - trashHandle = self._tree.trashRoot() + trashHandle = self._tree.trashRoot if trashHandle is None: label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH]) return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH) @@ -331,7 +327,7 @@ class NWProject(QObject): self.setProjectChanged(False) self._valid = True - self.statusMessage.emit(self.tr("Opened Project: {0}").format(self._data.name)) + SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name)) return True @@ -381,7 +377,7 @@ class NWProject(QObject): ) self._storage.writeLockFile() - self.statusMessage.emit(self.tr("Saved Project: {0}").format(self._data.name)) + SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name)) self.setProjectChanged(False) return True @@ -403,7 +399,7 @@ class NWProject(QObject): return False logger.info("Backing up project") - self.statusMessage.emit(self.tr("Backing up project ...")) + SHARED.newStatusMessage(self.tr("Backing up project ...")) if not self._data.name: SHARED.error(self.tr( @@ -434,7 +430,7 @@ class NWProject(QObject): SHARED.error(self.tr("Could not write backup archive.")) return False - self.statusMessage.emit(self.tr("Project backed up to '{0}'").format(str(archName))) + SHARED.newStatusMessage(self.tr("Project backed up to '{0}'").format(str(archName))) return True @@ -488,7 +484,7 @@ class NWProject(QObject): """ if isinstance(status, bool): self._changed = status - self.statusChanged.emit(self._changed) + SHARED.setGlobalProjectState(self._changed) return self._changed ## diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index fef2759d..b0a79822 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -40,6 +40,8 @@ if TYPE_CHECKING: # pragma: no cover logger = logging.getLogger(__name__) +MAX_DEPTH = 1000 # Cap of tree traversing for loops (recursion limit) + class NWTree: """Core: Project Tree Data Class @@ -59,7 +61,7 @@ class NWTree: also used for file names. """ - MAX_DEPTH = 1000 # Cap of tree traversing for loops + __slots__ = ("_project", "_tree", "_order", "_roots", "_trash", "_changed") def __init__(self, project: NWProject) -> None: @@ -74,6 +76,15 @@ class NWTree: return + ## + # Properties + ## + + @property + def trashRoot(self) -> str | None: + """Return the handle of the trash folder, or None.""" + return self._trash + ## # Class Methods ## @@ -320,7 +331,7 @@ class NWTree: return False iItem = tItem - for _ in range(self.MAX_DEPTH): + for _ in range(MAX_DEPTH): if iItem.itemParent is None: tItem.setRoot(iItem.itemHandle) tItem.setClassDefaults(iItem.itemClass) @@ -349,7 +360,7 @@ class NWTree: tItem = self.__getitem__(tHandle) if tItem is not None: tTree.append(tHandle) - for _ in range(self.MAX_DEPTH): + for _ in range(MAX_DEPTH): if tItem.itemParent is None: return tTree else: @@ -400,14 +411,6 @@ class NWTree: return True return False - def trashRoot(self) -> str | None: - """Returns the handle of the trash folder, or None if there - isn't one. - """ - if self._trash: - return self._trash - return None - def findRoot(self, itemClass: nwItemClass | None) -> str | None: """Find the first root item for a given class.""" for aRoot in self._roots: diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 1905bdec..e753a828 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -798,7 +798,7 @@ class GuiProjectTree(QTreeWidget): logger.error("There is no item to delete") return False - trashHandle = SHARED.project.tree.trashRoot() + trashHandle = SHARED.project.tree.trashRoot if tHandle == trashHandle: logger.error("Cannot delete the Trash folder") return False @@ -823,7 +823,7 @@ class GuiProjectTree(QTreeWidget): logger.error("No project open") return False - trashHandle = SHARED.project.tree.trashRoot() + trashHandle = SHARED.project.tree.trashRoot logger.debug("Emptying Trash folder") if trashHandle is None: @@ -1201,7 +1201,7 @@ class GuiProjectTree(QTreeWidget): # Trash Folder # ============ - trashHandle = SHARED.project.tree.trashRoot() + trashHandle = SHARED.project.tree.trashRoot if tItem.itemHandle == trashHandle and trashHandle is not None: # The trash folder only has one option aEmptyTrash = ctxMenu.addAction(self.tr("Empty Trash")) diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index d606b8d3..fbb16c0b 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -590,8 +590,8 @@ class GuiMain(QMainWindow): return True def openNextDocument(self, tHandle: str, wrapAround: bool = False) -> bool: - """Opens the next document in the project tree, following the - document with the given handle. Stops when reaching the end. + """Open the next document in the project tree, following the + document with the given handle. Stop when reaching the end. """ if not SHARED.hasProject: logger.error("No project open") diff --git a/novelwriter/shared.py b/novelwriter/shared.py index eea5fe26..262d5727 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -29,7 +29,7 @@ from time import time from typing import TYPE_CHECKING from pathlib import Path -from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot +from PyQt5.QtCore import QObject, pyqtSignal from PyQt5.QtWidgets import QMessageBox, QWidget if TYPE_CHECKING: # pragma: no cover @@ -84,7 +84,7 @@ class SharedData(QObject): @property def hasProject(self) -> bool: - """Return True of the project instance is populated.""" + """Return True if the project instance is populated.""" return self.project.isValid @property @@ -107,9 +107,9 @@ class SharedData(QObject): ## def initSharedData(self, gui: GuiMain, theme: GuiTheme) -> None: - """Initialise the UserData instance. This must be called as soon - as the Main GUI is created to ensure the SHARED singleton has the - properties needed for operation. + """Initialise the SharedData instance. This must be called as + soon as the Main GUI is created to ensure the SHARED singleton + has the properties needed for operation. """ self._gui = gui self._theme = theme @@ -159,6 +159,20 @@ class SharedData(QObject): self._idleRefTime = currTime return + def newStatusMessage(self, message: str) -> None: + """Request a new status message. This is a callable function for + core classes that cannot emit signals on their own. + """ + self.projectStatusMessage.emit(message) + return + + def setGlobalProjectState(self, state: bool) -> None: + """Change the global project status. This is a callable function + for core classes that cannot emit signals on their own. + """ + self.projectStatusChanged.emit(state) + return + ## # Alert Boxes ## @@ -201,22 +215,6 @@ class SharedData(QObject): self._alert.exec_() return self._alert.result() == QMessageBox.Yes - ## - # Internal Slots - ## - - @pyqtSlot(bool) - def _emitProjectStatusChange(self, state: bool) -> None: - """Forward the project status slot.""" - self.projectStatusChanged.emit(state) - return - - @pyqtSlot(str) - def _emitProjectStatusMeesage(self, message: str) -> None: - """Forward the project message slot.""" - self.projectStatusMessage.emit(message) - return - ## # Internal Functions ## @@ -225,12 +223,8 @@ class SharedData(QObject): """Create a new project instance.""" from novelwriter.core.project import NWProject if isinstance(self._project, NWProject): - self._project.statusChanged.disconnect() - self._project.statusMessage.disconnect() - self._project.deleteLater() - self._project = NWProject(self) - self._project.statusChanged.connect(self._emitProjectStatusChange) - self._project.statusMessage.connect(self._emitProjectStatusMeesage) + del self._project + self._project = NWProject() return def _resetIdleTimer(self) -> None: diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index 83abd8cb..0b36897b 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -119,7 +119,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems): assert bool(theTree) is False # Check for archive and trash folders - assert theTree.trashRoot() is None + assert theTree.trashRoot is None aHandles = [] for nwItem in mockItems: @@ -146,7 +146,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems): # ============ # Check that we have the correct archive and trash folders - assert theTree.trashRoot() == "a000000000003" + assert theTree.trashRoot == "a000000000003" assert theTree.findRoot(nwItemClass.ARCHIVE) == "a000000000002" assert theTree.isTrash("a000000000003") is True @@ -261,7 +261,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems): del theTree["a000000000003"] assert len(theTree) == len(mockItems) - 3 assert "a000000000003" not in theTree - assert theTree.trashRoot() is None + assert theTree.trashRoot is None # END Test testCoreTree_BuildTree @@ -365,7 +365,7 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc @pytest.mark.core -def testCoreTree_Methods(mockGUI, mockItems): +def testCoreTree_Methods(monkeypatch, mockGUI, mockItems): """Test various class methods.""" theProject = NWProject() theTree = NWTree(theProject) @@ -389,11 +389,10 @@ def testCoreTree_Methods(mockGUI, mockItems): assert theTree.updateItemData("b000000000001") is True # Update item data, root is unreachable - maxDepth = theTree.MAX_DEPTH - theTree.MAX_DEPTH = 0 # type: ignore - with pytest.raises(RecursionError): - theTree.updateItemData("b000000000001") - theTree.MAX_DEPTH = maxDepth + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0) + with pytest.raises(RecursionError): + theTree.updateItemData("b000000000001") # Check type assert theTree.checkType("blabla", nwItemType.FILE) is False @@ -424,11 +423,10 @@ def testCoreTree_Methods(mockGUI, mockItems): ] # Cause recursion error - maxDepth = theTree.MAX_DEPTH - theTree.MAX_DEPTH = 0 # type: ignore - with pytest.raises(RecursionError): - theTree.getItemPath("c000000000001") - theTree.MAX_DEPTH = maxDepth + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0) + with pytest.raises(RecursionError): + theTree.getItemPath("c000000000001") # Break the folder parent handle theTree["b000000000001"]._parent = "stuff" # type: ignore diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index fd906971..0c65cb5a 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -205,7 +205,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): assert len(SHARED.project.tree) == 0 assert len(SHARED.project.tree._order) == 0 assert len(SHARED.project.tree._roots) == 0 - assert SHARED.project.tree.trashRoot() is None + assert SHARED.project.tree.trashRoot is None assert SHARED.project.data.name == "" assert SHARED.project.data.title == "" assert SHARED.project.data.author == "" @@ -225,7 +225,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): assert len(SHARED.project.tree) == 8 assert len(SHARED.project.tree._order) == 8 assert len(SHARED.project.tree._roots) == 4 - assert SHARED.project.tree.trashRoot() is None + assert SHARED.project.tree.trashRoot is None assert SHARED.project.data.name == "New Project" assert SHARED.project.data.title == "New Novel" assert SHARED.project.data.author == "Jane Doe" diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index bfb57a39..71b35df8 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -348,7 +348,7 @@ def testGuiProjTree_RequestDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPat C.hChapterDir, C.hChapterDoc, C.hSceneDoc, "0000000000010" ] - trashHandle = SHARED.project.tree.trashRoot() + trashHandle = SHARED.project.tree.trashRoot assert projTree.getTreeFromHandle(trashHandle) == [ trashHandle, "0000000000012", "0000000000011" ] @@ -541,7 +541,7 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd): projTree.setExpandedFromHandle(None, True) projTree._addTrashRoot() - hTrashRoot = SHARED.project.tree.trashRoot() + hTrashRoot = SHARED.project.tree.trashRoot projTree.setSelectedHandle(C.hCharRoot) projTree.newTreeItem(nwItemType.FILE) 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 2/6] 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() From b54f81355a94380e083d26676c47dcd7968cd055 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 24 Aug 2023 20:40:10 +0200 Subject: [PATCH 3/6] Clean up the syntax highlighter class --- novelwriter/gui/doceditor.py | 4 +- novelwriter/gui/dochighlight.py | 355 ++++++++++++++++---------------- 2 files changed, 182 insertions(+), 177 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 7a2d8ee1..311fbe4e 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -397,7 +397,7 @@ class GuiDocEditor(QTextEdit): self._checkDocSize(docSize) spTemp = self.highLight.spellCheck if self._bigDoc: - self.highLight.spellCheck = False + self.highLight.setSpellCheck(False) bfTime = time() self._allowAutoReplace(False) @@ -418,7 +418,7 @@ class GuiDocEditor(QTextEdit): self.docHeader.setTitleFromHandle(self._docHandle) self.docFooter.setHandle(self._docHandle) self.updateDocMargins() - self.highLight.spellCheck = spTemp + self.highLight.setSpellCheck(spTemp) if tLine is None and self._nwItem is not None: # For large documents, we queue the repositioning until the diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index d47d3d48..5823a101 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -51,24 +51,27 @@ class GuiDocHighlighter(QSyntaxHighlighter): logger.debug("Create: GuiDocHighlighter") - self.theHandle = None - self.spellCheck = False - self.spellRx = None - self.hRules = [] - self.hStyles = {} + self._tHandle = None + self._spellCheck = False + self._spellRx = QRegularExpression() - self.colHead = QColor(0, 0, 0) - self.colHeadH = QColor(0, 0, 0) - self.colEmph = QColor(0, 0, 0) - self.colDialN = QColor(0, 0, 0) - self.colDialD = QColor(0, 0, 0) - self.colDialS = QColor(0, 0, 0) - self.colHidden = QColor(0, 0, 0) - self.colKey = QColor(0, 0, 0) - self.colVal = QColor(0, 0, 0) - self.colSpell = QColor(0, 0, 0) - self.colError = QColor(0, 0, 0) - self.colRepTag = QColor(0, 0, 0) + self._hRules: list[tuple[str, dict]] = [] + self._hStyles: dict[str, QTextCharFormat] = {} + + self._colHead = QColor(0, 0, 0) + self._colHeadH = QColor(0, 0, 0) + self._colEmph = QColor(0, 0, 0) + self._colDialN = QColor(0, 0, 0) + self._colDialD = QColor(0, 0, 0) + self._colDialS = QColor(0, 0, 0) + self._colHidden = QColor(0, 0, 0) + self._colKey = QColor(0, 0, 0) + self._colVal = QColor(0, 0, 0) + self._colSpell = QColor(0, 0, 0) + self._colError = QColor(0, 0, 0) + self._colRepTag = QColor(0, 0, 0) + self._colMod = QColor(0, 0, 0) + self._colBreak = QColor(0, 0, 0) self.initHighlighter() @@ -76,71 +79,76 @@ class GuiDocHighlighter(QSyntaxHighlighter): return - def initHighlighter(self): + @property + def spellCheck(self) -> bool: + """Check if spell checking is enabled.""" + return self._spellCheck + + def initHighlighter(self) -> None: """Initialise the syntax highlighter, setting all the colour rules and building the RegExes. """ logger.debug("Setting up highlighting rules") - self.colHead = QColor(*SHARED.theme.colHead) - self.colHeadH = QColor(*SHARED.theme.colHeadH) - self.colDialN = QColor(*SHARED.theme.colDialN) - self.colDialD = QColor(*SHARED.theme.colDialD) - self.colDialS = QColor(*SHARED.theme.colDialS) - self.colHidden = QColor(*SHARED.theme.colHidden) - self.colKey = QColor(*SHARED.theme.colKey) - self.colVal = QColor(*SHARED.theme.colVal) - self.colSpell = QColor(*SHARED.theme.colSpell) - self.colError = QColor(*SHARED.theme.colError) - self.colRepTag = QColor(*SHARED.theme.colRepTag) - self.colMod = QColor(*SHARED.theme.colMod) - self.colBreak = QColor(*SHARED.theme.colEmph) - self.colBreak.setAlpha(64) + self._colHead = QColor(*SHARED.theme.colHead) + self._colHeadH = QColor(*SHARED.theme.colHeadH) + self._colDialN = QColor(*SHARED.theme.colDialN) + self._colDialD = QColor(*SHARED.theme.colDialD) + self._colDialS = QColor(*SHARED.theme.colDialS) + self._colHidden = QColor(*SHARED.theme.colHidden) + self._colKey = QColor(*SHARED.theme.colKey) + self._colVal = QColor(*SHARED.theme.colVal) + self._colSpell = QColor(*SHARED.theme.colSpell) + self._colError = QColor(*SHARED.theme.colError) + self._colRepTag = QColor(*SHARED.theme.colRepTag) + self._colMod = QColor(*SHARED.theme.colMod) + self._colBreak = QColor(*SHARED.theme.colEmph) + self._colBreak.setAlpha(64) - self.colEmph = None + self._colEmph = None if CONFIG.highlightEmph: - self.colEmph = QColor(*SHARED.theme.colEmph) + self._colEmph = QColor(*SHARED.theme.colEmph) - self.hStyles = { - "header1": self._makeFormat(self.colHead, "bold", 1.8), - "header2": self._makeFormat(self.colHead, "bold", 1.6), - "header3": self._makeFormat(self.colHead, "bold", 1.4), - "header4": self._makeFormat(self.colHead, "bold", 1.2), - "header1h": self._makeFormat(self.colHeadH, "bold", 1.8), - "header2h": self._makeFormat(self.colHeadH, "bold", 1.6), - "header3h": self._makeFormat(self.colHeadH, "bold", 1.4), - "header4h": self._makeFormat(self.colHeadH, "bold", 1.2), - "bold": self._makeFormat(self.colEmph, "bold"), - "italic": self._makeFormat(self.colEmph, "italic"), - "strike": self._makeFormat(self.colHidden, "strike"), - "mspaces": self._makeFormat(self.colError, "errline"), - "nobreak": self._makeFormat(self.colBreak, "background"), - "dialogue1": self._makeFormat(self.colDialN), - "dialogue2": self._makeFormat(self.colDialD), - "dialogue3": self._makeFormat(self.colDialS), - "replace": self._makeFormat(self.colRepTag), - "hidden": self._makeFormat(self.colHidden), - "keyword": self._makeFormat(self.colKey), - "modifier": self._makeFormat(self.colMod), - "value": self._makeFormat(self.colVal, "underline"), - "codevalue": self._makeFormat(self.colVal), + self._hStyles = { + "header1": self._makeFormat(self._colHead, "bold", 1.8), + "header2": self._makeFormat(self._colHead, "bold", 1.6), + "header3": self._makeFormat(self._colHead, "bold", 1.4), + "header4": self._makeFormat(self._colHead, "bold", 1.2), + "header1h": self._makeFormat(self._colHeadH, "bold", 1.8), + "header2h": self._makeFormat(self._colHeadH, "bold", 1.6), + "header3h": self._makeFormat(self._colHeadH, "bold", 1.4), + "header4h": self._makeFormat(self._colHeadH, "bold", 1.2), + "bold": self._makeFormat(self._colEmph, "bold"), + "italic": self._makeFormat(self._colEmph, "italic"), + "strike": self._makeFormat(self._colHidden, "strike"), + "mspaces": self._makeFormat(self._colError, "errline"), + "nobreak": self._makeFormat(self._colBreak, "background"), + "dialogue1": self._makeFormat(self._colDialN), + "dialogue2": self._makeFormat(self._colDialD), + "dialogue3": self._makeFormat(self._colDialS), + "replace": self._makeFormat(self._colRepTag), + "hidden": self._makeFormat(self._colHidden), + "keyword": self._makeFormat(self._colKey), + "modifier": self._makeFormat(self._colMod), + "value": self._makeFormat(self._colVal, "underline"), + "codevalue": self._makeFormat(self._colVal), "codeinval": self._makeFormat(None, "errline"), } - self.hRules = [] + self._hRules = [] # Multiple or Trailing Spaces if CONFIG.showMultiSpaces: - self.hRules.append(( + self._hRules.append(( r"[ ]{2,}|[ ]*$", { - 0: self.hStyles["mspaces"], + 0: self._hStyles["mspaces"], } )) # Non-Breaking Spaces - self.hRules.append(( - "[%s%s]+" % (nwUnicode.U_NBSP, nwUnicode.U_THNBSP), { - 0: self.hStyles["nobreak"], + self._hRules.append(( + f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+", { + 0: self._hStyles["nobreak"], } )) @@ -153,68 +161,68 @@ class GuiDocHighlighter(QSyntaxHighlighter): # Straight Quotes if not (fmtDblO == fmtDblC == "\""): - self.hRules.append(( + self._hRules.append(( "(\\B\")(.*?)(\"\\B)", { - 0: self.hStyles["dialogue1"], + 0: self._hStyles["dialogue1"], } )) # Double Quotes dblEnd = "|$" if CONFIG.allowOpenDQuote else "" - self.hRules.append(( + self._hRules.append(( f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})", { - 0: self.hStyles["dialogue2"], + 0: self._hStyles["dialogue2"], } )) # Single Quotes sngEnd = "|$" if CONFIG.allowOpenSQuote else "" - self.hRules.append(( + self._hRules.append(( f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})", { - 0: self.hStyles["dialogue3"], + 0: self._hStyles["dialogue3"], } )) # Markdown Syntax - self.hRules.append(( + self._hRules.append(( nwRegEx.FMT_EI, { - 1: self.hStyles["hidden"], - 2: self.hStyles["italic"], - 3: self.hStyles["hidden"], + 1: self._hStyles["hidden"], + 2: self._hStyles["italic"], + 3: self._hStyles["hidden"], } )) - self.hRules.append(( + self._hRules.append(( nwRegEx.FMT_EB, { - 1: self.hStyles["hidden"], - 2: self.hStyles["bold"], - 3: self.hStyles["hidden"], + 1: self._hStyles["hidden"], + 2: self._hStyles["bold"], + 3: self._hStyles["hidden"], } )) - self.hRules.append(( + self._hRules.append(( nwRegEx.FMT_ST, { - 1: self.hStyles["hidden"], - 2: self.hStyles["strike"], - 3: self.hStyles["hidden"], + 1: self._hStyles["hidden"], + 2: self._hStyles["strike"], + 3: self._hStyles["hidden"], } )) # Alignment Tags - self.hRules.append(( + self._hRules.append(( r"(^>{1,2}|<{1,2}$)", { - 1: self.hStyles["hidden"], + 1: self._hStyles["hidden"], } )) # Auto-Replace Tags - self.hRules.append(( + self._hRules.append(( r"<(\S+?)>", { - 0: self.hStyles["replace"], + 0: self._hStyles["replace"], } )) # Build a QRegExp for each highlight pattern self.rxRules = [] - for regEx, regRules in self.hRules: + for regEx, regRules in self._hRules: hReg = QRegularExpression(regEx) hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) self.rxRules.append((hReg, regRules)) @@ -223,68 +231,65 @@ class GuiDocHighlighter(QSyntaxHighlighter): # 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) + self._spellRx = QRegularExpression(r"\b[^\s\-\+\/" + uCode + r"]+\b") + self._spellRx.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) - return True + return ## # Setters ## - def setSpellCheck(self, theMode): - """Enable/disable the real time spell checker. - """ - self.spellCheck = theMode - return True + def setSpellCheck(self, state: bool) -> None: + """Enable/disable the real time spell checker.""" + self._spellCheck = state + return - def setHandle(self, theHandle): - """Set the handle of the currently highlighted document. This is - needed for the index lookup for validating tags and references. - """ - self.theHandle = theHandle - return True + def setHandle(self, tHandle: str) -> None: + """Set the handle of the currently highlighted document.""" + self._tHandle = tHandle + return ## # Methods ## - def rehighlightByType(self, theType): + def rehighlightByType(self, cType: int) -> None: """Loop through all blocks and re-highlight those of a given content type. """ qDoc = self.document() nBlocks = qDoc.blockCount() - bfTime = time() + tStart = time() for i in range(nBlocks): theBlock = qDoc.findBlockByNumber(i) - if theBlock.userState() & theType > 0: + if theBlock.userState() & cType > 0: self.rehighlightBlock(theBlock) - afTime = time() - logger.debug( - "Document highlighted in %.3f ms" % (1000*(afTime-bfTime)) - ) + logger.debug("Document highlighted in %.3f ms" % (1000*(time() - tStart))) return ## # Highlight Block ## - def highlightBlock(self, theText): + def highlightBlock(self, text: str) -> None: """Highlight a single block. Prefer to check first character for all formats that are defined by their initial characters. This is significantly faster than running the regex checks used for text paragraphs. """ self.setCurrentBlockState(self.BLOCK_NONE) - if self.theHandle is None or not theText: + if self._tHandle is None or not text: return - if theText.startswith("@"): # Keywords and commands + if text.startswith("@"): # Keywords and commands self.setCurrentBlockState(self.BLOCK_META) pIndex = SHARED.project.index - tItem = SHARED.project.tree[self.theHandle] - isValid, theBits, thePos = pIndex.scanThis(theText) + tItem = SHARED.project.tree[self._tHandle] + if tItem is None: + return + + isValid, theBits, thePos = pIndex.scanThis(text) isGood = pIndex.checkThese(theBits, tItem) if isValid: for n, theBit in enumerate(theBits): @@ -292,12 +297,12 @@ class GuiDocHighlighter(QSyntaxHighlighter): xLen = len(theBit) if isGood[n]: if n == 0: - self.setFormat(xPos, xLen, self.hStyles["keyword"]) + self.setFormat(xPos, xLen, self._hStyles["keyword"]) else: - self.setFormat(xPos, xLen, self.hStyles["value"]) + self.setFormat(xPos, xLen, self._hStyles["value"]) else: kwFmt = self.format(xPos) - kwFmt.setUnderlineColor(self.colError) + kwFmt.setUnderlineColor(self._colError) kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) self.setFormat(xPos, xLen, kwFmt) @@ -305,69 +310,67 @@ class GuiDocHighlighter(QSyntaxHighlighter): # so we force a return here return - elif theText.startswith(("# ", "#! ", "## ", "##! ", "### ", "#### ")): + elif text.startswith(("# ", "#! ", "## ", "##! ", "### ", "#### ")): self.setCurrentBlockState(self.BLOCK_TITLE) - if theText.startswith("# "): # Header 1 - self.setFormat(0, 1, self.hStyles["header1h"]) - self.setFormat(1, len(theText), self.hStyles["header1"]) + if text.startswith("# "): # Header 1 + self.setFormat(0, 1, self._hStyles["header1h"]) + self.setFormat(1, len(text), self._hStyles["header1"]) - elif theText.startswith("## "): # Header 2 - self.setFormat(0, 2, self.hStyles["header2h"]) - self.setFormat(2, len(theText), self.hStyles["header2"]) + elif text.startswith("## "): # Header 2 + self.setFormat(0, 2, self._hStyles["header2h"]) + self.setFormat(2, len(text), self._hStyles["header2"]) - elif theText.startswith("### "): # Header 3 - self.setFormat(0, 3, self.hStyles["header3h"]) - self.setFormat(3, len(theText), self.hStyles["header3"]) + elif text.startswith("### "): # Header 3 + self.setFormat(0, 3, self._hStyles["header3h"]) + self.setFormat(3, len(text), self._hStyles["header3"]) - elif theText.startswith("#### "): # Header 4 - self.setFormat(0, 4, self.hStyles["header4h"]) - self.setFormat(4, len(theText), self.hStyles["header4"]) + elif text.startswith("#### "): # Header 4 + self.setFormat(0, 4, self._hStyles["header4h"]) + self.setFormat(4, len(text), self._hStyles["header4"]) - if theText.startswith("#! "): # Title - self.setFormat(0, 2, self.hStyles["header1h"]) - self.setFormat(2, len(theText), self.hStyles["header1"]) + if text.startswith("#! "): # Title + self.setFormat(0, 2, self._hStyles["header1h"]) + self.setFormat(2, len(text), self._hStyles["header1"]) - elif theText.startswith("##! "): # Unnumbered - self.setFormat(0, 3, self.hStyles["header2h"]) - self.setFormat(3, len(theText), self.hStyles["header2"]) + elif text.startswith("##! "): # Unnumbered + self.setFormat(0, 3, self._hStyles["header2h"]) + self.setFormat(3, len(text), self._hStyles["header2"]) - elif theText.startswith("%"): # Comments + elif text.startswith("%"): # Comments self.setCurrentBlockState(self.BLOCK_TEXT) - toCheck = theText[1:].lstrip() + toCheck = text[1:].lstrip() synTag = toCheck[:9].lower() - tLen = len(theText) + tLen = len(text) cLen = len(toCheck) cOff = tLen - cLen if synTag == "synopsis:": - self.setFormat(0, cOff+9, self.hStyles["modifier"]) - self.setFormat(cOff+9, tLen, self.hStyles["hidden"]) + self.setFormat(0, cOff+9, self._hStyles["modifier"]) + self.setFormat(cOff+9, tLen, self._hStyles["hidden"]) else: - self.setFormat(0, tLen, self.hStyles["hidden"]) + self.setFormat(0, tLen, self._hStyles["hidden"]) else: # Text Paragraph - if theText.startswith("["): # Special Command - sText = theText.rstrip() + if text.startswith("["): # Special Command + sText = text.rstrip() if sText in ("[NEWPAGE]", "[NEW PAGE]", "[VSPACE]"): - self.setFormat(0, len(theText), self.hStyles["keyword"]) + self.setFormat(0, len(text), self._hStyles["keyword"]) return elif sText.startswith("[VSPACE:") and sText.endswith("]"): tLen = len(sText) tVal = checkInt(sText[8:-1], 0) - self.setFormat(0, 8, self.hStyles["keyword"]) - if tVal > 0: - self.setFormat(8, tLen-9, self.hStyles["codevalue"]) - else: - self.setFormat(8, tLen-9, self.hStyles["codeinval"]) - self.setFormat(tLen-1, tLen, self.hStyles["keyword"]) + cVal = "codevalue" if tVal > 0 else "codeinval" + self.setFormat(0, 8, self._hStyles["keyword"]) + self.setFormat(8, tLen-9, self._hStyles[cVal]) + self.setFormat(tLen-1, tLen, self._hStyles["keyword"]) return # Regular text self.setCurrentBlockState(self.BLOCK_TEXT) for rX, xFmt in self.rxRules: - rxItt = rX.globalMatch(theText, 0) + rxItt = rX.globalMatch(text, 0) while rxItt.hasNext(): rxMatch = rxItt.next() for xM in xFmt: @@ -375,14 +378,14 @@ class GuiDocHighlighter(QSyntaxHighlighter): xLen = rxMatch.capturedLength(xM) for x in range(xPos, xPos+xLen): spFmt = self.format(x) - if spFmt != self.hStyles["hidden"]: + if spFmt != self._hStyles["hidden"]: spFmt.merge(xFmt[xM]) self.setFormat(x, 1, spFmt) - if not self.spellCheck: + if not self._spellCheck: return - rxSpell = self.spellRx.globalMatch(theText.replace("_", " "), 0) + rxSpell = self._spellRx.globalMatch(text.replace("_", " "), 0) while rxSpell.hasNext(): rxMatch = rxSpell.next() if not SHARED.spelling.checkWord(rxMatch.captured(0)): @@ -392,7 +395,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): xLen = rxMatch.capturedLength(0) for x in range(xPos, xPos+xLen): spFmt = self.format(x) - spFmt.setUnderlineColor(self.colSpell) + spFmt.setUnderlineColor(self._colSpell) spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) self.setFormat(x, 1, spFmt) @@ -402,33 +405,35 @@ class GuiDocHighlighter(QSyntaxHighlighter): # Internal Functions ## - def _makeFormat(self, fmtCol=None, fmtStyle=None, fmtSize=None): + def _makeFormat(self, color: QColor | None = None, style: str | None = None, + size: float | None = None) -> QTextCharFormat: """Generate a valid character format to be applied to the text that is to be highlighted. """ - theFormat = QTextCharFormat() + charFormat = QTextCharFormat() - if fmtCol is not None: - theFormat.setForeground(fmtCol) + if color is not None: + charFormat.setForeground(color) - if fmtStyle is not None: - if "bold" in fmtStyle: - theFormat.setFontWeight(QFont.Bold) - if "italic" in fmtStyle: - theFormat.setFontItalic(True) - if "strike" in fmtStyle: - theFormat.setFontStrikeOut(True) - if "errline" in fmtStyle: - theFormat.setUnderlineColor(self.colError) - theFormat.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) - if "underline" in fmtStyle: - theFormat.setFontUnderline(True) - if "background" in fmtStyle: - theFormat.setBackground(QBrush(fmtCol, Qt.SolidPattern)) + if style is not None: + styles = style.split(",") + if "bold" in styles: + charFormat.setFontWeight(QFont.Bold) + if "italic" in styles: + charFormat.setFontItalic(True) + if "strike" in styles: + charFormat.setFontStrikeOut(True) + if "errline" in styles: + charFormat.setUnderlineColor(self._colError) + charFormat.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) + if "underline" in styles: + charFormat.setFontUnderline(True) + if "background" in styles and color is not None: + charFormat.setBackground(QBrush(color, Qt.SolidPattern)) - if fmtSize is not None: - theFormat.setFontPointSize(int(round(fmtSize*CONFIG.textSize))) + if size is not None: + charFormat.setFontPointSize(int(round(size*CONFIG.textSize))) - return theFormat + return charFormat # END Class DocHighlighter From a72c824d8b871934e1de22b8374251af4e38a97c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 24 Aug 2023 22:33:53 +0200 Subject: [PATCH 4/6] Move all handling of spell check instance to shared class --- novelwriter/core/spellcheck.py | 6 +----- novelwriter/dialogs/projsettings.py | 11 +---------- novelwriter/gui/doceditor.py | 21 +-------------------- novelwriter/gui/statusbar.py | 2 +- novelwriter/guimain.py | 9 +++------ novelwriter/shared.py | 17 ++++++++++++++++- tests/test_dialogs/test_dlg_projsettings.py | 2 -- 7 files changed, 23 insertions(+), 45 deletions(-) diff --git a/novelwriter/core/spellcheck.py b/novelwriter/core/spellcheck.py index 7f9d7f4f..879007a0 100644 --- a/novelwriter/core/spellcheck.py +++ b/novelwriter/core/spellcheck.py @@ -97,6 +97,7 @@ class NWSpellEnchant: if self._enchant is None: self._enchant = FakeEnchant() else: + self._userDict.load() for word in self._userDict: self._enchant.add_to_session(word) @@ -136,11 +137,6 @@ 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 = [] diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index 861c36b6..cd4280e4 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -89,9 +89,6 @@ class GuiProjectSettings(NPagedDialog): self.buttonBox.rejected.connect(self._doClose) self.addControls(self.buttonBox) - # Flags - self._spellChanged = False - # Focus Tab self._focusTab(focusTab) @@ -103,10 +100,6 @@ class GuiProjectSettings(NPagedDialog): logger.debug("Delete: GuiProjectSettings") return - @property - def spellChanged(self): - return self._spellChanged - ## # Slots ## @@ -125,9 +118,7 @@ class GuiProjectSettings(NPagedDialog): project.data.setTitle(bookTitle) project.data.setAuthor(bookAuthor) project.data.setDoBackup(doBackup) - - # Remember this as updating spell dictionary can be expensive - self._spellChanged = project.data.setSpellLang(spellLang) + project.data.setSpellLang(spellLang) if self.tabStatus.colChanged: newList, delList = self.tabStatus.getNewList() diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 311fbe4e..2063627b 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -73,7 +73,6 @@ class GuiDocEditor(QTextEdit): statusMessage = pyqtSignal(str) docCountsChanged = pyqtSignal(str, int, int, int) editedStatusChanged = pyqtSignal(bool) - spellDictionaryChanged = pyqtSignal(str, str) loadDocumentTagRequest = pyqtSignal(str, Enum) novelStructureChanged = pyqtSignal() novelItemMetaChanged = pyqtSignal(str) @@ -301,7 +300,7 @@ class GuiDocEditor(QTextEdit): self._typPadAfter = CONFIG.fmtPadAfter # Reload spell check and dictionaries - self.setDictionaries() + SHARED.updateSpellCheckLanguage() # Set font textFont = QFont() @@ -691,24 +690,6 @@ class GuiDocEditor(QTextEdit): # Spell Checking ## - def setDictionaries(self): - """Set the spell checker dictionary language, and emit the - dictionary changed signal. - """ - if SHARED.project.data.spellLang is None: - theLang = CONFIG.spellLanguage - else: - theLang = SHARED.project.data.spellLang - - SHARED.spelling.setLanguage(theLang) - _, theProvider = SHARED.spelling.describeDict() - - self.spellDictionaryChanged.emit(str(theLang), str(theProvider)) - if not self._bigDoc: - self.spellCheckDocument() - - return True - 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. diff --git a/novelwriter/gui/statusbar.py b/novelwriter/gui/statusbar.py index 597124de..a80864ca 100644 --- a/novelwriter/gui/statusbar.py +++ b/novelwriter/gui/statusbar.py @@ -121,7 +121,7 @@ class GuiMainStatus(QStatusBar): def clearStatus(self) -> None: """Reset all widgets on the status bar to default values.""" self.setRefTime(-1.0) - self.setLanguage(None, "") + self.setLanguage(*SHARED.spelling.describeDict()) self.setProjectStats(0, 0) self.setProjectStatus(StatusLED.S_NONE) self.setDocumentStatus(StatusLED.S_NONE) diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index fbb16c0b..96806648 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -234,6 +234,7 @@ class GuiMain(QMainWindow): SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus) SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage) + SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage) self.viewsBar.viewChangeRequested.connect(self._changeView) @@ -251,7 +252,6 @@ class GuiMain(QMainWindow): self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox) self.novelView.openDocumentRequest.connect(self._openDocument) - self.docEditor.spellDictionaryChanged.connect(self.mainStatus.setLanguage) self.docEditor.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus) self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts) self.docEditor.docCountsChanged.connect(self.projView.updateCounts) @@ -428,7 +428,6 @@ class GuiMain(QMainWindow): SHARED.closeProject() - self.docEditor.setDictionaries() self._updateWindowTitle() self._changeView(nwView.PROJECT) @@ -489,7 +488,6 @@ class GuiMain(QMainWindow): # Update GUI self._updateWindowTitle(SHARED.project.data.name) self.rebuildTrees() - self.docEditor.setDictionaries() self.docEditor.toggleSpellCheck(SHARED.project.data.spellCheck) self.mainStatus.setRefTime(SHARED.project.projOpened) self.projView.openProjectTasks() @@ -907,8 +905,7 @@ class GuiMain(QMainWindow): if dlgProj.result() == QDialog.Accepted: logger.debug("Applying new project settings") - if dlgProj.spellChanged: - self.docEditor.setDictionaries() + SHARED.updateSpellCheckLanguage() self.itemDetails.refreshDetails() self._updateWindowTitle(SHARED.project.data.name) @@ -982,7 +979,7 @@ class GuiMain(QMainWindow): if dlgWords.result() == QDialog.Accepted: logger.debug("Reloading word list") - self.docEditor.setDictionaries() + SHARED.updateSpellCheckLanguage(reload=True) return True diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 09427760..9fc91c5e 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -65,6 +65,10 @@ class SharedData(QObject): self._idleRefTime = time() return + ## + # Properties + ## + @property def mainGui(self) -> GuiMain: """Return the Main GUI instance.""" @@ -141,7 +145,7 @@ class SharedData(QObject): self._lockedBy = self.project.lockStatus self._resetProject() - self.spelling.loadUserWordList() + self.updateSpellCheckLanguage(reload=True) self._resetIdleTimer() return status @@ -160,6 +164,16 @@ class SharedData(QObject): self._resetIdleTimer() return + def updateSpellCheckLanguage(self, reload: bool = False) -> None: + """Update the active spell check langauge from settings.""" + from novelwriter import CONFIG + language = self.project.data.spellLang or CONFIG.spellLanguage + if language != self.spelling.spellLanguage or reload: + self.spelling.setLanguage(language) + _, provider = self.spelling.describeDict() + self.spellLanguageChanged.emit(language, provider) + return + def updateIdleTime(self, currTime: float, userIdle: bool) -> None: """Update the idle time record. If the userIdle flag is True, the user idle counter is updated with the time difference since @@ -239,6 +253,7 @@ class SharedData(QObject): del self._spelling self._project = NWProject() self._spelling = NWSpellEnchant(self._project) + self.updateSpellCheckLanguage() return def _resetIdleTimer(self) -> None: diff --git a/tests/test_dialogs/test_dlg_projsettings.py b/tests/test_dialogs/test_dlg_projsettings.py index ddf426b3..a54639fc 100644 --- a/tests/test_dialogs/test_dlg_projsettings.py +++ b/tests/test_dialogs/test_dlg_projsettings.py @@ -43,7 +43,6 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI): # Block the GUI blocking thread monkeypatch.setattr(GuiProjectSettings, "exec_", lambda *a: None) monkeypatch.setattr(GuiProjectSettings, "result", lambda *a: QDialog.Accepted) - monkeypatch.setattr(GuiProjectSettings, "spellChanged", lambda *a: True) # Check that we cannot open when there is no project nwGUI.mainMenu.aProjectSettings.activate(QAction.Trigger) @@ -129,7 +128,6 @@ def testDlgProjSettings_Main(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockR assert tabMain.editName.text() == "Project Name" assert tabMain.editTitle.text() == "Project Title" assert tabMain.editAuthor.text() == "Jane Doe" - assert projSettings.spellChanged is False projSettings._doSave() assert theProject.data.name == "Project Name" From 56fc5ec40c29d57992f20911f8aded9279c34377 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 25 Aug 2023 13:09:05 +0200 Subject: [PATCH 5/6] Add spell check languages to Tools menu --- novelwriter/core/spellcheck.py | 12 +- novelwriter/dialogs/preferences.py | 11 +- novelwriter/dialogs/projsettings.py | 10 +- novelwriter/gui/doceditor.py | 2 +- novelwriter/gui/dochighlight.py | 2 +- novelwriter/gui/mainmenu.py | 118 ++++++++++---------- novelwriter/gui/statusbar.py | 12 +- tests/test_base/test_base_shared.py | 4 +- tests/test_dialogs/test_dlg_preferences.py | 2 +- tests/test_dialogs/test_dlg_projsettings.py | 11 +- tests/test_gui/test_gui_guimain.py | 3 +- 11 files changed, 90 insertions(+), 97 deletions(-) diff --git a/novelwriter/core/spellcheck.py b/novelwriter/core/spellcheck.py index 879007a0..e2a022e9 100644 --- a/novelwriter/core/spellcheck.py +++ b/novelwriter/core/spellcheck.py @@ -29,6 +29,8 @@ import logging from typing import TYPE_CHECKING, Iterator from pathlib import Path +from PyQt5.QtCore import QLocale + from novelwriter.error import logException from novelwriter.constants import nwFiles @@ -138,15 +140,15 @@ class NWSpellEnchant: return added def listDictionaries(self) -> list[tuple[str, str]]: - """Wrapper function for pyenchant.""" - retList = [] + """List available dictionaries.""" + lang = [] try: import enchant - for spTag, spProvider in enchant.list_dicts(): - retList.append((spTag, spProvider.name)) + tags = [x for x, _ in enchant.list_dicts()] + lang = [(x, f"{QLocale(x).nativeLanguageName().title()} [{x}]") for x in set(tags)] except Exception: logger.error("Failed to list languages for enchant spell checking") - return retList + return sorted(lang, key=lambda x: x[1]) def describeDict(self) -> tuple[str, str]: """Describe the currently loaded dictionary.""" diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 642b6362..72120448 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -26,7 +26,7 @@ from __future__ import annotations import logging from PyQt5.QtGui import QFont -from PyQt5.QtCore import Qt, QLocale +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QDialog, QWidget, QComboBox, QSpinBox, QPushButton, QDialogButtonBox, QLineEdit, QFileDialog, QFontDialog, QDoubleSpinBox @@ -658,12 +658,9 @@ class GuiPreferencesEditor(QWidget): self.spellLanguage = QComboBox(self) self.spellLanguage.setMaximumWidth(mW) - langAvail = SHARED.spelling.listDictionaries() - if CONFIG.hasEnchant and langAvail: - for spTag, spProv in langAvail: - qLocal = QLocale(spTag) - spLang = qLocal.nativeLanguageName().title() - self.spellLanguage.addItem("%s [%s]" % (spLang, spProv), spTag) + if CONFIG.hasEnchant: + for tag, language in SHARED.spelling.listDictionaries(): + self.spellLanguage.addItem(language, tag) else: self.spellLanguage.addItem(self.tr("None"), "") self.spellLanguage.setEnabled(False) diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index cd4280e4..ac79a986 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -28,7 +28,7 @@ import logging from typing import TYPE_CHECKING from PyQt5.QtGui import QIcon, QPixmap, QColor -from PyQt5.QtCore import Qt, QLocale, pyqtSlot +from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtWidgets import ( QColorDialog, QComboBox, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget @@ -233,11 +233,9 @@ class GuiProjectEditMain(QWidget): self.spellLang = QComboBox(self) self.spellLang.setMaximumWidth(xW) self.spellLang.addItem(self.tr("Default"), "None") - langAvail = SHARED.spelling.listDictionaries() - for spTag, spProv in langAvail: - qLocal = QLocale(spTag) - spLang = qLocal.nativeLanguageName().title() - self.spellLang.addItem("%s [%s]" % (spLang, spProv), spTag) + if CONFIG.hasEnchant: + for tag, language in SHARED.spelling.listDictionaries(): + self.spellLang.addItem(language, tag) self.mainForm.addRow( self.tr("Spell check language"), diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 2063627b..d4204a36 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -690,7 +690,7 @@ class GuiDocEditor(QTextEdit): # Spell Checking ## - def toggleSpellCheck(self, state: bool) -> None: + def toggleSpellCheck(self, state: bool | None) -> 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 diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index 5823a101..d265ade8 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -436,4 +436,4 @@ class GuiDocHighlighter(QSyntaxHighlighter): return charFormat -# END Class DocHighlighter +# END Class GuiDocHighlighter diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index b12f655f..55813a71 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -25,11 +25,12 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING from pathlib import Path from urllib.parse import urljoin from urllib.request import pathname2url -from PyQt5.QtCore import QUrl +from PyQt5.QtCore import QUrl, pyqtSlot from PyQt5.QtGui import QDesktopServices from PyQt5.QtWidgets import QMenuBar, QAction @@ -37,6 +38,9 @@ from novelwriter import CONFIG, SHARED from novelwriter.enum import nwDocAction, nwDocInsert, nwWidget from novelwriter.constants import nwConst, trConst, nwKeyWords, nwLabels, nwUnicode +if TYPE_CHECKING: # pragma: no cover + from novelwriter.guimain import GuiMain + logger = logging.getLogger(__name__) @@ -46,7 +50,7 @@ class GuiMainMenu(QMenuBar): add them from this class. """ - def __init__(self, mainGui): + def __init__(self, mainGui: GuiMain) -> None: super().__init__(parent=mainGui) logger.debug("Create: GuiMainMenu") @@ -77,46 +81,52 @@ class GuiMainMenu(QMenuBar): # Update Menu on Settings Changed ## - def setSpellCheck(self, theMode): - """Forward spell check check state to its action. - """ - self.aSpellCheck.setChecked(theMode) + def setSpellCheck(self, state: bool) -> None: + """Forward spell check check state to its action.""" + self.aSpellCheck.setChecked(state) return ## - # Slots + # Private Slots ## - def _toggleSpellCheck(self): + @pyqtSlot() + def _toggleSpellCheck(self) -> None: """Toggle spell checking. The active status of the spell check flag is handled by the document editor class, so we make no decision, just pass a None to the function and let it decide. """ self.mainGui.docEditor.toggleSpellCheck(None) - return True + return - def _openWebsite(self, theUrl): - """Open a URL in the system's default browser. - """ - QDesktopServices.openUrl(QUrl(theUrl)) - return True + @pyqtSlot(str) + def _openWebsite(self, url: str) -> None: + """Open a URL in the system's default browser.""" + QDesktopServices.openUrl(QUrl(url)) + return - def _openUserManualFile(self): - """Open the documentation in PDF format. - """ + @pyqtSlot() + def _openUserManualFile(self) -> None: + """Open the documentation in PDF format.""" if isinstance(CONFIG.pdfDocs, Path): QDesktopServices.openUrl( QUrl(urljoin("file:", pathname2url(str(CONFIG.pdfDocs)))) ) return + @pyqtSlot(str) + def _changeSpelling(self, language: str) -> None: + """Change the spell check language.""" + SHARED.project.data.setSpellLang(language) + SHARED.updateSpellCheckLanguage() + return + ## - # Menu Builders + # Internal Functions ## - def _buildProjectMenu(self): - """Assemble the Project menu. - """ + def _buildProjectMenu(self) -> None: + """Assemble the Project menu.""" # Project self.projMenu = self.addMenu(self.tr("&Project")) @@ -190,9 +200,8 @@ class GuiMainMenu(QMenuBar): return - def _buildDocumentMenu(self): - """Assemble the Document menu. - """ + def _buildDocumentMenu(self) -> None: + """Assemble the Document menu.""" # Document self.docuMenu = self.addMenu(self.tr("&Document")) @@ -245,9 +254,8 @@ class GuiMainMenu(QMenuBar): return - def _buildEditMenu(self): - """Assemble the Edit menu. - """ + def _buildEditMenu(self) -> None: + """Assemble the Edit menu.""" # Edit self.editMenu = self.addMenu(self.tr("&Edit")) @@ -301,9 +309,8 @@ class GuiMainMenu(QMenuBar): return - def _buildViewMenu(self): - """Assemble the View menu. - """ + def _buildViewMenu(self) -> None: + """Assemble the View menu.""" # View self.viewMenu = self.addMenu(self.tr("&View")) @@ -375,9 +382,8 @@ class GuiMainMenu(QMenuBar): return - def _buildInsertMenu(self): - """Assemble the Insert menu. - """ + def _buildInsertMenu(self) -> None: + """Assemble the Insert menu.""" # Insert self.insMenu = self.addMenu(self.tr("&Insert")) @@ -589,9 +595,8 @@ class GuiMainMenu(QMenuBar): return - def _buildFormatMenu(self): - """Assemble the Format menu. - """ + def _buildFormatMenu(self) -> None: + """Assemble the Format menu.""" # Format self.fmtMenu = self.addMenu(self.tr("&Format")) @@ -739,9 +744,8 @@ class GuiMainMenu(QMenuBar): return - def _buildSearchMenu(self): - """Assemble the Search menu. - """ + def _buildSearchMenu(self) -> None: + """Assemble the Search menu.""" # Search self.srcMenu = self.addMenu(self.tr("&Search")) @@ -753,28 +757,21 @@ class GuiMainMenu(QMenuBar): # Search > Replace self.aReplace = QAction(self.tr("Replace"), self) - if CONFIG.osDarwin: - self.aReplace.setShortcut("Ctrl+=") - else: - self.aReplace.setShortcut("Ctrl+H") + self.aReplace.setShortcut("Ctrl+=" if CONFIG.osDarwin else "Ctrl+H") self.aReplace.triggered.connect(lambda: self.mainGui.docEditor.beginReplace()) self.srcMenu.addAction(self.aReplace) # Search > Find Next self.aFindNext = QAction(self.tr("Find Next"), self) - if CONFIG.osDarwin: - self.aFindNext.setShortcuts(["Ctrl+G", "F3"]) - else: - self.aFindNext.setShortcuts(["F3", "Ctrl+G"]) + self.aFindNext.setShortcuts(["Ctrl+G", "F3"] if CONFIG.osDarwin else ["F3", "Ctrl+G"]) self.aFindNext.triggered.connect(lambda: self.mainGui.docEditor.findNext()) self.srcMenu.addAction(self.aFindNext) # Search > Find Prev self.aFindPrev = QAction(self.tr("Find Previous"), self) - if CONFIG.osDarwin: - self.aFindPrev.setShortcuts(["Ctrl+Shift+G", "Shift+F3"]) - else: - self.aFindPrev.setShortcuts(["Shift+F3", "Ctrl+Shift+G"]) + self.aFindPrev.setShortcuts( + ["Ctrl+Shift+G", "Shift+F3"] if CONFIG.osDarwin else ["Shift+F3", "Ctrl+Shift+G"] + ) self.aFindPrev.triggered.connect(lambda: self.mainGui.docEditor.findNext(goBack=True)) self.srcMenu.addAction(self.aFindPrev) @@ -786,9 +783,8 @@ class GuiMainMenu(QMenuBar): return - def _buildToolsMenu(self): - """Assemble the Tools menu. - """ + def _buildToolsMenu(self) -> None: + """Assemble the Tools menu.""" # Tools self.toolsMenu = self.addMenu(self.tr("&Tools")) @@ -800,6 +796,15 @@ class GuiMainMenu(QMenuBar): self.aSpellCheck.setShortcut("Ctrl+F7") self.toolsMenu.addAction(self.aSpellCheck) + self.mSelectLanguage = self.toolsMenu.addMenu(self.tr("Spell Check Language")) + languages = SHARED.spelling.listDictionaries() + languages.insert(0, ("None", self.tr("Default"))) + for n, (tag, language) in enumerate(languages): + aSpell = QAction(self.mSelectLanguage) + aSpell.setText(language) + aSpell.triggered.connect(lambda n, tag=tag: self._changeSpelling(tag)) + self.mSelectLanguage.addAction(aSpell) + # Tools > Re-Run Spell Check self.aReRunSpell = QAction(self.tr("Re-Run Spell Check"), self) self.aReRunSpell.setShortcut("F7") @@ -849,9 +854,8 @@ class GuiMainMenu(QMenuBar): return - def _buildHelpMenu(self): - """Assemble the Help menu. - """ + def _buildHelpMenu(self) -> None: + """Assemble the Help menu.""" # Help self.helpMenu = self.addMenu(self.tr("&Help")) diff --git a/novelwriter/gui/statusbar.py b/novelwriter/gui/statusbar.py index a80864ca..cb5ca85a 100644 --- a/novelwriter/gui/statusbar.py +++ b/novelwriter/gui/statusbar.py @@ -3,8 +3,7 @@ novelWriter – GUI Main Window Status Bar ======================================== File History: -Created: 2019-04-20 [0.0.1] GuiMainStatus -Created: 2020-05-17 [0.5.1] StatusLED +Created: 2019-04-20 [0.0.1] This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -208,13 +207,8 @@ class GuiMainStatus(QStatusBar): self.langText.setText(self.tr("None")) self.langText.setToolTip("") else: - qLocal = QLocale(language) - spLang = qLocal.nativeLanguageName().title() - self.langText.setText(spLang) - if provider: - self.langText.setToolTip("%s (%s)" % (language, provider)) - else: - self.langText.setToolTip(language) + self.langText.setText(QLocale(language).nativeLanguageName().title()) + self.langText.setToolTip(f"{language} ({provider})" if provider else language) return @pyqtSlot(bool) diff --git a/tests/test_base/test_base_shared.py b/tests/test_base/test_base_shared.py index 80f80954..bbf706e0 100644 --- a/tests/test_base/test_base_shared.py +++ b/tests/test_base/test_base_shared.py @@ -42,6 +42,8 @@ def testBaseSharedData_Init(): shared.theme with pytest.raises(Exception): shared.project + with pytest.raises(Exception): + shared.spelling # Create some mock objects mockGui = MockGuiMain() @@ -113,7 +115,7 @@ def testBaseSharedData_Projects(fncPath, caplog: pytest.LogCaptureFixture): project.openProject(fncPath) # First open with our independent project instance assert shared.hasProject is False assert shared.projectLock is None - assert shared.openProject(fncPath) is False # Then with out shared instance + assert shared.openProject(fncPath) is False # Then with our shared instance assert shared.hasProject is False assert isinstance(shared.projectLock, list) diff --git a/tests/test_dialogs/test_dlg_preferences.py b/tests/test_dialogs/test_dlg_preferences.py index 09a858fc..14712795 100644 --- a/tests/test_dialogs/test_dlg_preferences.py +++ b/tests/test_dialogs/test_dlg_preferences.py @@ -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(SHARED._spelling, "listDictionaries", lambda: [("en", "none")]) + monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")]) 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 a54639fc..f56f01bf 100644 --- a/tests/test_dialogs/test_dlg_projsettings.py +++ b/tests/test_dialogs/test_dlg_projsettings.py @@ -84,8 +84,7 @@ 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.""" - # Mock components - monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "none")]) + monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")]) # Create new project buildTestProject(nwGUI, projPath) @@ -147,9 +146,7 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPat dialog. """ monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) - - # Mock components - monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "none")]) + monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")]) # Create new project mockRnd.reset() @@ -347,9 +344,7 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPat def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): """Test the auto-replace tab of the project settings dialog.""" monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) - - # Mock components - monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "none")]) + monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")]) # Create new project mockRnd.reset() diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 0c65cb5a..a7ddd3a6 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -242,7 +242,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): assert nwGUI.projView.projTree._getTreeItem(C.hSceneDoc) is not None nwGUI.mainMenu.aSpellCheck.setChecked(True) - assert nwGUI.mainMenu._toggleSpellCheck() + nwGUI.mainMenu._toggleSpellCheck() + assert nwGUI.mainMenu.aSpellCheck.isChecked() is True # Change some settings CONFIG.hideHScroll = True From cf2d30895c4136465d42a27f97f64fca6b196122 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 25 Aug 2023 13:12:23 +0200 Subject: [PATCH 6/6] Remove added test check that fails --- tests/test_gui/test_gui_guimain.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index a7ddd3a6..e3025931 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -243,7 +243,6 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd): nwGUI.mainMenu.aSpellCheck.setChecked(True) nwGUI.mainMenu._toggleSpellCheck() - assert nwGUI.mainMenu.aSpellCheck.isChecked() is True # Change some settings CONFIG.hideHScroll = True