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:
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
+31 -6
View File
@@ -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
+52 -42
View File
@@ -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 20182020, 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
+10 -3
View File
@@ -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)
+5 -1
View File
@@ -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):
+2 -1
View File
@@ -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)
+8
View File
@@ -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