diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 39605f0a..941e83ee 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -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 diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 9e469732..9b82550f 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -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. diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index c3fe9c7c..de064614 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -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