Merge pull request #462 from vkbo/editor_features
Various Document Editor and Related Improvements
This commit is contained in:
+4
-4
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+10
-3
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user