diff --git a/nw/common.py b/nw/common.py index 2fef7874..17a8a337 100644 --- a/nw/common.py +++ b/nw/common.py @@ -203,12 +203,12 @@ def transferCase(theSource, theTarget): if len(theTarget) < 1 or len(theSource) < 1: return theResult - if theSource[0] == theSource[0].upper(): - theResult = theTarget[0].upper() + theTarget[1:] + if theSource.istitle(): + theResult = theTarget.title() - if theSource == theSource.upper(): + if theSource.isupper(): theResult = theTarget.upper() - elif theSource == theSource.lower(): + elif theSource.islower(): theResult = theTarget.lower() return theResult diff --git a/nw/core/spellcheck.py b/nw/core/spellcheck.py index 02d0f1b3..ff338f62 100644 --- a/nw/core/spellcheck.py +++ b/nw/core/spellcheck.py @@ -87,6 +87,11 @@ class NWSpellCheck(): """ return [] + def describeDict(self): + """Dummy function. + """ + return "", "" + @staticmethod def expandLanguage(spTag): """Translate a language tag to something more user friendly. @@ -187,6 +192,21 @@ class NWSpellEnchant(NWSpellCheck): logger.error("Failed to list languages for enchant spell checking") return retList + def describeDict(self): + """Return the tag and provider of the currently loaded + dictionary. + """ + try: + spTag = self.theDict.tag + spName = self.theDict.provider.name + except Exception as e: + logger.error("Failed to extract information about the dictionary") + logger.error(str(e)) + spTag = "" + spName = "" + + return spTag, spName + # END Class NWSpellEnchant class NWSpellEnchantDummy: @@ -221,12 +241,14 @@ class NWSpellSimple(NWSpellCheck): def __init__(self): NWSpellCheck.__init__(self) + self.theLang = "" logger.debug("Simple spell checking activated") return def setLanguage(self, theLang, projectDict=None): """Load a dictionary as a list from the app assets folder. """ + self.theLang = theLang self.WORDS = [] dictFile = os.path.join(self.mainConf.dictPath, theLang+".dict") try: @@ -269,15 +291,12 @@ class NWSpellSimple(NWSpellCheck): if len(theWord) == 0: return [] - firstUp = theWord[0] == theWord[0].upper() - theWord = theWord.lower() - - theMatches = get_close_matches(theWord, self.WORDS, n=10, cutoff=0.75) + theMatches = get_close_matches(theWord.lower(), self.WORDS, n=10, cutoff=0.75) theOptions = [] for aWord in theMatches: if len(aWord) == 0: continue - if firstUp: + if theWord[0].isupper(): aWord = aWord[0].upper() + aWord[1:] aWord = aWord.replace("'", self.mainConf.fmtApostrophe) theOptions.append(aWord) @@ -305,9 +324,15 @@ class NWSpellSimple(NWSpellCheck): if theBits[1] != ".dict": continue - spName = "%s [Internal]" % self.expandLanguage(theBits[0]) + spName = "%s [internal]" % self.expandLanguage(theBits[0]) retList.append((theBits[0], spName)) return retList + def describeDict(self): + """Return the tag and provider of the currently loaded + dictionary. + """ + return self.theLang, "internal" + # END Class NWSpellSimple diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py index afb704c7..f805e5ae 100644 --- a/nw/gui/doceditor.py +++ b/nw/gui/doceditor.py @@ -12,6 +12,7 @@ Created: 2020-04-25 [0.4.5] GuiDocEditHeader Rewritten: 2020-06-15 [0.9.0] GuiDocEditSearch Created: 2020-06-27 [0.10.0] GuiDocEditFooter + Rewritten: 2020-10-07 [1.0b3] BackgroundWordCounter This file is a part of novelWriter Copyright 2018–2020, Veronica Berglyd Olsen @@ -36,7 +37,8 @@ import logging from time import time from PyQt5.QtCore import ( - Qt, QSize, QThread, QTimer, pyqtSlot, QRegExp, QRegularExpression, QPointF + Qt, QSize, QTimer, pyqtSlot, pyqtSignal, QRegExp, QRegularExpression, + QPointF, QObject, QRunnable ) from PyQt5.QtGui import ( QTextCursor, QTextOption, QKeySequence, QFont, QColor, QPalette, @@ -135,14 +137,15 @@ class GuiDocEditor(QTextEdit): activated=self._followTag ) - # Set Up Word Count Thread and Timer + # Set Up Word Counter self.wcInterval = self.mainConf.wordCountTimer self.wcTimer = QTimer() self.wcTimer.setInterval(int(self.wcInterval*1000)) self.wcTimer.timeout.connect(self._runCounter) self.wCounter = BackgroundWordCounter(self) - self.wCounter.finished.connect(self._updateCounts) + self.wCounter.setAutoDelete(False) + self.wCounter.signals.countsReady.connect(self._updateCounts) self.initEditor() @@ -282,7 +285,7 @@ class GuiDocEditor(QTextEdit): self._allowAutoReplace(True) afTime = time() - logger.debug("Document highlighted in %.3f milliseconds" % (1000*(afTime-bfTime))) + logger.debug("Document highlighted in %.3f ms" % (1000*(afTime-bfTime))) self.lastEdit = time() self._runCounter() @@ -503,7 +506,11 @@ class GuiDocEditor(QTextEdit): theLang = self.theProject.projLang self.theDict.setLanguage(theLang, self.theProject.projDict) - self.theParent.statusBar.setLanguage(self.theDict.spellLanguage) + + aLang, aName = self.theDict.describeDict() + self.theParent.statusBar.setLanguage( + aLang, "%s [%s]" % (self.mainConf.spellTool.title(), aName.title()) + ) if not self.bigDoc: self.spellCheckDocument() @@ -550,9 +557,8 @@ class GuiDocEditor(QTextEdit): qApp.restoreOverrideCursor() afTime = time() logger.debug( - "Document re-highlighted in %.3f milliseconds" % (1000*(afTime-bfTime)) + "Document highlighted in %.3f ms" % (1000*(afTime-bfTime)) ) - self.theParent.statusBar.showMessage("Spell check complete") return True @@ -857,7 +863,7 @@ class GuiDocEditor(QTextEdit): mnuHead = QAction("Spelling Suggestion(s)", mnuContext) mnuContext.addAction(mnuHead) - theSuggest = self.theDict.suggestWords(theWord) + theSuggest = self.theDict.suggestWords(theWord)[:15] if len(theSuggest) > 0: for aWord in theSuggest: mnuWord = QAction("%s %s" % (nwUnicode.U_ENDASH, aWord), mnuContext) @@ -910,21 +916,18 @@ class GuiDocEditor(QTextEdit): """Decide whether to run the word counter, or stop the timer due to inactivity. """ - sinceActive = time()-self.lastEdit - if sinceActive > 5*self.wcInterval: - logger.debug( - "Stopping word count timer: no activity last %.1f seconds" % sinceActive - ) - self.wcTimer.stop() - elif self.wCounter.isRunning(): - logger.verbose("Word counter thread is busy") - else: - logger.verbose("Starting word counter") - self.wCounter.start() + if self.wCounter.isRunning(): + logger.verbose("Word counter is busy") + return + + if time() - self.lastEdit < 5*self.wcInterval: + logger.verbose("Running word counter") + self.theParent.threadPool.start(self.wCounter) + return - @pyqtSlot() - def _updateCounts(self): + @pyqtSlot(int, int, int) + def _updateCounts(self, cCount, wCount, pCount): """Slot for the word counter's finished signal """ theItem = self.nwDocument.getCurrentItem() @@ -933,19 +936,17 @@ class GuiDocEditor(QTextEdit): logger.verbose("Updating word count") - self.charCount = self.wCounter.charCount - self.wordCount = self.wCounter.wordCount - self.paraCount = self.wCounter.paraCount - theItem.setCharCount(self.charCount) - theItem.setWordCount(self.wordCount) - theItem.setParaCount(self.paraCount) + self.charCount = cCount + self.wordCount = wCount + self.paraCount = pCount + theItem.setCharCount(cCount) + theItem.setWordCount(wCount) + theItem.setParaCount(pCount) - self.theParent.treeView.propagateCount(self.theHandle, self.wordCount) + self.theParent.treeView.propagateCount(self.theHandle, wCount) self.theParent.treeView.projectWordCount() - self.theParent.treeMeta.updateCounts( - self.theHandle, self.charCount, self.wordCount, self.paraCount - ) - self._checkDocSize(self.charCount) + self.theParent.treeMeta.updateCounts(self.theHandle, cCount, wCount, pCount) + self._checkDocSize(self.qDocument.characterCount()) self.docFooter.updateCounts() return @@ -1519,33 +1520,42 @@ class GuiDocEditor(QTextEdit): # END Class GuiDocEditor # =============================================================================================== # -# The Off GUI Thread Word Counter -# Runs the word counter in the background for the DocEditor +# The Off-GUI Thread Word Counter +# A runnable for the word counter to be run in the thread pool off the main GUI thread. # =============================================================================================== # -class BackgroundWordCounter(QThread): +class BackgroundWordCounter(QRunnable): def __init__(self, docEditor): - QThread.__init__(self, docEditor) + QRunnable.__init__(self) self.docEditor = docEditor - self.charCount = 0 - self.wordCount = 0 - self.paraCount = 0 + self.signals = BackgroundWordCounterSignals() + self._isRunning = False return + def isRunning(self): + return self._isRunning + + @pyqtSlot() def run(self): """Overloaded run function for the word counter, forwarding the call to the function that does the actual counting. """ + self._isRunning = True theText = self.docEditor.getText() cC, wC, pC = countWords(theText) - self.charCount = cC - self.wordCount = wC - self.paraCount = pC + self.signals.countsReady.emit(cC, wC, pC) + self._isRunning = False return ## END Class BackgroundWordCounter +class BackgroundWordCounterSignals(QObject): + + countsReady = pyqtSignal(int, int, int) + +# END Class BackgroundWordCounterSignals + # =============================================================================================== # # The Embedded Document Search/Replace Feature # Only used by DocEditor, and is at a fixed position in the QTextEdit's viewport diff --git a/nw/gui/dochighlight.py b/nw/gui/dochighlight.py index 663732b4..564b1a9b 100644 --- a/nw/gui/dochighlight.py +++ b/nw/gui/dochighlight.py @@ -28,6 +28,8 @@ import nw import logging +from time import time + from PyQt5.QtCore import Qt, QRegularExpression from PyQt5.QtGui import ( QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush @@ -203,7 +205,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): # Build a QRegExp for spell checker # Include additional characters that the highlighter should # consider to be word separators - wordSep = r"_\+/" + wordSep = r"\-_\+/" wordSep += nwUnicode.U_ENDASH wordSep += nwUnicode.U_EMDASH self.spellRx = QRegularExpression(r"\b[^\s"+wordSep+r"]+\b") @@ -244,10 +246,15 @@ class GuiDocHighlighter(QSyntaxHighlighter): """ qDocument = self.document() nBlocks = qDocument.blockCount() + bfTime = time() for i in range(nBlocks): theBlock = qDocument.findBlockByNumber(i) - if theBlock.userState() & theType == theType: + if theBlock.userState() & theType > 0: self.rehighlightBlock(theBlock) + afTime = time() + logger.debug( + "Document highlighted in %.3f ms" % (1000*(afTime-bfTime)) + ) return ## @@ -343,7 +350,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): while rxSpell.hasNext(): rxMatch = rxSpell.next() if not self.theDict.checkWord(rxMatch.captured(0)): - if rxMatch.captured(0) == rxMatch.captured(0).upper(): + if rxMatch.captured(0).isupper() or rxMatch.captured(0).isnumeric(): continue xPos = rxMatch.capturedStart(0) xLen = rxMatch.capturedLength(0) diff --git a/nw/gui/statusbar.py b/nw/gui/statusbar.py index 914fedae..c43c3f6f 100644 --- a/nw/gui/statusbar.py +++ b/nw/gui/statusbar.py @@ -152,13 +152,17 @@ class GuiMainStatus(QStatusBar): qApp.processEvents() return - def setLanguage(self, theLanguage): + def setLanguage(self, theLanguage, theProvider=""): """Set the language code for the spell checker. """ if theLanguage is None: self.langText.setText("None") + self.langText.setToolTip("") else: self.langText.setText(NWSpellCheck.expandLanguage(theLanguage)) + self.langText.setToolTip( + "Provider: %s" % (theProvider if theProvider else "unknown") + ) return def setProjectStatus(self, isChanged): diff --git a/nw/guimain.py b/nw/guimain.py index a741af88..93c62210 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -32,7 +32,7 @@ import os from datetime import datetime from time import time -from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtCore import Qt, QTimer, QThreadPool from PyQt5.QtGui import QIcon, QPixmap, QColor, QKeySequence, QCursor from PyQt5.QtWidgets import ( qApp, QMainWindow, QVBoxLayout, QWidget, QSplitter, QFileDialog, QShortcut, @@ -60,6 +60,7 @@ class GuiMain(QMainWindow): logger.debug("Initialising GUI ...") self.setObjectName("GuiMain") self.mainConf = nw.CONFIG + self.threadPool = QThreadPool() # Some runtime info useful for debugging logger.info("OS: %s" % self.mainConf.osType) diff --git a/tests/test_project.py b/tests/test_project.py index 21423ffe..282acbc7 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -377,6 +377,10 @@ def testSpellEnchant(nwTemp, nwConf): dList = spChk.listDictionaries() assert len(dList) > 0 + aTag, aName = spChk.describeDict() + assert aTag == "en" + assert aName != "" + @pytest.mark.project def testSpellSimple(nwTemp, nwConf): wList = os.path.join(nwTemp, "wordlist.txt") @@ -402,6 +406,10 @@ def testSpellSimple(nwTemp, nwConf): dList = spChk.listDictionaries() assert len(dList) > 0 + aTag, aName = spChk.describeDict() + assert aTag == "en" + assert aName == "internal" + @pytest.mark.project def testProjectOptions(nwDummy, nwLipsum): """Test the class that holds all the GUI state user options that are