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 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
##
+38 -38
View File
@@ -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
@@ -47,11 +49,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 +78,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 +87,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 +96,12 @@ 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 +112,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 +129,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
@@ -134,30 +140,26 @@ 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]:
"""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 +194,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 +213,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 +228,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
+14 -11
View File
@@ -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:
+4 -11
View File
@@ -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
@@ -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,12 +658,9 @@ class GuiPreferencesEditor(QWidget):
self.spellLanguage = QComboBox(self)
self.spellLanguage.setMaximumWidth(mW)
langAvail = self.mainGui.docEditor.spEnchant.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)
+5 -18
View File
@@ -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
@@ -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()
@@ -199,8 +190,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,11 +233,9 @@ class GuiProjectEditMain(QWidget):
self.spellLang = QComboBox(self)
self.spellLang.setMaximumWidth(xW)
self.spellLang.addItem(self.tr("Default"), "None")
langAvail = self.mainGui.docEditor.spEnchant.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"),
+21 -42
View File
@@ -54,7 +54,6 @@ from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass
from novelwriter.common import minmax, transferCase
from novelwriter.constants import nwConst, nwKeyWords, nwUnicode
from novelwriter.core.index import countWords
from novelwriter.core.spellcheck import NWSpellEnchant
from novelwriter.gui.dochighlight import GuiDocHighlighter
if TYPE_CHECKING: # pragma: no cover
@@ -74,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)
@@ -133,8 +131,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)
@@ -303,7 +300,7 @@ class GuiDocEditor(QTextEdit):
self._typPadAfter = CONFIG.fmtPadAfter
# Reload spell check and dictionaries
self.setDictionaries()
SHARED.updateSpellCheckLanguage()
# Set font
textFont = QFont()
@@ -399,7 +396,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)
@@ -420,7 +417,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
@@ -693,55 +690,37 @@ 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
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):
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
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 +1172,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 +1224,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
+185 -182
View File
@@ -29,7 +29,7 @@ from time import time
from PyQt5.QtCore import Qt, QRegularExpression
from PyQt5.QtGui import (
QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush
QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush, QTextDocument
)
from novelwriter import CONFIG, SHARED
@@ -46,31 +46,32 @@ 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
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()
@@ -78,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"],
}
))
@@ -155,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))
@@ -225,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):
@@ -294,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)
@@ -307,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:
@@ -377,24 +378,24 @@ 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 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)
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)
@@ -404,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
# END Class GuiDocHighlighter
+61 -57
View File
@@ -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"))
+3 -3
View File
@@ -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"))
+4 -10
View File
@@ -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 20182023, Veronica Berglyd Olsen
@@ -121,7 +120,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)
@@ -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)
+5 -8
View File
@@ -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()
@@ -590,8 +588,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")
@@ -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
+52 -29
View File
@@ -29,9 +29,11 @@ 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
from novelwriter.core.spellcheck import NWSpellEnchant
if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain
from novelwriter.gui.theme import GuiTheme
@@ -43,24 +45,30 @@ 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
self._idleRefTime = time()
return
##
# Properties
##
@property
def mainGui(self) -> GuiMain:
"""Return the Main GUI instance."""
@@ -82,9 +90,16 @@ 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 of the project instance is populated."""
"""Return True if the project instance is populated."""
return self.project.isValid
@property
@@ -107,9 +122,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
@@ -130,6 +145,7 @@ class SharedData(QObject):
self._lockedBy = self.project.lockStatus
self._resetProject()
self.updateSpellCheckLanguage(reload=True)
self._resetIdleTimer()
return status
@@ -148,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
@@ -159,6 +185,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,36 +241,19 @@ 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
##
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):
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
del self._spelling
self._project = NWProject()
self._spelling = NWSpellEnchant(self._project)
self.updateSpellCheckLanguage()
return
def _resetIdleTimer(self) -> None:
+3 -1
View File
@@ -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)
+10 -17
View File
@@ -58,17 +58,14 @@ def testCoreSpell_UserDictionary(monkeypatch, mockGUI, fncPath):
assert sorted(userDict) == ["bar", "foo"]
# Save the file, but fail
assert userDict._path is None
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
userDict.save()
# There should be no file, but the file path should now be cached
assert userDict._path == dictFile
# There should be no file
assert not dictFile.exists()
# Break the path check
userDict._path = None
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
userDict.save()
@@ -85,23 +82,19 @@ def testCoreSpell_UserDictionary(monkeypatch, mockGUI, fncPath):
assert sorted(userDict) == []
# Load the file, but fail
userDict._path = None
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
userDict.load()
# Path is now set, but no words
assert userDict._path == dictFile
# No words loaded
assert sorted(userDict) == []
# Break the path check
userDict._path = None
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
userDict.load()
# Path is now None, and no words
assert userDict._path is None
# No words loaded
assert sorted(userDict) == []
# Load the words again, properly
@@ -122,18 +115,18 @@ def testCoreSpell_FakeEnchant(monkeypatch, mockGUI, fncPath):
mp.setitem(sys.modules, "enchant", None)
spChk = NWSpellEnchant(project)
spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, FakeEnchant)
assert isinstance(spChk._enchant, FakeEnchant)
# Request a non-existent dictionary
spChk = NWSpellEnchant(project)
spChk.setLanguage("whatchamajig")
assert isinstance(spChk._dictObj, FakeEnchant)
assert isinstance(spChk._enchant, FakeEnchant)
# Request an empty language string
# See issue https://github.com/vkbo/novelWriter/issues/1096
spChk = NWSpellEnchant(project)
spChk.setLanguage("")
assert isinstance(spChk._dictObj, FakeEnchant)
assert isinstance(spChk._enchant, FakeEnchant)
# FakeEnchant should handle requests
fkChk = FakeEnchant()
@@ -164,14 +157,14 @@ def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
assert spChk.spellLanguage is None
# Check that the FakeEnchant class is actually handling this
assert isinstance(spChk._dictObj, FakeEnchant)
assert isinstance(spChk._enchant, FakeEnchant)
assert spChk.checkWord("word") is True
assert spChk.suggestWords("word") == []
assert spChk.addWord("word") is True
# Set the dict to None, and check enchant error handling
spChk = NWSpellEnchant(project)
spChk._dictObj = None # type: ignore
spChk._enchant = None # type: ignore
assert spChk.checkWord("word") is True
assert spChk.suggestWords("word") == []
assert spChk.addWord("word") is False
@@ -182,7 +175,7 @@ def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
spChk = NWSpellEnchant(project)
spChk.setLanguage("en_US")
spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, enchant.Dict)
assert isinstance(spChk._enchant, enchant.Dict)
assert spChk.spellLanguage == "en_US"
assert spChk.listDictionaries() != []
assert spChk.describeDict() != ("", "")
@@ -194,6 +187,6 @@ def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
with monkeypatch.context() as mp:
mp.setattr("enchant.Broker.request_dict", lambda *a: None)
spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, FakeEnchant)
assert isinstance(spChk._enchant, FakeEnchant)
# END Test testCoreSpell_Enchant
+12 -14
View File
@@ -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
+2 -2
View File
@@ -30,7 +30,7 @@ from PyQt5.QtWidgets import (
QDialogButtonBox, QDialog, QAction, QFileDialog, QFontDialog
)
from novelwriter import CONFIG
from novelwriter import CONFIG, SHARED
from novelwriter.dialogs.quotes import GuiQuoteSelect
from novelwriter.dialogs.preferences import GuiPreferences
@@ -42,7 +42,7 @@ def testDlgPreferences_Main(qtbot, monkeypatch, nwGUI, tstPaths):
"""Test the preferences dialog."""
monkeypatch.setattr(GuiPreferences, "exec_", lambda *a: None)
monkeypatch.setattr(GuiPreferences, "result", lambda *a: QDialog.Accepted)
monkeypatch.setattr(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")])
monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")])
with monkeypatch.context() as mp:
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
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)
@@ -84,10 +83,8 @@ 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(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")])
"""Test the main tab of the project settings dialog."""
monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")])
# Create new project
buildTestProject(nwGUI, projPath)
@@ -130,7 +127,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"
@@ -150,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(nwGUI.docEditor.spEnchant, "listDictionaries", lambda: [("en", "none")])
monkeypatch.setattr(SHARED._spelling, "listDictionaries", lambda: [("en", "English [en]")])
# Create new project
mockRnd.reset()
@@ -348,12 +342,9 @@ 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", "English [en]")])
# Create new project
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._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"
@@ -242,7 +242,7 @@ 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()
# Change some settings
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,
"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)