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:
Veronica Berglyd Olsen
2021-09-19 13:59:36 +02:00
committed by GitHub
parent c852a5bda3
commit be3376272e
3 changed files with 219 additions and 25 deletions
+128 -24
View File
@@ -113,6 +113,7 @@ class GuiDocEditor(QTextEdit):
qDoc = self.document()
qDoc.contentsChange.connect(self._docChange)
qDoc.documentLayout().documentSizeChanged.connect(self._docSizeChanged)
self.selectionChanged.connect(self._updateSelectedStatus)
# Document Title
self.docHeader = GuiDocEditHeader(self)
@@ -152,16 +153,26 @@ class GuiDocEditor(QTextEdit):
activated=self._followTag
)
# Set Up Word Counter
self.wcTimer = QTimer()
self.wcTimer.timeout.connect(self._runCounter)
# Set Up Document Word Counter
self.wcTimerDoc = QTimer()
self.wcTimerDoc.timeout.connect(self._runDocCounter)
self.wCounter = BackgroundWordCounter(self)
self.wCounter.setAutoDelete(False)
self.wCounter.signals.countsReady.connect(self._updateCounts)
self.wCounterDoc = BackgroundWordCounter(self)
self.wCounterDoc.setAutoDelete(False)
self.wCounterDoc.signals.countsReady.connect(self._updateDocCounts)
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()
logger.debug("GuiDocEditor initialisation complete")
@@ -175,7 +186,8 @@ class GuiDocEditor(QTextEdit):
self._nwDocument = None
self.setReadOnly(True)
self.clear()
self.wcTimer.stop()
self.wcTimerDoc.stop()
self.wcTimerSel.stop()
self._docHandle = None
self._charCount = 0
@@ -283,7 +295,7 @@ class GuiDocEditor(QTextEdit):
# Configure word count timer
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
# font changed, otherwise we just clear the editor entirely,
@@ -347,8 +359,8 @@ class GuiDocEditor(QTextEdit):
self._lastEdit = time()
self._lastActive = time()
self._runCounter()
self.wcTimer.start()
self._runDocCounter()
self.wcTimerDoc.start()
self._docHandle = tHandle
self.setReadOnly(False)
@@ -445,7 +457,7 @@ class GuiDocEditor(QTextEdit):
docText = self.getText()
cC, wC, pC = countWords(docText)
self._updateCounts(cC, wC, pC)
self._updateDocCounts(cC, wC, pC)
self._nwItem.setCharCount(self._charCount)
self._nwItem.setWordCount(self._wordCount)
@@ -1078,8 +1090,8 @@ class GuiDocEditor(QTextEdit):
if not self._docChanged:
self.setDocumentChanged(chrRem != 0 or chrAdd != 0)
if not self.wcTimer.isActive():
self.wcTimer.start()
if not self.wcTimerDoc.isActive():
self.wcTimerDoc.start()
if self._doReplace and chrAdd == 1:
self._docAutoReplace(self.document().findBlock(thePos))
@@ -1213,25 +1225,25 @@ class GuiDocEditor(QTextEdit):
return
@pyqtSlot()
def _runCounter(self):
def _runDocCounter(self):
"""Decide whether to run the word counter, or not due to
inactivity.
"""
if self._docHandle is None:
return
if self.wCounter.isRunning():
if self.wCounterDoc.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)
self.theParent.threadPool.start(self.wCounterDoc)
return
@pyqtSlot(int, int, int)
def _updateCounts(self, cCount, wCount, pCount):
def _updateDocCounts(self, cCount, wCount, pCount):
"""Slot for the word counter's finished signal
"""
if self._docHandle is None or self._nwItem is None:
@@ -1255,6 +1267,51 @@ class GuiDocEditor(QTextEdit):
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")
def _docSizeChanged(self, theSize):
"""Called whenever the underlying document layout size changes.
@@ -2010,11 +2067,15 @@ class GuiDocEditor(QTextEdit):
class BackgroundWordCounter(QRunnable):
def __init__(self, docEditor):
def __init__(self, docEditor, forSelection=False):
QRunnable.__init__(self)
self.docEditor = docEditor
self.signals = BackgroundWordCounterSignals()
self._docEditor = docEditor
self._forSelection = forSelection
self._isRunning = False
self.signals = BackgroundWordCounterSignals()
return
def isRunning(self):
@@ -2026,10 +2087,15 @@ class BackgroundWordCounter(QRunnable):
call to the function that does the actual counting.
"""
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)
self.signals.countsReady.emit(cC, wC, pC)
self._isRunning = False
return
# END Class BackgroundWordCounter
@@ -2666,6 +2732,8 @@ class GuiDocEditFooter(QWidget):
self._theItem = None
self._docHandle = None
self._docSelection = False
self.sPx = int(round(0.9*self.theTheme.baseIconSize))
fPx = int(0.9*self.theTheme.fontPixelSize)
bSp = self.mainConf.pxInt(4)
@@ -2784,11 +2852,19 @@ class GuiDocEditFooter(QWidget):
else:
self._theItem = self.theProject.projTree[self._docHandle]
self.setHasSelection(False)
self.updateInfo()
self.updateCounts()
return
def setHasSelection(self, hasSelection):
"""Toggle the word counter mode between full count and selection
count mode.
"""
self._docSelection = hasSelection
return
def updateInfo(self):
"""Update the content of text labels.
"""
@@ -2814,7 +2890,7 @@ class GuiDocEditFooter(QWidget):
return
def updateLineCount(self):
"""Update the word count.
"""Update the line counter.
"""
if self._theItem is None:
iLine = 0
@@ -2830,8 +2906,21 @@ class GuiDocEditFooter(QWidget):
return
def updateCounts(self):
"""Update the word count.
def updateCounts(self, wCount=None, cCount=None):
"""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:
wCount = 0
@@ -2851,4 +2940,19 @@ class GuiDocEditFooter(QWidget):
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
+90
View File
@@ -28,6 +28,7 @@ from PyQt5.QtGui import QTextBlock, QTextCursor, QTextOption
from PyQt5.QtWidgets import QAction, QMessageBox, qApp
from novelwriter.gui.doceditor import GuiDocEditor
from novelwriter.core import countWords
from novelwriter.enum import nwDocAction, nwDocInsert, nwItemClass, nwItemLayout
from novelwriter.constants import nwKeyWords, nwUnicode
@@ -1187,6 +1188,95 @@ def testGuiEditor_Tags(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText):
# 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
def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum):
"""Test the document editor search functionality.
+1 -1
View File
@@ -317,7 +317,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir):
qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay)
qtbot.wait(stepDelay)
nwGUI.docEditor.wCounter.run()
nwGUI.docEditor.wCounterDoc.run()
qtbot.wait(stepDelay)
# Save the document