Merge pull request #462 from vkbo/editor_features

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