Add selected words count feature (#899)
* Add a selected words counter in the document editor * Update and extend test coverage * Rename the functions to something more sensible
This commit is contained in:
committed by
GitHub
parent
c852a5bda3
commit
be3376272e
+128
-24
@@ -113,6 +113,7 @@ class GuiDocEditor(QTextEdit):
|
|||||||
qDoc = self.document()
|
qDoc = self.document()
|
||||||
qDoc.contentsChange.connect(self._docChange)
|
qDoc.contentsChange.connect(self._docChange)
|
||||||
qDoc.documentLayout().documentSizeChanged.connect(self._docSizeChanged)
|
qDoc.documentLayout().documentSizeChanged.connect(self._docSizeChanged)
|
||||||
|
self.selectionChanged.connect(self._updateSelectedStatus)
|
||||||
|
|
||||||
# Document Title
|
# Document Title
|
||||||
self.docHeader = GuiDocEditHeader(self)
|
self.docHeader = GuiDocEditHeader(self)
|
||||||
@@ -152,16 +153,26 @@ class GuiDocEditor(QTextEdit):
|
|||||||
activated=self._followTag
|
activated=self._followTag
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set Up Word Counter
|
# Set Up Document Word Counter
|
||||||
self.wcTimer = QTimer()
|
self.wcTimerDoc = QTimer()
|
||||||
self.wcTimer.timeout.connect(self._runCounter)
|
self.wcTimerDoc.timeout.connect(self._runDocCounter)
|
||||||
|
|
||||||
self.wCounter = BackgroundWordCounter(self)
|
self.wCounterDoc = BackgroundWordCounter(self)
|
||||||
self.wCounter.setAutoDelete(False)
|
self.wCounterDoc.setAutoDelete(False)
|
||||||
self.wCounter.signals.countsReady.connect(self._updateCounts)
|
self.wCounterDoc.signals.countsReady.connect(self._updateDocCounts)
|
||||||
|
|
||||||
self.wcInterval = self.mainConf.wordCountTimer
|
self.wcInterval = self.mainConf.wordCountTimer
|
||||||
|
|
||||||
|
# Set Up Selection Word Counter
|
||||||
|
self.wcTimerSel = QTimer()
|
||||||
|
self.wcTimerSel.timeout.connect(self._runSelCounter)
|
||||||
|
self.wcTimerSel.setInterval(500)
|
||||||
|
|
||||||
|
self.wCounterSel = BackgroundWordCounter(self, forSelection=True)
|
||||||
|
self.wCounterSel.setAutoDelete(False)
|
||||||
|
self.wCounterSel.signals.countsReady.connect(self._updateSelCounts)
|
||||||
|
|
||||||
|
# Finalise
|
||||||
self.initEditor()
|
self.initEditor()
|
||||||
|
|
||||||
logger.debug("GuiDocEditor initialisation complete")
|
logger.debug("GuiDocEditor initialisation complete")
|
||||||
@@ -175,7 +186,8 @@ class GuiDocEditor(QTextEdit):
|
|||||||
self._nwDocument = None
|
self._nwDocument = None
|
||||||
self.setReadOnly(True)
|
self.setReadOnly(True)
|
||||||
self.clear()
|
self.clear()
|
||||||
self.wcTimer.stop()
|
self.wcTimerDoc.stop()
|
||||||
|
self.wcTimerSel.stop()
|
||||||
|
|
||||||
self._docHandle = None
|
self._docHandle = None
|
||||||
self._charCount = 0
|
self._charCount = 0
|
||||||
@@ -283,7 +295,7 @@ class GuiDocEditor(QTextEdit):
|
|||||||
|
|
||||||
# Configure word count timer
|
# Configure word count timer
|
||||||
self.wcInterval = self.mainConf.wordCountTimer
|
self.wcInterval = self.mainConf.wordCountTimer
|
||||||
self.wcTimer.setInterval(int(self.wcInterval*1000))
|
self.wcTimerDoc.setInterval(int(self.wcInterval*1000))
|
||||||
|
|
||||||
# If we have a document open, we should reload it in case the
|
# If we have a document open, we should reload it in case the
|
||||||
# font changed, otherwise we just clear the editor entirely,
|
# font changed, otherwise we just clear the editor entirely,
|
||||||
@@ -347,8 +359,8 @@ class GuiDocEditor(QTextEdit):
|
|||||||
|
|
||||||
self._lastEdit = time()
|
self._lastEdit = time()
|
||||||
self._lastActive = time()
|
self._lastActive = time()
|
||||||
self._runCounter()
|
self._runDocCounter()
|
||||||
self.wcTimer.start()
|
self.wcTimerDoc.start()
|
||||||
self._docHandle = tHandle
|
self._docHandle = tHandle
|
||||||
|
|
||||||
self.setReadOnly(False)
|
self.setReadOnly(False)
|
||||||
@@ -445,7 +457,7 @@ class GuiDocEditor(QTextEdit):
|
|||||||
docText = self.getText()
|
docText = self.getText()
|
||||||
|
|
||||||
cC, wC, pC = countWords(docText)
|
cC, wC, pC = countWords(docText)
|
||||||
self._updateCounts(cC, wC, pC)
|
self._updateDocCounts(cC, wC, pC)
|
||||||
|
|
||||||
self._nwItem.setCharCount(self._charCount)
|
self._nwItem.setCharCount(self._charCount)
|
||||||
self._nwItem.setWordCount(self._wordCount)
|
self._nwItem.setWordCount(self._wordCount)
|
||||||
@@ -1078,8 +1090,8 @@ class GuiDocEditor(QTextEdit):
|
|||||||
if not self._docChanged:
|
if not self._docChanged:
|
||||||
self.setDocumentChanged(chrRem != 0 or chrAdd != 0)
|
self.setDocumentChanged(chrRem != 0 or chrAdd != 0)
|
||||||
|
|
||||||
if not self.wcTimer.isActive():
|
if not self.wcTimerDoc.isActive():
|
||||||
self.wcTimer.start()
|
self.wcTimerDoc.start()
|
||||||
|
|
||||||
if self._doReplace and chrAdd == 1:
|
if self._doReplace and chrAdd == 1:
|
||||||
self._docAutoReplace(self.document().findBlock(thePos))
|
self._docAutoReplace(self.document().findBlock(thePos))
|
||||||
@@ -1213,25 +1225,25 @@ class GuiDocEditor(QTextEdit):
|
|||||||
return
|
return
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def _runCounter(self):
|
def _runDocCounter(self):
|
||||||
"""Decide whether to run the word counter, or not due to
|
"""Decide whether to run the word counter, or not due to
|
||||||
inactivity.
|
inactivity.
|
||||||
"""
|
"""
|
||||||
if self._docHandle is None:
|
if self._docHandle is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.wCounter.isRunning():
|
if self.wCounterDoc.isRunning():
|
||||||
logger.verbose("Word counter is busy")
|
logger.verbose("Word counter is busy")
|
||||||
return
|
return
|
||||||
|
|
||||||
if time() - self._lastEdit < 5 * self.wcInterval:
|
if time() - self._lastEdit < 5 * self.wcInterval:
|
||||||
logger.verbose("Running word counter")
|
logger.verbose("Running word counter")
|
||||||
self.theParent.threadPool.start(self.wCounter)
|
self.theParent.threadPool.start(self.wCounterDoc)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@pyqtSlot(int, int, int)
|
@pyqtSlot(int, int, int)
|
||||||
def _updateCounts(self, cCount, wCount, pCount):
|
def _updateDocCounts(self, cCount, wCount, pCount):
|
||||||
"""Slot for the word counter's finished signal
|
"""Slot for the word counter's finished signal
|
||||||
"""
|
"""
|
||||||
if self._docHandle is None or self._nwItem is None:
|
if self._docHandle is None or self._nwItem is None:
|
||||||
@@ -1255,6 +1267,51 @@ class GuiDocEditor(QTextEdit):
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def _updateSelectedStatus(self):
|
||||||
|
"""The user made a change in text selection. Forward this
|
||||||
|
information to the footer, and start the selection word counter.
|
||||||
|
"""
|
||||||
|
if self.textCursor().hasSelection():
|
||||||
|
if not self.wcTimerSel.isActive():
|
||||||
|
self.wcTimerSel.start()
|
||||||
|
self.docFooter.setHasSelection(True)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.wcTimerSel.stop()
|
||||||
|
self.docFooter.setHasSelection(False)
|
||||||
|
self.docFooter.updateCounts()
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def _runSelCounter(self):
|
||||||
|
"""Update the selection word count.
|
||||||
|
"""
|
||||||
|
if self._docHandle is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.wCounterSel.isRunning():
|
||||||
|
logger.verbose("Selection word counter is busy")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.theParent.threadPool.start(self.wCounterSel)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
@pyqtSlot(int, int, int)
|
||||||
|
def _updateSelCounts(self, cCount, wCount, pCount):
|
||||||
|
"""Slot for the word counter's finished signal
|
||||||
|
"""
|
||||||
|
if self._docHandle is None or self._nwItem is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.verbose("User selectee %d words", wCount)
|
||||||
|
self.docFooter.updateCounts(wCount=wCount, cCount=cCount)
|
||||||
|
self.wcTimerSel.stop()
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
@pyqtSlot("QSizeF")
|
@pyqtSlot("QSizeF")
|
||||||
def _docSizeChanged(self, theSize):
|
def _docSizeChanged(self, theSize):
|
||||||
"""Called whenever the underlying document layout size changes.
|
"""Called whenever the underlying document layout size changes.
|
||||||
@@ -2010,11 +2067,15 @@ class GuiDocEditor(QTextEdit):
|
|||||||
|
|
||||||
class BackgroundWordCounter(QRunnable):
|
class BackgroundWordCounter(QRunnable):
|
||||||
|
|
||||||
def __init__(self, docEditor):
|
def __init__(self, docEditor, forSelection=False):
|
||||||
QRunnable.__init__(self)
|
QRunnable.__init__(self)
|
||||||
self.docEditor = docEditor
|
|
||||||
self.signals = BackgroundWordCounterSignals()
|
self._docEditor = docEditor
|
||||||
|
self._forSelection = forSelection
|
||||||
self._isRunning = False
|
self._isRunning = False
|
||||||
|
|
||||||
|
self.signals = BackgroundWordCounterSignals()
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
def isRunning(self):
|
def isRunning(self):
|
||||||
@@ -2026,10 +2087,15 @@ class BackgroundWordCounter(QRunnable):
|
|||||||
call to the function that does the actual counting.
|
call to the function that does the actual counting.
|
||||||
"""
|
"""
|
||||||
self._isRunning = True
|
self._isRunning = True
|
||||||
theText = self.docEditor.getText()
|
if self._forSelection:
|
||||||
|
theText = self._docEditor.textCursor().selectedText()
|
||||||
|
else:
|
||||||
|
theText = self._docEditor.getText()
|
||||||
|
|
||||||
cC, wC, pC = countWords(theText)
|
cC, wC, pC = countWords(theText)
|
||||||
self.signals.countsReady.emit(cC, wC, pC)
|
self.signals.countsReady.emit(cC, wC, pC)
|
||||||
self._isRunning = False
|
self._isRunning = False
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# END Class BackgroundWordCounter
|
# END Class BackgroundWordCounter
|
||||||
@@ -2666,6 +2732,8 @@ class GuiDocEditFooter(QWidget):
|
|||||||
self._theItem = None
|
self._theItem = None
|
||||||
self._docHandle = None
|
self._docHandle = None
|
||||||
|
|
||||||
|
self._docSelection = False
|
||||||
|
|
||||||
self.sPx = int(round(0.9*self.theTheme.baseIconSize))
|
self.sPx = int(round(0.9*self.theTheme.baseIconSize))
|
||||||
fPx = int(0.9*self.theTheme.fontPixelSize)
|
fPx = int(0.9*self.theTheme.fontPixelSize)
|
||||||
bSp = self.mainConf.pxInt(4)
|
bSp = self.mainConf.pxInt(4)
|
||||||
@@ -2784,11 +2852,19 @@ class GuiDocEditFooter(QWidget):
|
|||||||
else:
|
else:
|
||||||
self._theItem = self.theProject.projTree[self._docHandle]
|
self._theItem = self.theProject.projTree[self._docHandle]
|
||||||
|
|
||||||
|
self.setHasSelection(False)
|
||||||
self.updateInfo()
|
self.updateInfo()
|
||||||
self.updateCounts()
|
self.updateCounts()
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def setHasSelection(self, hasSelection):
|
||||||
|
"""Toggle the word counter mode between full count and selection
|
||||||
|
count mode.
|
||||||
|
"""
|
||||||
|
self._docSelection = hasSelection
|
||||||
|
return
|
||||||
|
|
||||||
def updateInfo(self):
|
def updateInfo(self):
|
||||||
"""Update the content of text labels.
|
"""Update the content of text labels.
|
||||||
"""
|
"""
|
||||||
@@ -2814,7 +2890,7 @@ class GuiDocEditFooter(QWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
def updateLineCount(self):
|
def updateLineCount(self):
|
||||||
"""Update the word count.
|
"""Update the line counter.
|
||||||
"""
|
"""
|
||||||
if self._theItem is None:
|
if self._theItem is None:
|
||||||
iLine = 0
|
iLine = 0
|
||||||
@@ -2830,8 +2906,21 @@ class GuiDocEditFooter(QWidget):
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
def updateCounts(self):
|
def updateCounts(self, wCount=None, cCount=None):
|
||||||
"""Update the word count.
|
"""Select which word count display mode to use.
|
||||||
|
"""
|
||||||
|
if self._docSelection:
|
||||||
|
self._updateSelectionWordCounts(wCount, cCount)
|
||||||
|
else:
|
||||||
|
self._updateWordCounts()
|
||||||
|
return
|
||||||
|
|
||||||
|
##
|
||||||
|
# Internal Functions
|
||||||
|
##
|
||||||
|
|
||||||
|
def _updateWordCounts(self):
|
||||||
|
"""Update the word count for the whole document.
|
||||||
"""
|
"""
|
||||||
if self._theItem is None:
|
if self._theItem is None:
|
||||||
wCount = 0
|
wCount = 0
|
||||||
@@ -2851,4 +2940,19 @@ class GuiDocEditFooter(QWidget):
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _updateSelectionWordCounts(self, wCount, cCount):
|
||||||
|
"""Update the word count for a selection.
|
||||||
|
"""
|
||||||
|
if wCount is None or cCount is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.wordsText.setText(
|
||||||
|
self.tr("Words: {0} selected").format(f"{wCount:n}")
|
||||||
|
)
|
||||||
|
self.wordsText.setToolTip(
|
||||||
|
self.tr("Character count: {0}").format(f"{cCount:n}")
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
# END Class GuiDocEditFooter
|
# END Class GuiDocEditFooter
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from PyQt5.QtGui import QTextBlock, QTextCursor, QTextOption
|
|||||||
from PyQt5.QtWidgets import QAction, QMessageBox, qApp
|
from PyQt5.QtWidgets import QAction, QMessageBox, qApp
|
||||||
|
|
||||||
from novelwriter.gui.doceditor import GuiDocEditor
|
from novelwriter.gui.doceditor import GuiDocEditor
|
||||||
|
from novelwriter.core import countWords
|
||||||
from novelwriter.enum import nwDocAction, nwDocInsert, nwItemClass, nwItemLayout
|
from novelwriter.enum import nwDocAction, nwDocInsert, nwItemClass, nwItemLayout
|
||||||
from novelwriter.constants import nwKeyWords, nwUnicode
|
from novelwriter.constants import nwKeyWords, nwUnicode
|
||||||
|
|
||||||
@@ -1187,6 +1188,95 @@ def testGuiEditor_Tags(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText):
|
|||||||
# END Test testGuiEditor_Tags
|
# END Test testGuiEditor_Tags
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def testGuiEditor_WordCounters(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumText):
|
||||||
|
"""Test saving text from the editor.
|
||||||
|
"""
|
||||||
|
# Block message box
|
||||||
|
monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes)
|
||||||
|
monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes)
|
||||||
|
|
||||||
|
class MockThreadPool:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._objID = None
|
||||||
|
|
||||||
|
def start(self, runObj):
|
||||||
|
self._objID = id(runObj)
|
||||||
|
|
||||||
|
def objectID(self):
|
||||||
|
return self._objID
|
||||||
|
|
||||||
|
nwGUI.threadPool = MockThreadPool()
|
||||||
|
nwGUI.docEditor.wcTimerDoc.blockSignals(True)
|
||||||
|
nwGUI.docEditor.wcTimerSel.blockSignals(True)
|
||||||
|
assert nwGUI.openProject(nwMinimal) is True
|
||||||
|
|
||||||
|
# Run on an empty document
|
||||||
|
nwGUI.docEditor._runDocCounter()
|
||||||
|
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
|
||||||
|
nwGUI.docEditor._updateDocCounts(0, 0, 0)
|
||||||
|
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
|
||||||
|
|
||||||
|
nwGUI.docEditor._runSelCounter()
|
||||||
|
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
|
||||||
|
nwGUI.docEditor._updateSelCounts(0, 0, 0)
|
||||||
|
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
|
||||||
|
|
||||||
|
# Open a document and populate it
|
||||||
|
sHandle = "8c659a11cd429"
|
||||||
|
nwGUI.theProject.projTree[sHandle].initCount = 0 # Clear item's count
|
||||||
|
nwGUI.theProject.projTree[sHandle].wordCount = 0 # Clear item's count
|
||||||
|
assert nwGUI.openDocument(sHandle) is True
|
||||||
|
qtbot.wait(stepDelay)
|
||||||
|
|
||||||
|
theText = "\n\n".join(ipsumText)
|
||||||
|
cC, wC, pC = countWords(theText)
|
||||||
|
assert nwGUI.docEditor.replaceText(theText) is True
|
||||||
|
|
||||||
|
# Check that a busy counter is blocked
|
||||||
|
with monkeypatch.context() as mp:
|
||||||
|
mp.setattr(nwGUI.docEditor.wCounterDoc, "isRunning", lambda *a: True)
|
||||||
|
nwGUI.docEditor._runDocCounter()
|
||||||
|
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
|
||||||
|
|
||||||
|
with monkeypatch.context() as mp:
|
||||||
|
mp.setattr(nwGUI.docEditor.wCounterSel, "isRunning", lambda *a: True)
|
||||||
|
nwGUI.docEditor._runSelCounter()
|
||||||
|
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
|
||||||
|
|
||||||
|
# Run the full word counter
|
||||||
|
nwGUI.docEditor._runDocCounter()
|
||||||
|
assert nwGUI.threadPool.objectID() == id(nwGUI.docEditor.wCounterDoc)
|
||||||
|
|
||||||
|
nwGUI.docEditor.wCounterDoc.run()
|
||||||
|
# nwGUI.docEditor._updateDocCounts(cC, wC, pC)
|
||||||
|
qtbot.wait(stepDelay)
|
||||||
|
assert nwGUI.theProject.projTree[sHandle].charCount == cC
|
||||||
|
assert nwGUI.theProject.projTree[sHandle].wordCount == wC
|
||||||
|
assert nwGUI.theProject.projTree[sHandle].paraCount == pC
|
||||||
|
assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} (+{wC})"
|
||||||
|
|
||||||
|
# Select all text
|
||||||
|
assert nwGUI.docEditor.docFooter._docSelection is False
|
||||||
|
nwGUI.docEditor.docAction(nwDocAction.SEL_ALL)
|
||||||
|
qtbot.wait(stepDelay)
|
||||||
|
assert nwGUI.docEditor.docFooter._docSelection is True
|
||||||
|
|
||||||
|
# Run the selection word counter
|
||||||
|
nwGUI.docEditor._runSelCounter()
|
||||||
|
assert nwGUI.threadPool.objectID() == id(nwGUI.docEditor.wCounterSel)
|
||||||
|
|
||||||
|
nwGUI.docEditor.wCounterSel.run()
|
||||||
|
# nwGUI.docEditor._updateSelCounts(cC, wC, pC)
|
||||||
|
qtbot.wait(stepDelay)
|
||||||
|
assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} selected"
|
||||||
|
|
||||||
|
# qtbot.stopForInteraction()
|
||||||
|
|
||||||
|
# END Test testGuiEditor_WordCounters
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum):
|
def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum):
|
||||||
"""Test the document editor search functionality.
|
"""Test the document editor search functionality.
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir):
|
|||||||
qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay)
|
qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay)
|
||||||
|
|
||||||
qtbot.wait(stepDelay)
|
qtbot.wait(stepDelay)
|
||||||
nwGUI.docEditor.wCounter.run()
|
nwGUI.docEditor.wCounterDoc.run()
|
||||||
qtbot.wait(stepDelay)
|
qtbot.wait(stepDelay)
|
||||||
|
|
||||||
# Save the document
|
# Save the document
|
||||||
|
|||||||
Reference in New Issue
Block a user