Refactor handling of spell checking (#1508)

This commit is contained in:
Veronica Berglyd Olsen
2023-08-25 12:19:25 +01:00
committed by GitHub
19 changed files with 438 additions and 475 deletions
+9 -13
View File
@@ -31,7 +31,7 @@ from typing import TYPE_CHECKING, Iterator
from pathlib import Path from pathlib import Path
from functools import partial 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 import CONFIG, SHARED, __version__, __hexversion__
from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout
@@ -55,13 +55,9 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NWProject(QObject): class NWProject:
statusChanged = pyqtSignal(bool) def __init__(self) -> None:
statusMessage = pyqtSignal(str)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent=parent)
# Core Elements # Core Elements
self._options = OptionState(self) # Project-specific GUI options self._options = OptionState(self) # Project-specific GUI options
@@ -206,7 +202,7 @@ class NWProject(QObject):
def trashFolder(self) -> str: def trashFolder(self) -> str:
"""Add the special trash root folder to the project.""" """Add the special trash root folder to the project."""
trashHandle = self._tree.trashRoot() trashHandle = self._tree.trashRoot
if trashHandle is None: if trashHandle is None:
label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH]) label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH) return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH)
@@ -331,7 +327,7 @@ class NWProject(QObject):
self.setProjectChanged(False) self.setProjectChanged(False)
self._valid = True 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 return True
@@ -381,7 +377,7 @@ class NWProject(QObject):
) )
self._storage.writeLockFile() 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) self.setProjectChanged(False)
return True return True
@@ -403,7 +399,7 @@ class NWProject(QObject):
return False return False
logger.info("Backing up project") 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: if not self._data.name:
SHARED.error(self.tr( SHARED.error(self.tr(
@@ -434,7 +430,7 @@ class NWProject(QObject):
SHARED.error(self.tr("Could not write backup archive.")) SHARED.error(self.tr("Could not write backup archive."))
return False 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 return True
@@ -488,7 +484,7 @@ class NWProject(QObject):
""" """
if isinstance(status, bool): if isinstance(status, bool):
self._changed = status self._changed = status
self.statusChanged.emit(self._changed) SHARED.setGlobalProjectState(self._changed)
return self._changed return self._changed
## ##
+38 -38
View File
@@ -29,6 +29,8 @@ import logging
from typing import TYPE_CHECKING, Iterator from typing import TYPE_CHECKING, Iterator
from pathlib import Path from pathlib import Path
from PyQt5.QtCore import QLocale
from novelwriter.error import logException from novelwriter.error import logException
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
@@ -47,11 +49,15 @@ class NWSpellEnchant:
def __init__(self, project: NWProject) -> None: def __init__(self, project: NWProject) -> None:
self._project = project self._project = project
self._dictObj = FakeEnchant() self._enchant = FakeEnchant()
self._userDict = UserDictionary(project) self._userDict = UserDictionary(project)
self._language = None self._language = None
self._broker = 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 return
## ##
@@ -72,7 +78,7 @@ class NWSpellEnchant:
crash. Note that enchant will allow loading an empty string as crash. Note that enchant will allow loading an empty string as
a tag, but this will fail later on. See issue #1096. a tag, but this will fail later on. See issue #1096.
""" """
self._dictObj = FakeEnchant() self._enchant = FakeEnchant()
self._broker = None self._broker = None
self._language = None self._language = None
@@ -81,7 +87,7 @@ class NWSpellEnchant:
if language and enchant.dict_exists(language): if language and enchant.dict_exists(language):
self._broker = enchant.Broker() self._broker = enchant.Broker()
self._dictObj = self._broker.request_dict(language) self._enchant = self._broker.request_dict(language)
self._language = language self._language = language
logger.debug("Enchant spell checking for language '%s' loaded", language) logger.debug("Enchant spell checking for language '%s' loaded", language)
else: else:
@@ -90,12 +96,12 @@ class NWSpellEnchant:
except Exception: except Exception:
logger.error("Failed to load enchant spell checking for language '%s'", language) logger.error("Failed to load enchant spell checking for language '%s'", language)
if self._dictObj is None: if self._enchant is None:
self._dictObj = FakeEnchant() self._enchant = FakeEnchant()
else: else:
self._userDict.load() self._userDict.load()
for pWord in self._userDict: for word in self._userDict:
self._dictObj.add_to_session(pWord) self._enchant.add_to_session(word)
return return
@@ -106,14 +112,14 @@ class NWSpellEnchant:
def checkWord(self, word: str) -> bool: def checkWord(self, word: str) -> bool:
"""Wrapper function for pyenchant.""" """Wrapper function for pyenchant."""
try: try:
return bool(self._dictObj.check(word)) return bool(self._enchant.check(word))
except Exception: except Exception:
return True return True
def suggestWords(self, word: str) -> list[str]: def suggestWords(self, word: str) -> list[str]:
"""Wrapper function for pyenchant.""" """Wrapper function for pyenchant."""
try: try:
return self._dictObj.suggest(word) return self._enchant.suggest(word)
except Exception: except Exception:
return [] return []
@@ -123,7 +129,7 @@ class NWSpellEnchant:
if not word: if not word:
return False return False
try: try:
self._dictObj.add_to_session(word) self._enchant.add_to_session(word)
except Exception: except Exception:
return False return False
@@ -134,30 +140,26 @@ class NWSpellEnchant:
return added return added
def listDictionaries(self) -> list[tuple[str, str]]: def listDictionaries(self) -> list[tuple[str, str]]:
"""Wrapper function for pyenchant.""" """List available dictionaries."""
retList = [] lang = []
try: try:
import enchant import enchant
for spTag, spProvider in enchant.list_dicts(): tags = [x for x, _ in enchant.list_dicts()]
retList.append((spTag, spProvider.name)) lang = [(x, f"{QLocale(x).nativeLanguageName().title()} [{x}]") for x in set(tags)]
except Exception: except Exception:
logger.error("Failed to list languages for enchant spell checking") logger.error("Failed to list languages for enchant spell checking")
return sorted(lang, key=lambda x: x[1])
return retList
def describeDict(self) -> tuple[str, str]: def describeDict(self) -> tuple[str, str]:
"""Return the tag and provider of the currently loaded """Describe the currently loaded dictionary."""
dictionary.
"""
try: try:
tag = self._dictObj.tag tag = self._enchant.tag
name = self._dictObj.provider.name # type: ignore name = self._enchant.provider.name # type: ignore
except Exception: except Exception:
logger.error("Failed to extract information about the dictionary") logger.error("Failed to extract information about the dictionary")
logException() logException()
tag = "" tag = ""
name = "" name = ""
return tag, name return tag, name
# END Class NWSpellEnchant # END Class NWSpellEnchant
@@ -192,7 +194,6 @@ class UserDictionary:
def __init__(self, project: NWProject) -> None: def __init__(self, project: NWProject) -> None:
self._project = project self._project = project
self._words = set() self._words = set()
self._path = None
return return
def __contains__(self, word: str) -> bool: def __contains__(self, word: str) -> bool:
@@ -212,13 +213,14 @@ class UserDictionary:
def load(self) -> None: def load(self) -> None:
"""Load the user's dictionary.""" """Load the user's dictionary."""
self._path = self._project.storage.getMetaFile(nwFiles.DICT_FILE)
self._words = set() 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: 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) data = json.load(fObj)
self._words = set(data.get("novelWriter.userDict", [])) self._words = set(data.get("novelWriter.userDict", []))
logger.info("Loaded: %s", nwFiles.DICT_FILE)
except Exception: except Exception:
logger.error("Failed to load user dictionary") logger.error("Failed to load user dictionary")
logException() logException()
@@ -226,17 +228,15 @@ class UserDictionary:
def save(self) -> None: def save(self) -> None:
"""Save the user's dictionary.""" """Save the user's dictionary."""
if self._path is None: wordList = self._project.storage.getMetaFile(nwFiles.DICT_FILE)
self._path = self._project.storage.getMetaFile(nwFiles.DICT_FILE) if isinstance(wordList, Path):
if not isinstance(self._path, Path): try:
return with open(wordList, mode="w", encoding="utf-8") as fObj:
try: data = {"novelWriter.userDict": list(self._words)}
with open(self._path, mode="w", encoding="utf-8") as fObj: json.dump(data, fObj, indent=2)
data = {"novelWriter.userDict": list(self._words)} except Exception:
json.dump(data, fObj, indent=2) logger.error("Failed to save user dictionary")
except Exception: logException()
logger.error("Failed to save user dictionary")
logException()
return return
# END Class UserDictionary # END Class UserDictionary
+14 -11
View File
@@ -40,6 +40,8 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAX_DEPTH = 1000 # Cap of tree traversing for loops (recursion limit)
class NWTree: class NWTree:
"""Core: Project Tree Data Class """Core: Project Tree Data Class
@@ -59,7 +61,7 @@ class NWTree:
also used for file names. 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: def __init__(self, project: NWProject) -> None:
@@ -74,6 +76,15 @@ class NWTree:
return return
##
# Properties
##
@property
def trashRoot(self) -> str | None:
"""Return the handle of the trash folder, or None."""
return self._trash
## ##
# Class Methods # Class Methods
## ##
@@ -320,7 +331,7 @@ class NWTree:
return False return False
iItem = tItem iItem = tItem
for _ in range(self.MAX_DEPTH): for _ in range(MAX_DEPTH):
if iItem.itemParent is None: if iItem.itemParent is None:
tItem.setRoot(iItem.itemHandle) tItem.setRoot(iItem.itemHandle)
tItem.setClassDefaults(iItem.itemClass) tItem.setClassDefaults(iItem.itemClass)
@@ -349,7 +360,7 @@ class NWTree:
tItem = self.__getitem__(tHandle) tItem = self.__getitem__(tHandle)
if tItem is not None: if tItem is not None:
tTree.append(tHandle) tTree.append(tHandle)
for _ in range(self.MAX_DEPTH): for _ in range(MAX_DEPTH):
if tItem.itemParent is None: if tItem.itemParent is None:
return tTree return tTree
else: else:
@@ -400,14 +411,6 @@ class NWTree:
return True return True
return False 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: def findRoot(self, itemClass: nwItemClass | None) -> str | None:
"""Find the first root item for a given class.""" """Find the first root item for a given class."""
for aRoot in self._roots: for aRoot in self._roots:
+4 -11
View File
@@ -26,7 +26,7 @@ from __future__ import annotations
import logging import logging
from PyQt5.QtGui import QFont from PyQt5.QtGui import QFont
from PyQt5.QtCore import Qt, QLocale from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QDialog, QWidget, QComboBox, QSpinBox, QPushButton, QDialogButtonBox, QDialog, QWidget, QComboBox, QSpinBox, QPushButton, QDialogButtonBox,
QLineEdit, QFileDialog, QFontDialog, QDoubleSpinBox QLineEdit, QFileDialog, QFontDialog, QDoubleSpinBox
@@ -49,8 +49,6 @@ class GuiPreferences(NPagedDialog):
logger.debug("Create: GuiPreferences") logger.debug("Create: GuiPreferences")
self.setObjectName("GuiPreferences") self.setObjectName("GuiPreferences")
self.mainGui = mainGui
self.setWindowTitle(self.tr("Preferences")) self.setWindowTitle(self.tr("Preferences"))
self.tabGeneral = GuiPreferencesGeneral(self) self.tabGeneral = GuiPreferencesGeneral(self)
@@ -645,8 +643,6 @@ class GuiPreferencesEditor(QWidget):
def __init__(self, prefsGui): def __init__(self, prefsGui):
super().__init__(parent=prefsGui) super().__init__(parent=prefsGui)
self.mainGui = prefsGui.mainGui
# The Form # The Form
self.mainForm = NConfigLayout() self.mainForm = NConfigLayout()
self.mainForm.setHelpTextStyle(SHARED.theme.helpText) self.mainForm.setHelpTextStyle(SHARED.theme.helpText)
@@ -662,12 +658,9 @@ class GuiPreferencesEditor(QWidget):
self.spellLanguage = QComboBox(self) self.spellLanguage = QComboBox(self)
self.spellLanguage.setMaximumWidth(mW) self.spellLanguage.setMaximumWidth(mW)
langAvail = self.mainGui.docEditor.spEnchant.listDictionaries() if CONFIG.hasEnchant:
if CONFIG.hasEnchant and langAvail: for tag, language in SHARED.spelling.listDictionaries():
for spTag, spProv in langAvail: self.spellLanguage.addItem(language, tag)
qLocal = QLocale(spTag)
spLang = qLocal.nativeLanguageName().title()
self.spellLanguage.addItem("%s [%s]" % (spLang, spProv), spTag)
else: else:
self.spellLanguage.addItem(self.tr("None"), "") self.spellLanguage.addItem(self.tr("None"), "")
self.spellLanguage.setEnabled(False) self.spellLanguage.setEnabled(False)
+5 -18
View File
@@ -28,7 +28,7 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from PyQt5.QtGui import QIcon, QPixmap, QColor from PyQt5.QtGui import QIcon, QPixmap, QColor
from PyQt5.QtCore import Qt, QLocale, pyqtSlot from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QColorDialog, QComboBox, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit, QColorDialog, QComboBox, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
@@ -89,9 +89,6 @@ class GuiProjectSettings(NPagedDialog):
self.buttonBox.rejected.connect(self._doClose) self.buttonBox.rejected.connect(self._doClose)
self.addControls(self.buttonBox) self.addControls(self.buttonBox)
# Flags
self._spellChanged = False
# Focus Tab # Focus Tab
self._focusTab(focusTab) self._focusTab(focusTab)
@@ -103,10 +100,6 @@ class GuiProjectSettings(NPagedDialog):
logger.debug("Delete: GuiProjectSettings") logger.debug("Delete: GuiProjectSettings")
return return
@property
def spellChanged(self):
return self._spellChanged
## ##
# Slots # Slots
## ##
@@ -125,9 +118,7 @@ class GuiProjectSettings(NPagedDialog):
project.data.setTitle(bookTitle) project.data.setTitle(bookTitle)
project.data.setAuthor(bookAuthor) project.data.setAuthor(bookAuthor)
project.data.setDoBackup(doBackup) project.data.setDoBackup(doBackup)
project.data.setSpellLang(spellLang)
# Remember this as updating spell dictionary can be expensive
self._spellChanged = project.data.setSpellLang(spellLang)
if self.tabStatus.colChanged: if self.tabStatus.colChanged:
newList, delList = self.tabStatus.getNewList() newList, delList = self.tabStatus.getNewList()
@@ -199,8 +190,6 @@ class GuiProjectEditMain(QWidget):
def __init__(self, projGui): def __init__(self, projGui):
super().__init__(parent=projGui) super().__init__(parent=projGui)
self.mainGui = projGui.mainGui
# The Form # The Form
self.mainForm = NConfigLayout() self.mainForm = NConfigLayout()
self.mainForm.setHelpTextStyle(SHARED.theme.helpText) self.mainForm.setHelpTextStyle(SHARED.theme.helpText)
@@ -244,11 +233,9 @@ class GuiProjectEditMain(QWidget):
self.spellLang = QComboBox(self) self.spellLang = QComboBox(self)
self.spellLang.setMaximumWidth(xW) self.spellLang.setMaximumWidth(xW)
self.spellLang.addItem(self.tr("Default"), "None") self.spellLang.addItem(self.tr("Default"), "None")
langAvail = self.mainGui.docEditor.spEnchant.listDictionaries() if CONFIG.hasEnchant:
for spTag, spProv in langAvail: for tag, language in SHARED.spelling.listDictionaries():
qLocal = QLocale(spTag) self.spellLang.addItem(language, tag)
spLang = qLocal.nativeLanguageName().title()
self.spellLang.addItem("%s [%s]" % (spLang, spProv), spTag)
self.mainForm.addRow( self.mainForm.addRow(
self.tr("Spell check language"), self.tr("Spell check language"),
+21 -42
View File
@@ -54,7 +54,6 @@ from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass
from novelwriter.common import minmax, transferCase from novelwriter.common import minmax, transferCase
from novelwriter.constants import nwConst, nwKeyWords, nwUnicode from novelwriter.constants import nwConst, nwKeyWords, nwUnicode
from novelwriter.core.index import countWords from novelwriter.core.index import countWords
from novelwriter.core.spellcheck import NWSpellEnchant
from novelwriter.gui.dochighlight import GuiDocHighlighter from novelwriter.gui.dochighlight import GuiDocHighlighter
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
@@ -74,7 +73,6 @@ class GuiDocEditor(QTextEdit):
statusMessage = pyqtSignal(str) statusMessage = pyqtSignal(str)
docCountsChanged = pyqtSignal(str, int, int, int) docCountsChanged = pyqtSignal(str, int, int, int)
editedStatusChanged = pyqtSignal(bool) editedStatusChanged = pyqtSignal(bool)
spellDictionaryChanged = pyqtSignal(str, str)
loadDocumentTagRequest = pyqtSignal(str, Enum) loadDocumentTagRequest = pyqtSignal(str, Enum)
novelStructureChanged = pyqtSignal() novelStructureChanged = pyqtSignal()
novelItemMetaChanged = pyqtSignal(str) novelItemMetaChanged = pyqtSignal(str)
@@ -133,8 +131,7 @@ class GuiDocEditor(QTextEdit):
self.docSearch = GuiDocEditSearch(self) self.docSearch = GuiDocEditSearch(self)
# Syntax # Syntax
self.spEnchant = NWSpellEnchant(SHARED.project) self.highLight = GuiDocHighlighter(qDoc)
self.highLight = GuiDocHighlighter(qDoc, self.spEnchant)
# Context Menu # Context Menu
self.setContextMenuPolicy(Qt.CustomContextMenu) self.setContextMenuPolicy(Qt.CustomContextMenu)
@@ -303,7 +300,7 @@ class GuiDocEditor(QTextEdit):
self._typPadAfter = CONFIG.fmtPadAfter self._typPadAfter = CONFIG.fmtPadAfter
# Reload spell check and dictionaries # Reload spell check and dictionaries
self.setDictionaries() SHARED.updateSpellCheckLanguage()
# Set font # Set font
textFont = QFont() textFont = QFont()
@@ -399,7 +396,7 @@ class GuiDocEditor(QTextEdit):
self._checkDocSize(docSize) self._checkDocSize(docSize)
spTemp = self.highLight.spellCheck spTemp = self.highLight.spellCheck
if self._bigDoc: if self._bigDoc:
self.highLight.spellCheck = False self.highLight.setSpellCheck(False)
bfTime = time() bfTime = time()
self._allowAutoReplace(False) self._allowAutoReplace(False)
@@ -420,7 +417,7 @@ class GuiDocEditor(QTextEdit):
self.docHeader.setTitleFromHandle(self._docHandle) self.docHeader.setTitleFromHandle(self._docHandle)
self.docFooter.setHandle(self._docHandle) self.docFooter.setHandle(self._docHandle)
self.updateDocMargins() self.updateDocMargins()
self.highLight.spellCheck = spTemp self.highLight.setSpellCheck(spTemp)
if tLine is None and self._nwItem is not None: if tLine is None and self._nwItem is not None:
# For large documents, we queue the repositioning until the # For large documents, we queue the repositioning until the
@@ -693,55 +690,37 @@ class GuiDocEditor(QTextEdit):
# Spell Checking # Spell Checking
## ##
def setDictionaries(self): def toggleSpellCheck(self, state: bool | None) -> None:
"""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
self.spEnchant.setLanguage(theLang)
_, theProvider = self.spEnchant.describeDict()
self.spellDictionaryChanged.emit(str(theLang), str(theProvider))
if not self._bigDoc:
self.spellCheckDocument()
return True
def toggleSpellCheck(self, theMode):
"""This is the main spell check setting function, and this one """This is the main spell check setting function, and this one
should call all other setSpellCheck functions in other classes. should call all other setSpellCheck functions in other classes.
If the spell check mode (theMode) is not defined (None), then If the spell check mode (theMode) is not defined (None), then
toggle the current status saved in this class. toggle the current status saved in this class.
""" """
if theMode is None: if state is None:
theMode = not self._spellCheck state = not self._spellCheck
if not CONFIG.hasEnchant: if not CONFIG.hasEnchant:
if theMode: if state:
SHARED.info(self.tr( SHARED.info(self.tr(
"Spell checking requires the package PyEnchant. " "Spell checking requires the package PyEnchant. "
"It does not appear to be installed." "It does not appear to be installed."
)) ))
theMode = False state = False
if self.spEnchant.spellLanguage is None: if SHARED.spelling.spellLanguage is None:
theMode = False state = False
self._spellCheck = theMode self._spellCheck = state
self.mainGui.mainMenu.setSpellCheck(theMode) self.mainGui.mainMenu.setSpellCheck(state)
SHARED.project.data.setSpellCheck(theMode) SHARED.project.data.setSpellCheck(state)
self.highLight.setSpellCheck(theMode) self.highLight.setSpellCheck(state)
if not self._bigDoc or theMode is False: if not self._bigDoc or state is False:
# We don't run the spell checker automatically on big docs # We don't run the spell checker automatically on big docs
self.spellCheckDocument() 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: def spellCheckDocument(self) -> None:
"""Rerun the highlighter to update spell checking status of the """Rerun the highlighter to update spell checking status of the
@@ -1193,14 +1172,14 @@ class GuiDocEditor(QTextEdit):
if spellCheck: if spellCheck:
logger.debug("Looking up '%s' in the dictionary", theWord) logger.debug("Looking up '%s' in the dictionary", theWord)
spellCheck &= not self.spEnchant.checkWord(theWord) spellCheck &= not SHARED.spelling.checkWord(theWord)
if spellCheck: if spellCheck:
mnuContext.addSeparator() mnuContext.addSeparator()
mnuHead = QAction(self.tr("Spelling Suggestion(s)"), mnuContext) mnuHead = QAction(self.tr("Spelling Suggestion(s)"), mnuContext)
mnuContext.addAction(mnuHead) mnuContext.addAction(mnuHead)
theSuggest = self.spEnchant.suggestWords(theWord)[:15] theSuggest = SHARED.spelling.suggestWords(theWord)[:15]
if len(theSuggest) > 0: if len(theSuggest) > 0:
for aWord in theSuggest: for aWord in theSuggest:
mnuWord = QAction("%s %s" % (nwUnicode.U_ENDASH, aWord), mnuContext) mnuWord = QAction("%s %s" % (nwUnicode.U_ENDASH, aWord), mnuContext)
@@ -1245,7 +1224,7 @@ class GuiDocEditor(QTextEdit):
""" """
theWord = theCursor.selectedText().strip().strip(self._nonWord) theWord = theCursor.selectedText().strip().strip(self._nonWord)
logger.debug("Added '%s' to project dictionary", theWord) logger.debug("Added '%s' to project dictionary", theWord)
self.spEnchant.addWord(theWord) SHARED.spelling.addWord(theWord)
self.highLight.rehighlightBlock(theCursor.block()) self.highLight.rehighlightBlock(theCursor.block())
return return
+185 -182
View File
@@ -29,7 +29,7 @@ from time import time
from PyQt5.QtCore import Qt, QRegularExpression from PyQt5.QtCore import Qt, QRegularExpression
from PyQt5.QtGui import ( from PyQt5.QtGui import (
QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush, QTextDocument
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
@@ -46,31 +46,32 @@ class GuiDocHighlighter(QSyntaxHighlighter):
BLOCK_META = 2 BLOCK_META = 2
BLOCK_TITLE = 4 BLOCK_TITLE = 4
def __init__(self, theDoc, spEnchant): def __init__(self, document: QTextDocument) -> None:
super().__init__(theDoc) super().__init__(document)
logger.debug("Create: GuiDocHighlighter") logger.debug("Create: GuiDocHighlighter")
self.theDoc = theDoc self._tHandle = None
self.spEnchant = spEnchant self._spellCheck = False
self.theHandle = None self._spellRx = QRegularExpression()
self.spellCheck = False
self.spellRx = None
self.hRules = []
self.hStyles = {}
self.colHead = QColor(0, 0, 0) self._hRules: list[tuple[str, dict]] = []
self.colHeadH = QColor(0, 0, 0) self._hStyles: dict[str, QTextCharFormat] = {}
self.colEmph = QColor(0, 0, 0)
self.colDialN = QColor(0, 0, 0) self._colHead = QColor(0, 0, 0)
self.colDialD = QColor(0, 0, 0) self._colHeadH = QColor(0, 0, 0)
self.colDialS = QColor(0, 0, 0) self._colEmph = QColor(0, 0, 0)
self.colHidden = QColor(0, 0, 0) self._colDialN = QColor(0, 0, 0)
self.colKey = QColor(0, 0, 0) self._colDialD = QColor(0, 0, 0)
self.colVal = QColor(0, 0, 0) self._colDialS = QColor(0, 0, 0)
self.colSpell = QColor(0, 0, 0) self._colHidden = QColor(0, 0, 0)
self.colError = QColor(0, 0, 0) self._colKey = QColor(0, 0, 0)
self.colRepTag = 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() self.initHighlighter()
@@ -78,71 +79,76 @@ class GuiDocHighlighter(QSyntaxHighlighter):
return 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 """Initialise the syntax highlighter, setting all the colour
rules and building the RegExes. rules and building the RegExes.
""" """
logger.debug("Setting up highlighting rules") logger.debug("Setting up highlighting rules")
self.colHead = QColor(*SHARED.theme.colHead) self._colHead = QColor(*SHARED.theme.colHead)
self.colHeadH = QColor(*SHARED.theme.colHeadH) self._colHeadH = QColor(*SHARED.theme.colHeadH)
self.colDialN = QColor(*SHARED.theme.colDialN) self._colDialN = QColor(*SHARED.theme.colDialN)
self.colDialD = QColor(*SHARED.theme.colDialD) self._colDialD = QColor(*SHARED.theme.colDialD)
self.colDialS = QColor(*SHARED.theme.colDialS) self._colDialS = QColor(*SHARED.theme.colDialS)
self.colHidden = QColor(*SHARED.theme.colHidden) self._colHidden = QColor(*SHARED.theme.colHidden)
self.colKey = QColor(*SHARED.theme.colKey) self._colKey = QColor(*SHARED.theme.colKey)
self.colVal = QColor(*SHARED.theme.colVal) self._colVal = QColor(*SHARED.theme.colVal)
self.colSpell = QColor(*SHARED.theme.colSpell) self._colSpell = QColor(*SHARED.theme.colSpell)
self.colError = QColor(*SHARED.theme.colError) self._colError = QColor(*SHARED.theme.colError)
self.colRepTag = QColor(*SHARED.theme.colRepTag) self._colRepTag = QColor(*SHARED.theme.colRepTag)
self.colMod = QColor(*SHARED.theme.colMod) self._colMod = QColor(*SHARED.theme.colMod)
self.colBreak = QColor(*SHARED.theme.colEmph) self._colBreak = QColor(*SHARED.theme.colEmph)
self.colBreak.setAlpha(64) self._colBreak.setAlpha(64)
self.colEmph = None self._colEmph = None
if CONFIG.highlightEmph: if CONFIG.highlightEmph:
self.colEmph = QColor(*SHARED.theme.colEmph) self._colEmph = QColor(*SHARED.theme.colEmph)
self.hStyles = { self._hStyles = {
"header1": self._makeFormat(self.colHead, "bold", 1.8), "header1": self._makeFormat(self._colHead, "bold", 1.8),
"header2": self._makeFormat(self.colHead, "bold", 1.6), "header2": self._makeFormat(self._colHead, "bold", 1.6),
"header3": self._makeFormat(self.colHead, "bold", 1.4), "header3": self._makeFormat(self._colHead, "bold", 1.4),
"header4": self._makeFormat(self.colHead, "bold", 1.2), "header4": self._makeFormat(self._colHead, "bold", 1.2),
"header1h": self._makeFormat(self.colHeadH, "bold", 1.8), "header1h": self._makeFormat(self._colHeadH, "bold", 1.8),
"header2h": self._makeFormat(self.colHeadH, "bold", 1.6), "header2h": self._makeFormat(self._colHeadH, "bold", 1.6),
"header3h": self._makeFormat(self.colHeadH, "bold", 1.4), "header3h": self._makeFormat(self._colHeadH, "bold", 1.4),
"header4h": self._makeFormat(self.colHeadH, "bold", 1.2), "header4h": self._makeFormat(self._colHeadH, "bold", 1.2),
"bold": self._makeFormat(self.colEmph, "bold"), "bold": self._makeFormat(self._colEmph, "bold"),
"italic": self._makeFormat(self.colEmph, "italic"), "italic": self._makeFormat(self._colEmph, "italic"),
"strike": self._makeFormat(self.colHidden, "strike"), "strike": self._makeFormat(self._colHidden, "strike"),
"mspaces": self._makeFormat(self.colError, "errline"), "mspaces": self._makeFormat(self._colError, "errline"),
"nobreak": self._makeFormat(self.colBreak, "background"), "nobreak": self._makeFormat(self._colBreak, "background"),
"dialogue1": self._makeFormat(self.colDialN), "dialogue1": self._makeFormat(self._colDialN),
"dialogue2": self._makeFormat(self.colDialD), "dialogue2": self._makeFormat(self._colDialD),
"dialogue3": self._makeFormat(self.colDialS), "dialogue3": self._makeFormat(self._colDialS),
"replace": self._makeFormat(self.colRepTag), "replace": self._makeFormat(self._colRepTag),
"hidden": self._makeFormat(self.colHidden), "hidden": self._makeFormat(self._colHidden),
"keyword": self._makeFormat(self.colKey), "keyword": self._makeFormat(self._colKey),
"modifier": self._makeFormat(self.colMod), "modifier": self._makeFormat(self._colMod),
"value": self._makeFormat(self.colVal, "underline"), "value": self._makeFormat(self._colVal, "underline"),
"codevalue": self._makeFormat(self.colVal), "codevalue": self._makeFormat(self._colVal),
"codeinval": self._makeFormat(None, "errline"), "codeinval": self._makeFormat(None, "errline"),
} }
self.hRules = [] self._hRules = []
# Multiple or Trailing Spaces # Multiple or Trailing Spaces
if CONFIG.showMultiSpaces: if CONFIG.showMultiSpaces:
self.hRules.append(( self._hRules.append((
r"[ ]{2,}|[ ]*$", { r"[ ]{2,}|[ ]*$", {
0: self.hStyles["mspaces"], 0: self._hStyles["mspaces"],
} }
)) ))
# Non-Breaking Spaces # Non-Breaking Spaces
self.hRules.append(( self._hRules.append((
"[%s%s]+" % (nwUnicode.U_NBSP, nwUnicode.U_THNBSP), { f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+", {
0: self.hStyles["nobreak"], 0: self._hStyles["nobreak"],
} }
)) ))
@@ -155,68 +161,68 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Straight Quotes # Straight Quotes
if not (fmtDblO == fmtDblC == "\""): if not (fmtDblO == fmtDblC == "\""):
self.hRules.append(( self._hRules.append((
"(\\B\")(.*?)(\"\\B)", { "(\\B\")(.*?)(\"\\B)", {
0: self.hStyles["dialogue1"], 0: self._hStyles["dialogue1"],
} }
)) ))
# Double Quotes # Double Quotes
dblEnd = "|$" if CONFIG.allowOpenDQuote else "" dblEnd = "|$" if CONFIG.allowOpenDQuote else ""
self.hRules.append(( self._hRules.append((
f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})", { f"(\\B{fmtDblO})(.*?)({fmtDblC}\\B{dblEnd})", {
0: self.hStyles["dialogue2"], 0: self._hStyles["dialogue2"],
} }
)) ))
# Single Quotes # Single Quotes
sngEnd = "|$" if CONFIG.allowOpenSQuote else "" sngEnd = "|$" if CONFIG.allowOpenSQuote else ""
self.hRules.append(( self._hRules.append((
f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})", { f"(\\B{fmtSngO})(.*?)({fmtSngC}\\B{sngEnd})", {
0: self.hStyles["dialogue3"], 0: self._hStyles["dialogue3"],
} }
)) ))
# Markdown Syntax # Markdown Syntax
self.hRules.append(( self._hRules.append((
nwRegEx.FMT_EI, { nwRegEx.FMT_EI, {
1: self.hStyles["hidden"], 1: self._hStyles["hidden"],
2: self.hStyles["italic"], 2: self._hStyles["italic"],
3: self.hStyles["hidden"], 3: self._hStyles["hidden"],
} }
)) ))
self.hRules.append(( self._hRules.append((
nwRegEx.FMT_EB, { nwRegEx.FMT_EB, {
1: self.hStyles["hidden"], 1: self._hStyles["hidden"],
2: self.hStyles["bold"], 2: self._hStyles["bold"],
3: self.hStyles["hidden"], 3: self._hStyles["hidden"],
} }
)) ))
self.hRules.append(( self._hRules.append((
nwRegEx.FMT_ST, { nwRegEx.FMT_ST, {
1: self.hStyles["hidden"], 1: self._hStyles["hidden"],
2: self.hStyles["strike"], 2: self._hStyles["strike"],
3: self.hStyles["hidden"], 3: self._hStyles["hidden"],
} }
)) ))
# Alignment Tags # Alignment Tags
self.hRules.append(( self._hRules.append((
r"(^>{1,2}|<{1,2}$)", { r"(^>{1,2}|<{1,2}$)", {
1: self.hStyles["hidden"], 1: self._hStyles["hidden"],
} }
)) ))
# Auto-Replace Tags # Auto-Replace Tags
self.hRules.append(( self._hRules.append((
r"<(\S+?)>", { r"<(\S+?)>", {
0: self.hStyles["replace"], 0: self._hStyles["replace"],
} }
)) ))
# Build a QRegExp for each highlight pattern # Build a QRegExp for each highlight pattern
self.rxRules = [] self.rxRules = []
for regEx, regRules in self.hRules: for regEx, regRules in self._hRules:
hReg = QRegularExpression(regEx) hReg = QRegularExpression(regEx)
hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
self.rxRules.append((hReg, regRules)) self.rxRules.append((hReg, regRules))
@@ -225,68 +231,65 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Include additional characters that the highlighter should # Include additional characters that the highlighter should
# consider to be word separators # consider to be word separators
uCode = nwUnicode.U_ENDASH + nwUnicode.U_EMDASH uCode = nwUnicode.U_ENDASH + nwUnicode.U_EMDASH
self.spellRx = QRegularExpression(r"\b[^\s\-\+\/" + uCode + r"]+\b") self._spellRx = QRegularExpression(r"\b[^\s\-\+\/" + uCode + r"]+\b")
self.spellRx.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) self._spellRx.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
return True return
## ##
# Setters # Setters
## ##
def setSpellCheck(self, theMode): def setSpellCheck(self, state: bool) -> None:
"""Enable/disable the real time spell checker. """Enable/disable the real time spell checker."""
""" self._spellCheck = state
self.spellCheck = theMode return
return True
def setHandle(self, theHandle): def setHandle(self, tHandle: str) -> None:
"""Set the handle of the currently highlighted document. This is """Set the handle of the currently highlighted document."""
needed for the index lookup for validating tags and references. self._tHandle = tHandle
""" return
self.theHandle = theHandle
return True
## ##
# Methods # Methods
## ##
def rehighlightByType(self, theType): def rehighlightByType(self, cType: int) -> None:
"""Loop through all blocks and re-highlight those of a given """Loop through all blocks and re-highlight those of a given
content type. content type.
""" """
qDoc = self.document() qDoc = self.document()
nBlocks = qDoc.blockCount() nBlocks = qDoc.blockCount()
bfTime = time() tStart = time()
for i in range(nBlocks): for i in range(nBlocks):
theBlock = qDoc.findBlockByNumber(i) theBlock = qDoc.findBlockByNumber(i)
if theBlock.userState() & theType > 0: if theBlock.userState() & cType > 0:
self.rehighlightBlock(theBlock) self.rehighlightBlock(theBlock)
afTime = time() logger.debug("Document highlighted in %.3f ms" % (1000*(time() - tStart)))
logger.debug(
"Document highlighted in %.3f ms" % (1000*(afTime-bfTime))
)
return return
## ##
# Highlight Block # Highlight Block
## ##
def highlightBlock(self, theText): def highlightBlock(self, text: str) -> None:
"""Highlight a single block. Prefer to check first character for """Highlight a single block. Prefer to check first character for
all formats that are defined by their initial characters. This all formats that are defined by their initial characters. This
is significantly faster than running the regex checks used for is significantly faster than running the regex checks used for
text paragraphs. text paragraphs.
""" """
self.setCurrentBlockState(self.BLOCK_NONE) self.setCurrentBlockState(self.BLOCK_NONE)
if self.theHandle is None or not theText: if self._tHandle is None or not text:
return return
if theText.startswith("@"): # Keywords and commands if text.startswith("@"): # Keywords and commands
self.setCurrentBlockState(self.BLOCK_META) self.setCurrentBlockState(self.BLOCK_META)
pIndex = SHARED.project.index pIndex = SHARED.project.index
tItem = SHARED.project.tree[self.theHandle] tItem = SHARED.project.tree[self._tHandle]
isValid, theBits, thePos = pIndex.scanThis(theText) if tItem is None:
return
isValid, theBits, thePos = pIndex.scanThis(text)
isGood = pIndex.checkThese(theBits, tItem) isGood = pIndex.checkThese(theBits, tItem)
if isValid: if isValid:
for n, theBit in enumerate(theBits): for n, theBit in enumerate(theBits):
@@ -294,12 +297,12 @@ class GuiDocHighlighter(QSyntaxHighlighter):
xLen = len(theBit) xLen = len(theBit)
if isGood[n]: if isGood[n]:
if n == 0: if n == 0:
self.setFormat(xPos, xLen, self.hStyles["keyword"]) self.setFormat(xPos, xLen, self._hStyles["keyword"])
else: else:
self.setFormat(xPos, xLen, self.hStyles["value"]) self.setFormat(xPos, xLen, self._hStyles["value"])
else: else:
kwFmt = self.format(xPos) kwFmt = self.format(xPos)
kwFmt.setUnderlineColor(self.colError) kwFmt.setUnderlineColor(self._colError)
kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
self.setFormat(xPos, xLen, kwFmt) self.setFormat(xPos, xLen, kwFmt)
@@ -307,69 +310,67 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# so we force a return here # so we force a return here
return return
elif theText.startswith(("# ", "#! ", "## ", "##! ", "### ", "#### ")): elif text.startswith(("# ", "#! ", "## ", "##! ", "### ", "#### ")):
self.setCurrentBlockState(self.BLOCK_TITLE) self.setCurrentBlockState(self.BLOCK_TITLE)
if theText.startswith("# "): # Header 1 if text.startswith("# "): # Header 1
self.setFormat(0, 1, self.hStyles["header1h"]) self.setFormat(0, 1, self._hStyles["header1h"])
self.setFormat(1, len(theText), self.hStyles["header1"]) self.setFormat(1, len(text), self._hStyles["header1"])
elif theText.startswith("## "): # Header 2 elif text.startswith("## "): # Header 2
self.setFormat(0, 2, self.hStyles["header2h"]) self.setFormat(0, 2, self._hStyles["header2h"])
self.setFormat(2, len(theText), self.hStyles["header2"]) self.setFormat(2, len(text), self._hStyles["header2"])
elif theText.startswith("### "): # Header 3 elif text.startswith("### "): # Header 3
self.setFormat(0, 3, self.hStyles["header3h"]) self.setFormat(0, 3, self._hStyles["header3h"])
self.setFormat(3, len(theText), self.hStyles["header3"]) self.setFormat(3, len(text), self._hStyles["header3"])
elif theText.startswith("#### "): # Header 4 elif text.startswith("#### "): # Header 4
self.setFormat(0, 4, self.hStyles["header4h"]) self.setFormat(0, 4, self._hStyles["header4h"])
self.setFormat(4, len(theText), self.hStyles["header4"]) self.setFormat(4, len(text), self._hStyles["header4"])
if theText.startswith("#! "): # Title if text.startswith("#! "): # Title
self.setFormat(0, 2, self.hStyles["header1h"]) self.setFormat(0, 2, self._hStyles["header1h"])
self.setFormat(2, len(theText), self.hStyles["header1"]) self.setFormat(2, len(text), self._hStyles["header1"])
elif theText.startswith("##! "): # Unnumbered elif text.startswith("##! "): # Unnumbered
self.setFormat(0, 3, self.hStyles["header2h"]) self.setFormat(0, 3, self._hStyles["header2h"])
self.setFormat(3, len(theText), self.hStyles["header2"]) self.setFormat(3, len(text), self._hStyles["header2"])
elif theText.startswith("%"): # Comments elif text.startswith("%"): # Comments
self.setCurrentBlockState(self.BLOCK_TEXT) self.setCurrentBlockState(self.BLOCK_TEXT)
toCheck = theText[1:].lstrip() toCheck = text[1:].lstrip()
synTag = toCheck[:9].lower() synTag = toCheck[:9].lower()
tLen = len(theText) tLen = len(text)
cLen = len(toCheck) cLen = len(toCheck)
cOff = tLen - cLen cOff = tLen - cLen
if synTag == "synopsis:": if synTag == "synopsis:":
self.setFormat(0, cOff+9, self.hStyles["modifier"]) self.setFormat(0, cOff+9, self._hStyles["modifier"])
self.setFormat(cOff+9, tLen, self.hStyles["hidden"]) self.setFormat(cOff+9, tLen, self._hStyles["hidden"])
else: else:
self.setFormat(0, tLen, self.hStyles["hidden"]) self.setFormat(0, tLen, self._hStyles["hidden"])
else: # Text Paragraph else: # Text Paragraph
if theText.startswith("["): # Special Command if text.startswith("["): # Special Command
sText = theText.rstrip() sText = text.rstrip()
if sText in ("[NEWPAGE]", "[NEW PAGE]", "[VSPACE]"): if sText in ("[NEWPAGE]", "[NEW PAGE]", "[VSPACE]"):
self.setFormat(0, len(theText), self.hStyles["keyword"]) self.setFormat(0, len(text), self._hStyles["keyword"])
return return
elif sText.startswith("[VSPACE:") and sText.endswith("]"): elif sText.startswith("[VSPACE:") and sText.endswith("]"):
tLen = len(sText) tLen = len(sText)
tVal = checkInt(sText[8:-1], 0) tVal = checkInt(sText[8:-1], 0)
self.setFormat(0, 8, self.hStyles["keyword"]) cVal = "codevalue" if tVal > 0 else "codeinval"
if tVal > 0: self.setFormat(0, 8, self._hStyles["keyword"])
self.setFormat(8, tLen-9, self.hStyles["codevalue"]) self.setFormat(8, tLen-9, self._hStyles[cVal])
else: self.setFormat(tLen-1, tLen, self._hStyles["keyword"])
self.setFormat(8, tLen-9, self.hStyles["codeinval"])
self.setFormat(tLen-1, tLen, self.hStyles["keyword"])
return return
# Regular text # Regular text
self.setCurrentBlockState(self.BLOCK_TEXT) self.setCurrentBlockState(self.BLOCK_TEXT)
for rX, xFmt in self.rxRules: for rX, xFmt in self.rxRules:
rxItt = rX.globalMatch(theText, 0) rxItt = rX.globalMatch(text, 0)
while rxItt.hasNext(): while rxItt.hasNext():
rxMatch = rxItt.next() rxMatch = rxItt.next()
for xM in xFmt: for xM in xFmt:
@@ -377,24 +378,24 @@ class GuiDocHighlighter(QSyntaxHighlighter):
xLen = rxMatch.capturedLength(xM) xLen = rxMatch.capturedLength(xM)
for x in range(xPos, xPos+xLen): for x in range(xPos, xPos+xLen):
spFmt = self.format(x) spFmt = self.format(x)
if spFmt != self.hStyles["hidden"]: if spFmt != self._hStyles["hidden"]:
spFmt.merge(xFmt[xM]) spFmt.merge(xFmt[xM])
self.setFormat(x, 1, spFmt) self.setFormat(x, 1, spFmt)
if not self.spellCheck: if not self._spellCheck:
return return
rxSpell = self.spellRx.globalMatch(theText.replace("_", " "), 0) rxSpell = self._spellRx.globalMatch(text.replace("_", " "), 0)
while rxSpell.hasNext(): while rxSpell.hasNext():
rxMatch = rxSpell.next() 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(): if rxMatch.captured(0).isupper() or rxMatch.captured(0).isnumeric():
continue continue
xPos = rxMatch.capturedStart(0) xPos = rxMatch.capturedStart(0)
xLen = rxMatch.capturedLength(0) xLen = rxMatch.capturedLength(0)
for x in range(xPos, xPos+xLen): for x in range(xPos, xPos+xLen):
spFmt = self.format(x) spFmt = self.format(x)
spFmt.setUnderlineColor(self.colSpell) spFmt.setUnderlineColor(self._colSpell)
spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
self.setFormat(x, 1, spFmt) self.setFormat(x, 1, spFmt)
@@ -404,33 +405,35 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Internal Functions # 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 """Generate a valid character format to be applied to the text
that is to be highlighted. that is to be highlighted.
""" """
theFormat = QTextCharFormat() charFormat = QTextCharFormat()
if fmtCol is not None: if color is not None:
theFormat.setForeground(fmtCol) charFormat.setForeground(color)
if fmtStyle is not None: if style is not None:
if "bold" in fmtStyle: styles = style.split(",")
theFormat.setFontWeight(QFont.Bold) if "bold" in styles:
if "italic" in fmtStyle: charFormat.setFontWeight(QFont.Bold)
theFormat.setFontItalic(True) if "italic" in styles:
if "strike" in fmtStyle: charFormat.setFontItalic(True)
theFormat.setFontStrikeOut(True) if "strike" in styles:
if "errline" in fmtStyle: charFormat.setFontStrikeOut(True)
theFormat.setUnderlineColor(self.colError) if "errline" in styles:
theFormat.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) charFormat.setUnderlineColor(self._colError)
if "underline" in fmtStyle: charFormat.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
theFormat.setFontUnderline(True) if "underline" in styles:
if "background" in fmtStyle: charFormat.setFontUnderline(True)
theFormat.setBackground(QBrush(fmtCol, Qt.SolidPattern)) if "background" in styles and color is not None:
charFormat.setBackground(QBrush(color, Qt.SolidPattern))
if fmtSize is not None: if size is not None:
theFormat.setFontPointSize(int(round(fmtSize*CONFIG.textSize))) charFormat.setFontPointSize(int(round(size*CONFIG.textSize)))
return theFormat return charFormat
# END Class DocHighlighter # END Class GuiDocHighlighter
+61 -57
View File
@@ -25,11 +25,12 @@ from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING
from pathlib import Path from pathlib import Path
from urllib.parse import urljoin from urllib.parse import urljoin
from urllib.request import pathname2url from urllib.request import pathname2url
from PyQt5.QtCore import QUrl from PyQt5.QtCore import QUrl, pyqtSlot
from PyQt5.QtGui import QDesktopServices from PyQt5.QtGui import QDesktopServices
from PyQt5.QtWidgets import QMenuBar, QAction from PyQt5.QtWidgets import QMenuBar, QAction
@@ -37,6 +38,9 @@ from novelwriter import CONFIG, SHARED
from novelwriter.enum import nwDocAction, nwDocInsert, nwWidget from novelwriter.enum import nwDocAction, nwDocInsert, nwWidget
from novelwriter.constants import nwConst, trConst, nwKeyWords, nwLabels, nwUnicode from novelwriter.constants import nwConst, trConst, nwKeyWords, nwLabels, nwUnicode
if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,7 +50,7 @@ class GuiMainMenu(QMenuBar):
add them from this class. add them from this class.
""" """
def __init__(self, mainGui): def __init__(self, mainGui: GuiMain) -> None:
super().__init__(parent=mainGui) super().__init__(parent=mainGui)
logger.debug("Create: GuiMainMenu") logger.debug("Create: GuiMainMenu")
@@ -77,46 +81,52 @@ class GuiMainMenu(QMenuBar):
# Update Menu on Settings Changed # Update Menu on Settings Changed
## ##
def setSpellCheck(self, theMode): def setSpellCheck(self, state: bool) -> None:
"""Forward spell check check state to its action. """Forward spell check check state to its action."""
""" self.aSpellCheck.setChecked(state)
self.aSpellCheck.setChecked(theMode)
return return
## ##
# Slots # Private Slots
## ##
def _toggleSpellCheck(self): @pyqtSlot()
def _toggleSpellCheck(self) -> None:
"""Toggle spell checking. The active status of the spell check """Toggle spell checking. The active status of the spell check
flag is handled by the document editor class, so we make no flag is handled by the document editor class, so we make no
decision, just pass a None to the function and let it decide. decision, just pass a None to the function and let it decide.
""" """
self.mainGui.docEditor.toggleSpellCheck(None) self.mainGui.docEditor.toggleSpellCheck(None)
return True return
def _openWebsite(self, theUrl): @pyqtSlot(str)
"""Open a URL in the system's default browser. def _openWebsite(self, url: str) -> None:
""" """Open a URL in the system's default browser."""
QDesktopServices.openUrl(QUrl(theUrl)) QDesktopServices.openUrl(QUrl(url))
return True return
def _openUserManualFile(self): @pyqtSlot()
"""Open the documentation in PDF format. def _openUserManualFile(self) -> None:
""" """Open the documentation in PDF format."""
if isinstance(CONFIG.pdfDocs, Path): if isinstance(CONFIG.pdfDocs, Path):
QDesktopServices.openUrl( QDesktopServices.openUrl(
QUrl(urljoin("file:", pathname2url(str(CONFIG.pdfDocs)))) QUrl(urljoin("file:", pathname2url(str(CONFIG.pdfDocs))))
) )
return 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): def _buildProjectMenu(self) -> None:
"""Assemble the Project menu. """Assemble the Project menu."""
"""
# Project # Project
self.projMenu = self.addMenu(self.tr("&Project")) self.projMenu = self.addMenu(self.tr("&Project"))
@@ -190,9 +200,8 @@ class GuiMainMenu(QMenuBar):
return return
def _buildDocumentMenu(self): def _buildDocumentMenu(self) -> None:
"""Assemble the Document menu. """Assemble the Document menu."""
"""
# Document # Document
self.docuMenu = self.addMenu(self.tr("&Document")) self.docuMenu = self.addMenu(self.tr("&Document"))
@@ -245,9 +254,8 @@ class GuiMainMenu(QMenuBar):
return return
def _buildEditMenu(self): def _buildEditMenu(self) -> None:
"""Assemble the Edit menu. """Assemble the Edit menu."""
"""
# Edit # Edit
self.editMenu = self.addMenu(self.tr("&Edit")) self.editMenu = self.addMenu(self.tr("&Edit"))
@@ -301,9 +309,8 @@ class GuiMainMenu(QMenuBar):
return return
def _buildViewMenu(self): def _buildViewMenu(self) -> None:
"""Assemble the View menu. """Assemble the View menu."""
"""
# View # View
self.viewMenu = self.addMenu(self.tr("&View")) self.viewMenu = self.addMenu(self.tr("&View"))
@@ -375,9 +382,8 @@ class GuiMainMenu(QMenuBar):
return return
def _buildInsertMenu(self): def _buildInsertMenu(self) -> None:
"""Assemble the Insert menu. """Assemble the Insert menu."""
"""
# Insert # Insert
self.insMenu = self.addMenu(self.tr("&Insert")) self.insMenu = self.addMenu(self.tr("&Insert"))
@@ -589,9 +595,8 @@ class GuiMainMenu(QMenuBar):
return return
def _buildFormatMenu(self): def _buildFormatMenu(self) -> None:
"""Assemble the Format menu. """Assemble the Format menu."""
"""
# Format # Format
self.fmtMenu = self.addMenu(self.tr("&Format")) self.fmtMenu = self.addMenu(self.tr("&Format"))
@@ -739,9 +744,8 @@ class GuiMainMenu(QMenuBar):
return return
def _buildSearchMenu(self): def _buildSearchMenu(self) -> None:
"""Assemble the Search menu. """Assemble the Search menu."""
"""
# Search # Search
self.srcMenu = self.addMenu(self.tr("&Search")) self.srcMenu = self.addMenu(self.tr("&Search"))
@@ -753,28 +757,21 @@ class GuiMainMenu(QMenuBar):
# Search > Replace # Search > Replace
self.aReplace = QAction(self.tr("Replace"), self) self.aReplace = QAction(self.tr("Replace"), self)
if CONFIG.osDarwin: self.aReplace.setShortcut("Ctrl+=" if CONFIG.osDarwin else "Ctrl+H")
self.aReplace.setShortcut("Ctrl+=")
else:
self.aReplace.setShortcut("Ctrl+H")
self.aReplace.triggered.connect(lambda: self.mainGui.docEditor.beginReplace()) self.aReplace.triggered.connect(lambda: self.mainGui.docEditor.beginReplace())
self.srcMenu.addAction(self.aReplace) self.srcMenu.addAction(self.aReplace)
# Search > Find Next # Search > Find Next
self.aFindNext = QAction(self.tr("Find Next"), self) self.aFindNext = QAction(self.tr("Find Next"), self)
if CONFIG.osDarwin: self.aFindNext.setShortcuts(["Ctrl+G", "F3"] if CONFIG.osDarwin else ["F3", "Ctrl+G"])
self.aFindNext.setShortcuts(["Ctrl+G", "F3"])
else:
self.aFindNext.setShortcuts(["F3", "Ctrl+G"])
self.aFindNext.triggered.connect(lambda: self.mainGui.docEditor.findNext()) self.aFindNext.triggered.connect(lambda: self.mainGui.docEditor.findNext())
self.srcMenu.addAction(self.aFindNext) self.srcMenu.addAction(self.aFindNext)
# Search > Find Prev # Search > Find Prev
self.aFindPrev = QAction(self.tr("Find Previous"), self) self.aFindPrev = QAction(self.tr("Find Previous"), self)
if CONFIG.osDarwin: self.aFindPrev.setShortcuts(
self.aFindPrev.setShortcuts(["Ctrl+Shift+G", "Shift+F3"]) ["Ctrl+Shift+G", "Shift+F3"] if CONFIG.osDarwin else ["Shift+F3", "Ctrl+Shift+G"]
else: )
self.aFindPrev.setShortcuts(["Shift+F3", "Ctrl+Shift+G"])
self.aFindPrev.triggered.connect(lambda: self.mainGui.docEditor.findNext(goBack=True)) self.aFindPrev.triggered.connect(lambda: self.mainGui.docEditor.findNext(goBack=True))
self.srcMenu.addAction(self.aFindPrev) self.srcMenu.addAction(self.aFindPrev)
@@ -786,9 +783,8 @@ class GuiMainMenu(QMenuBar):
return return
def _buildToolsMenu(self): def _buildToolsMenu(self) -> None:
"""Assemble the Tools menu. """Assemble the Tools menu."""
"""
# Tools # Tools
self.toolsMenu = self.addMenu(self.tr("&Tools")) self.toolsMenu = self.addMenu(self.tr("&Tools"))
@@ -800,6 +796,15 @@ class GuiMainMenu(QMenuBar):
self.aSpellCheck.setShortcut("Ctrl+F7") self.aSpellCheck.setShortcut("Ctrl+F7")
self.toolsMenu.addAction(self.aSpellCheck) 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 # Tools > Re-Run Spell Check
self.aReRunSpell = QAction(self.tr("Re-Run Spell Check"), self) self.aReRunSpell = QAction(self.tr("Re-Run Spell Check"), self)
self.aReRunSpell.setShortcut("F7") self.aReRunSpell.setShortcut("F7")
@@ -849,9 +854,8 @@ class GuiMainMenu(QMenuBar):
return return
def _buildHelpMenu(self): def _buildHelpMenu(self) -> None:
"""Assemble the Help menu. """Assemble the Help menu."""
"""
# Help # Help
self.helpMenu = self.addMenu(self.tr("&Help")) self.helpMenu = self.addMenu(self.tr("&Help"))
+3 -3
View File
@@ -798,7 +798,7 @@ class GuiProjectTree(QTreeWidget):
logger.error("There is no item to delete") logger.error("There is no item to delete")
return False return False
trashHandle = SHARED.project.tree.trashRoot() trashHandle = SHARED.project.tree.trashRoot
if tHandle == trashHandle: if tHandle == trashHandle:
logger.error("Cannot delete the Trash folder") logger.error("Cannot delete the Trash folder")
return False return False
@@ -823,7 +823,7 @@ class GuiProjectTree(QTreeWidget):
logger.error("No project open") logger.error("No project open")
return False return False
trashHandle = SHARED.project.tree.trashRoot() trashHandle = SHARED.project.tree.trashRoot
logger.debug("Emptying Trash folder") logger.debug("Emptying Trash folder")
if trashHandle is None: if trashHandle is None:
@@ -1201,7 +1201,7 @@ class GuiProjectTree(QTreeWidget):
# Trash Folder # Trash Folder
# ============ # ============
trashHandle = SHARED.project.tree.trashRoot() trashHandle = SHARED.project.tree.trashRoot
if tItem.itemHandle == trashHandle and trashHandle is not None: if tItem.itemHandle == trashHandle and trashHandle is not None:
# The trash folder only has one option # The trash folder only has one option
aEmptyTrash = ctxMenu.addAction(self.tr("Empty Trash")) aEmptyTrash = ctxMenu.addAction(self.tr("Empty Trash"))
+4 -10
View File
@@ -3,8 +3,7 @@ novelWriter GUI Main Window Status Bar
======================================== ========================================
File History: File History:
Created: 2019-04-20 [0.0.1] GuiMainStatus Created: 2019-04-20 [0.0.1]
Created: 2020-05-17 [0.5.1] StatusLED
This file is a part of novelWriter This file is a part of novelWriter
Copyright 20182023, Veronica Berglyd Olsen Copyright 20182023, Veronica Berglyd Olsen
@@ -121,7 +120,7 @@ class GuiMainStatus(QStatusBar):
def clearStatus(self) -> None: def clearStatus(self) -> None:
"""Reset all widgets on the status bar to default values.""" """Reset all widgets on the status bar to default values."""
self.setRefTime(-1.0) self.setRefTime(-1.0)
self.setLanguage(None, "") self.setLanguage(*SHARED.spelling.describeDict())
self.setProjectStats(0, 0) self.setProjectStats(0, 0)
self.setProjectStatus(StatusLED.S_NONE) self.setProjectStatus(StatusLED.S_NONE)
self.setDocumentStatus(StatusLED.S_NONE) self.setDocumentStatus(StatusLED.S_NONE)
@@ -208,13 +207,8 @@ class GuiMainStatus(QStatusBar):
self.langText.setText(self.tr("None")) self.langText.setText(self.tr("None"))
self.langText.setToolTip("") self.langText.setToolTip("")
else: else:
qLocal = QLocale(language) self.langText.setText(QLocale(language).nativeLanguageName().title())
spLang = qLocal.nativeLanguageName().title() self.langText.setToolTip(f"{language} ({provider})" if provider else language)
self.langText.setText(spLang)
if provider:
self.langText.setToolTip("%s (%s)" % (language, provider))
else:
self.langText.setToolTip(language)
return return
@pyqtSlot(bool) @pyqtSlot(bool)
+5 -8
View File
@@ -234,6 +234,7 @@ class GuiMain(QMainWindow):
SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus) SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus)
SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage) SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage)
SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage)
self.viewsBar.viewChangeRequested.connect(self._changeView) self.viewsBar.viewChangeRequested.connect(self._changeView)
@@ -251,7 +252,6 @@ class GuiMain(QMainWindow):
self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox) self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
self.novelView.openDocumentRequest.connect(self._openDocument) self.novelView.openDocumentRequest.connect(self._openDocument)
self.docEditor.spellDictionaryChanged.connect(self.mainStatus.setLanguage)
self.docEditor.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus) self.docEditor.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus)
self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts) self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts)
self.docEditor.docCountsChanged.connect(self.projView.updateCounts) self.docEditor.docCountsChanged.connect(self.projView.updateCounts)
@@ -428,7 +428,6 @@ class GuiMain(QMainWindow):
SHARED.closeProject() SHARED.closeProject()
self.docEditor.setDictionaries()
self._updateWindowTitle() self._updateWindowTitle()
self._changeView(nwView.PROJECT) self._changeView(nwView.PROJECT)
@@ -489,7 +488,6 @@ class GuiMain(QMainWindow):
# Update GUI # Update GUI
self._updateWindowTitle(SHARED.project.data.name) self._updateWindowTitle(SHARED.project.data.name)
self.rebuildTrees() self.rebuildTrees()
self.docEditor.setDictionaries()
self.docEditor.toggleSpellCheck(SHARED.project.data.spellCheck) self.docEditor.toggleSpellCheck(SHARED.project.data.spellCheck)
self.mainStatus.setRefTime(SHARED.project.projOpened) self.mainStatus.setRefTime(SHARED.project.projOpened)
self.projView.openProjectTasks() self.projView.openProjectTasks()
@@ -590,8 +588,8 @@ class GuiMain(QMainWindow):
return True return True
def openNextDocument(self, tHandle: str, wrapAround: bool = False) -> bool: def openNextDocument(self, tHandle: str, wrapAround: bool = False) -> bool:
"""Opens the next document in the project tree, following the """Open the next document in the project tree, following the
document with the given handle. Stops when reaching the end. document with the given handle. Stop when reaching the end.
""" """
if not SHARED.hasProject: if not SHARED.hasProject:
logger.error("No project open") logger.error("No project open")
@@ -907,8 +905,7 @@ class GuiMain(QMainWindow):
if dlgProj.result() == QDialog.Accepted: if dlgProj.result() == QDialog.Accepted:
logger.debug("Applying new project settings") logger.debug("Applying new project settings")
if dlgProj.spellChanged: SHARED.updateSpellCheckLanguage()
self.docEditor.setDictionaries()
self.itemDetails.refreshDetails() self.itemDetails.refreshDetails()
self._updateWindowTitle(SHARED.project.data.name) self._updateWindowTitle(SHARED.project.data.name)
@@ -982,7 +979,7 @@ class GuiMain(QMainWindow):
if dlgWords.result() == QDialog.Accepted: if dlgWords.result() == QDialog.Accepted:
logger.debug("Reloading word list") logger.debug("Reloading word list")
self.docEditor.setDictionaries() SHARED.updateSpellCheckLanguage(reload=True)
return True return True
+52 -29
View File
@@ -29,9 +29,11 @@ from time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pathlib import Path from pathlib import Path
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot from PyQt5.QtCore import QObject, pyqtSignal
from PyQt5.QtWidgets import QMessageBox, QWidget from PyQt5.QtWidgets import QMessageBox, QWidget
from novelwriter.core.spellcheck import NWSpellEnchant
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain from novelwriter.guimain import GuiMain
from novelwriter.gui.theme import GuiTheme from novelwriter.gui.theme import GuiTheme
@@ -43,24 +45,30 @@ logger = logging.getLogger(__name__)
class SharedData(QObject): class SharedData(QObject):
__slots__ = ( __slots__ = (
"_gui", "_theme", "_project", "_lockedBy", "_alert", "_gui", "_theme", "_project", "_spelling", "_lockedBy", "_alert",
"_idleTime", "_idleRefTime", "_idleTime", "_idleRefTime",
) )
projectStatusChanged = pyqtSignal(bool) projectStatusChanged = pyqtSignal(bool)
projectStatusMessage = pyqtSignal(str) projectStatusMessage = pyqtSignal(str)
spellLanguageChanged = pyqtSignal(str, str)
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._gui = None self._gui = None
self._theme = None self._theme = None
self._project = None self._project = None
self._spelling = None
self._lockedBy = None self._lockedBy = None
self._alert = None self._alert = None
self._idleTime = 0.0 self._idleTime = 0.0
self._idleRefTime = time() self._idleRefTime = time()
return return
##
# Properties
##
@property @property
def mainGui(self) -> GuiMain: def mainGui(self) -> GuiMain:
"""Return the Main GUI instance.""" """Return the Main GUI instance."""
@@ -82,9 +90,16 @@ class SharedData(QObject):
raise Exception("SharedData class not fully initialised") raise Exception("SharedData class not fully initialised")
return self._project 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 @property
def hasProject(self) -> bool: def hasProject(self) -> bool:
"""Return True of the project instance is populated.""" """Return True if the project instance is populated."""
return self.project.isValid return self.project.isValid
@property @property
@@ -107,9 +122,9 @@ class SharedData(QObject):
## ##
def initSharedData(self, gui: GuiMain, theme: GuiTheme) -> None: def initSharedData(self, gui: GuiMain, theme: GuiTheme) -> None:
"""Initialise the UserData instance. This must be called as soon """Initialise the SharedData instance. This must be called as
as the Main GUI is created to ensure the SHARED singleton has the soon as the Main GUI is created to ensure the SHARED singleton
properties needed for operation. has the properties needed for operation.
""" """
self._gui = gui self._gui = gui
self._theme = theme self._theme = theme
@@ -130,6 +145,7 @@ class SharedData(QObject):
self._lockedBy = self.project.lockStatus self._lockedBy = self.project.lockStatus
self._resetProject() self._resetProject()
self.updateSpellCheckLanguage(reload=True)
self._resetIdleTimer() self._resetIdleTimer()
return status return status
@@ -148,6 +164,16 @@ class SharedData(QObject):
self._resetIdleTimer() self._resetIdleTimer()
return 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: def updateIdleTime(self, currTime: float, userIdle: bool) -> None:
"""Update the idle time record. If the userIdle flag is True, """Update the idle time record. If the userIdle flag is True,
the user idle counter is updated with the time difference since the user idle counter is updated with the time difference since
@@ -159,6 +185,20 @@ class SharedData(QObject):
self._idleRefTime = currTime self._idleRefTime = currTime
return 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 # Alert Boxes
## ##
@@ -201,36 +241,19 @@ class SharedData(QObject):
self._alert.exec_() self._alert.exec_()
return self._alert.result() == QMessageBox.Yes 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 # Internal Functions
## ##
def _resetProject(self) -> None: def _resetProject(self) -> None:
"""Create a new project instance.""" """Create a new project and spell checking instance."""
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
if isinstance(self._project, NWProject): if isinstance(self._project, NWProject):
self._project.statusChanged.disconnect() del self._project
self._project.statusMessage.disconnect() del self._spelling
self._project.deleteLater() self._project = NWProject()
self._project = NWProject(self) self._spelling = NWSpellEnchant(self._project)
self._project.statusChanged.connect(self._emitProjectStatusChange) self.updateSpellCheckLanguage()
self._project.statusMessage.connect(self._emitProjectStatusMeesage)
return return
def _resetIdleTimer(self) -> None: def _resetIdleTimer(self) -> None:
+3 -1
View File
@@ -42,6 +42,8 @@ def testBaseSharedData_Init():
shared.theme shared.theme
with pytest.raises(Exception): with pytest.raises(Exception):
shared.project shared.project
with pytest.raises(Exception):
shared.spelling
# Create some mock objects # Create some mock objects
mockGui = MockGuiMain() mockGui = MockGuiMain()
@@ -113,7 +115,7 @@ def testBaseSharedData_Projects(fncPath, caplog: pytest.LogCaptureFixture):
project.openProject(fncPath) # First open with our independent project instance project.openProject(fncPath) # First open with our independent project instance
assert shared.hasProject is False assert shared.hasProject is False
assert shared.projectLock is None 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 shared.hasProject is False
assert isinstance(shared.projectLock, list) assert isinstance(shared.projectLock, list)
+10 -17
View File
@@ -58,17 +58,14 @@ def testCoreSpell_UserDictionary(monkeypatch, mockGUI, fncPath):
assert sorted(userDict) == ["bar", "foo"] assert sorted(userDict) == ["bar", "foo"]
# Save the file, but fail # Save the file, but fail
assert userDict._path is None
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError) mp.setattr("builtins.open", causeOSError)
userDict.save() userDict.save()
# There should be no file, but the file path should now be cached # There should be no file
assert userDict._path == dictFile
assert not dictFile.exists() assert not dictFile.exists()
# Break the path check # Break the path check
userDict._path = None
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None) mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
userDict.save() userDict.save()
@@ -85,23 +82,19 @@ def testCoreSpell_UserDictionary(monkeypatch, mockGUI, fncPath):
assert sorted(userDict) == [] assert sorted(userDict) == []
# Load the file, but fail # Load the file, but fail
userDict._path = None
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError) mp.setattr("builtins.open", causeOSError)
userDict.load() userDict.load()
# Path is now set, but no words # No words loaded
assert userDict._path == dictFile
assert sorted(userDict) == [] assert sorted(userDict) == []
# Break the path check # Break the path check
userDict._path = None
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None) mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
userDict.load() userDict.load()
# Path is now None, and no words # No words loaded
assert userDict._path is None
assert sorted(userDict) == [] assert sorted(userDict) == []
# Load the words again, properly # Load the words again, properly
@@ -122,18 +115,18 @@ def testCoreSpell_FakeEnchant(monkeypatch, mockGUI, fncPath):
mp.setitem(sys.modules, "enchant", None) mp.setitem(sys.modules, "enchant", None)
spChk = NWSpellEnchant(project) spChk = NWSpellEnchant(project)
spChk.setLanguage("en_US") spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, FakeEnchant) assert isinstance(spChk._enchant, FakeEnchant)
# Request a non-existent dictionary # Request a non-existent dictionary
spChk = NWSpellEnchant(project) spChk = NWSpellEnchant(project)
spChk.setLanguage("whatchamajig") spChk.setLanguage("whatchamajig")
assert isinstance(spChk._dictObj, FakeEnchant) assert isinstance(spChk._enchant, FakeEnchant)
# Request an empty language string # Request an empty language string
# See issue https://github.com/vkbo/novelWriter/issues/1096 # See issue https://github.com/vkbo/novelWriter/issues/1096
spChk = NWSpellEnchant(project) spChk = NWSpellEnchant(project)
spChk.setLanguage("") spChk.setLanguage("")
assert isinstance(spChk._dictObj, FakeEnchant) assert isinstance(spChk._enchant, FakeEnchant)
# FakeEnchant should handle requests # FakeEnchant should handle requests
fkChk = FakeEnchant() fkChk = FakeEnchant()
@@ -164,14 +157,14 @@ def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
assert spChk.spellLanguage is None assert spChk.spellLanguage is None
# Check that the FakeEnchant class is actually handling this # 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.checkWord("word") is True
assert spChk.suggestWords("word") == [] assert spChk.suggestWords("word") == []
assert spChk.addWord("word") is True assert spChk.addWord("word") is True
# Set the dict to None, and check enchant error handling # Set the dict to None, and check enchant error handling
spChk = NWSpellEnchant(project) spChk = NWSpellEnchant(project)
spChk._dictObj = None # type: ignore spChk._enchant = None # type: ignore
assert spChk.checkWord("word") is True assert spChk.checkWord("word") is True
assert spChk.suggestWords("word") == [] assert spChk.suggestWords("word") == []
assert spChk.addWord("word") is False assert spChk.addWord("word") is False
@@ -182,7 +175,7 @@ def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
spChk = NWSpellEnchant(project) spChk = NWSpellEnchant(project)
spChk.setLanguage("en_US") spChk.setLanguage("en_US")
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.spellLanguage == "en_US"
assert spChk.listDictionaries() != [] assert spChk.listDictionaries() != []
assert spChk.describeDict() != ("", "") assert spChk.describeDict() != ("", "")
@@ -194,6 +187,6 @@ def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("enchant.Broker.request_dict", lambda *a: None) mp.setattr("enchant.Broker.request_dict", lambda *a: None)
spChk.setLanguage("en_US") spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, FakeEnchant) assert isinstance(spChk._enchant, FakeEnchant)
# END Test testCoreSpell_Enchant # END Test testCoreSpell_Enchant
+12 -14
View File
@@ -119,7 +119,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems):
assert bool(theTree) is False assert bool(theTree) is False
# Check for archive and trash folders # Check for archive and trash folders
assert theTree.trashRoot() is None assert theTree.trashRoot is None
aHandles = [] aHandles = []
for nwItem in mockItems: for nwItem in mockItems:
@@ -146,7 +146,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems):
# ============ # ============
# Check that we have the correct archive and trash folders # 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.findRoot(nwItemClass.ARCHIVE) == "a000000000002"
assert theTree.isTrash("a000000000003") is True assert theTree.isTrash("a000000000003") is True
@@ -261,7 +261,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems):
del theTree["a000000000003"] del theTree["a000000000003"]
assert len(theTree) == len(mockItems) - 3 assert len(theTree) == len(mockItems) - 3
assert "a000000000003" not in theTree assert "a000000000003" not in theTree
assert theTree.trashRoot() is None assert theTree.trashRoot is None
# END Test testCoreTree_BuildTree # END Test testCoreTree_BuildTree
@@ -365,7 +365,7 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc
@pytest.mark.core @pytest.mark.core
def testCoreTree_Methods(mockGUI, mockItems): def testCoreTree_Methods(monkeypatch, mockGUI, mockItems):
"""Test various class methods.""" """Test various class methods."""
theProject = NWProject() theProject = NWProject()
theTree = NWTree(theProject) theTree = NWTree(theProject)
@@ -389,11 +389,10 @@ def testCoreTree_Methods(mockGUI, mockItems):
assert theTree.updateItemData("b000000000001") is True assert theTree.updateItemData("b000000000001") is True
# Update item data, root is unreachable # Update item data, root is unreachable
maxDepth = theTree.MAX_DEPTH with monkeypatch.context() as mp:
theTree.MAX_DEPTH = 0 # type: ignore mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0)
with pytest.raises(RecursionError): with pytest.raises(RecursionError):
theTree.updateItemData("b000000000001") theTree.updateItemData("b000000000001")
theTree.MAX_DEPTH = maxDepth
# Check type # Check type
assert theTree.checkType("blabla", nwItemType.FILE) is False assert theTree.checkType("blabla", nwItemType.FILE) is False
@@ -424,11 +423,10 @@ def testCoreTree_Methods(mockGUI, mockItems):
] ]
# Cause recursion error # Cause recursion error
maxDepth = theTree.MAX_DEPTH with monkeypatch.context() as mp:
theTree.MAX_DEPTH = 0 # type: ignore mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0)
with pytest.raises(RecursionError): with pytest.raises(RecursionError):
theTree.getItemPath("c000000000001") theTree.getItemPath("c000000000001")
theTree.MAX_DEPTH = maxDepth
# Break the folder parent handle # Break the folder parent handle
theTree["b000000000001"]._parent = "stuff" # type: ignore theTree["b000000000001"]._parent = "stuff" # type: ignore
+2 -2
View File
@@ -30,7 +30,7 @@ from PyQt5.QtWidgets import (
QDialogButtonBox, QDialog, QAction, QFileDialog, QFontDialog QDialogButtonBox, QDialog, QAction, QFileDialog, QFontDialog
) )
from novelwriter import CONFIG from novelwriter import CONFIG, SHARED
from novelwriter.dialogs.quotes import GuiQuoteSelect from novelwriter.dialogs.quotes import GuiQuoteSelect
from novelwriter.dialogs.preferences import GuiPreferences from novelwriter.dialogs.preferences import GuiPreferences
@@ -42,7 +42,7 @@ def testDlgPreferences_Main(qtbot, monkeypatch, nwGUI, tstPaths):
"""Test the preferences dialog.""" """Test the preferences dialog."""
monkeypatch.setattr(GuiPreferences, "exec_", lambda *a: None) monkeypatch.setattr(GuiPreferences, "exec_", lambda *a: None)
monkeypatch.setattr(GuiPreferences, "result", lambda *a: QDialog.Accepted) monkeypatch.setattr(GuiPreferences, "result", lambda *a: QDialog.Accepted)
monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")]) monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")])
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr(GuiPreferences, "updateTheme", lambda *a: True) mp.setattr(GuiPreferences, "updateTheme", lambda *a: True)
+5 -14
View File
@@ -43,7 +43,6 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI):
# Block the GUI blocking thread # Block the GUI blocking thread
monkeypatch.setattr(GuiProjectSettings, "exec_", lambda *a: None) monkeypatch.setattr(GuiProjectSettings, "exec_", lambda *a: None)
monkeypatch.setattr(GuiProjectSettings, "result", lambda *a: QDialog.Accepted) 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 # Check that we cannot open when there is no project
nwGUI.mainMenu.aProjectSettings.activate(QAction.Trigger) nwGUI.mainMenu.aProjectSettings.activate(QAction.Trigger)
@@ -84,10 +83,8 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI):
@pytest.mark.gui @pytest.mark.gui
def testDlgProjSettings_Main(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): 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."""
""" monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")])
# Mock components
monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")])
# Create new project # Create new project
buildTestProject(nwGUI, projPath) buildTestProject(nwGUI, projPath)
@@ -130,7 +127,6 @@ def testDlgProjSettings_Main(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockR
assert tabMain.editName.text() == "Project Name" assert tabMain.editName.text() == "Project Name"
assert tabMain.editTitle.text() == "Project Title" assert tabMain.editTitle.text() == "Project Title"
assert tabMain.editAuthor.text() == "Jane Doe" assert tabMain.editAuthor.text() == "Jane Doe"
assert projSettings.spellChanged is False
projSettings._doSave() projSettings._doSave()
assert theProject.data.name == "Project Name" assert theProject.data.name == "Project Name"
@@ -150,9 +146,7 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPat
dialog. dialog.
""" """
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")])
# Mock components
monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")])
# Create new project # Create new project
mockRnd.reset() mockRnd.reset()
@@ -348,12 +342,9 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncPath, projPat
@pytest.mark.gui @pytest.mark.gui
def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd): 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)) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")])
# Mock components
monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")])
# Create new project # Create new project
mockRnd.reset() mockRnd.reset()
+3 -3
View File
@@ -205,7 +205,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
assert len(SHARED.project.tree) == 0 assert len(SHARED.project.tree) == 0
assert len(SHARED.project.tree._order) == 0 assert len(SHARED.project.tree._order) == 0
assert len(SHARED.project.tree._roots) == 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.name == ""
assert SHARED.project.data.title == "" assert SHARED.project.data.title == ""
assert SHARED.project.data.author == "" 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) == 8
assert len(SHARED.project.tree._order) == 8 assert len(SHARED.project.tree._order) == 8
assert len(SHARED.project.tree._roots) == 4 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.name == "New Project"
assert SHARED.project.data.title == "New Novel" assert SHARED.project.data.title == "New Novel"
assert SHARED.project.data.author == "Jane Doe" assert SHARED.project.data.author == "Jane Doe"
@@ -242,7 +242,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
assert nwGUI.projView.projTree._getTreeItem(C.hSceneDoc) is not None assert nwGUI.projView.projTree._getTreeItem(C.hSceneDoc) is not None
nwGUI.mainMenu.aSpellCheck.setChecked(True) nwGUI.mainMenu.aSpellCheck.setChecked(True)
assert nwGUI.mainMenu._toggleSpellCheck() nwGUI.mainMenu._toggleSpellCheck()
# Change some settings # Change some settings
CONFIG.hideHScroll = True CONFIG.hideHScroll = True
+2 -2
View File
@@ -348,7 +348,7 @@ def testGuiProjTree_RequestDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPat
C.hChapterDir, C.hChapterDoc, C.hSceneDoc, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000010" "0000000000010"
] ]
trashHandle = SHARED.project.tree.trashRoot() trashHandle = SHARED.project.tree.trashRoot
assert projTree.getTreeFromHandle(trashHandle) == [ assert projTree.getTreeFromHandle(trashHandle) == [
trashHandle, "0000000000012", "0000000000011" trashHandle, "0000000000012", "0000000000011"
] ]
@@ -541,7 +541,7 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
projTree.setExpandedFromHandle(None, True) projTree.setExpandedFromHandle(None, True)
projTree._addTrashRoot() projTree._addTrashRoot()
hTrashRoot = SHARED.project.tree.trashRoot() hTrashRoot = SHARED.project.tree.trashRoot
projTree.setSelectedHandle(C.hCharRoot) projTree.setSelectedHandle(C.hCharRoot)
projTree.newTreeItem(nwItemType.FILE) projTree.newTreeItem(nwItemType.FILE)