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 = 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
+90
View File
@@ -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.
+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.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