From 536e828cc6dcb410107bfbc7e7a3337b4a6c90d3 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 28 Mar 2024 13:40:15 +0100 Subject: [PATCH 1/4] Refactor doc search class to allow single document search --- novelwriter/core/coretools.py | 40 +++++++++++++++++------------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 7b26f71e..f6558600 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -337,32 +337,32 @@ class DocSearch: """Iteratively search through documents in a project.""" self._regEx.setPattern(self._buildPattern(search)) logger.debug("Searching with pattern '%s'", self._regEx.pattern()) - - num = len(search) storage = project.storage for item in project.tree: if item.isFileType(): - text = storage.getDocumentText(item.itemHandle) - rxItt = self._regEx.globalMatch(text) - count = 0 - capped = False - results = [] - while rxItt.hasNext(): - rxMatch = rxItt.next() - pos = rxMatch.capturedStart() - num = rxMatch.capturedLength() - context = text[pos:pos+100].partition("\n")[0] - if context: - results.append((pos, num, context)) - count += 1 - if count >= nwConst.MAX_SEARCH_RESULT: - capped = True - break - + results, capped = self.searchText(storage.getDocumentText(item.itemHandle)) yield item, results, capped - return + def searchText(self, text: str) -> tuple[list[tuple[int, int, str]], bool]: + """Search a piece of text for RegEx matches.""" + rxItt = self._regEx.globalMatch(text) + count = 0 + capped = False + results = [] + while rxItt.hasNext(): + rxMatch = rxItt.next() + pos = rxMatch.capturedStart() + num = rxMatch.capturedLength() + context = text[pos:pos+100].partition("\n")[0] + if context: + results.append((pos, num, context)) + count += 1 + if count >= nwConst.MAX_SEARCH_RESULT: + capped = True + break + return results, capped + ## # Internal Functions ## From 7149e363b120a3d0509d11fa036bd59afdf23b73 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 28 Mar 2024 13:40:52 +0100 Subject: [PATCH 2/4] Update search result on document change --- novelwriter/gui/doceditor.py | 14 ++++++++------ novelwriter/gui/search.py | 32 +++++++++++++++++++++++++++----- novelwriter/guimain.py | 1 + 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 7c8c6747..cd90bcfd 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -92,6 +92,7 @@ class GuiDocEditor(QPlainTextEdit): # Custom Signals statusMessage = pyqtSignal(str) docCountsChanged = pyqtSignal(str, int, int, int) + docTextChanged = pyqtSignal(str, float) editedStatusChanged = pyqtSignal(bool) loadDocumentTagRequest = pyqtSignal(str, Enum) novelStructureChanged = pyqtSignal() @@ -1193,18 +1194,19 @@ class GuiDocEditor(QPlainTextEdit): if self._docHandle is None: return - if self.wCounterDoc.isRunning(): - logger.debug("Word counter is busy") - return - if time() - self._lastEdit < 25.0: - logger.debug("Running word counter") - SHARED.runInThreadPool(self.wCounterDoc) + logger.debug("Running document tasks") + if not self.wCounterDoc.isRunning(): + SHARED.runInThreadPool(self.wCounterDoc) + self.docHeader.setOutline({ block.blockNumber(): block.text() for block in self._qDocument.iterBlockByType(BLOCK_TITLE, maxCount=30) }) + if self._docChanged: + self.docTextChanged.emit(self._docHandle, self._lastEdit) + return @pyqtSlot(int, int, int) diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py index b32e652a..95ef15ea 100644 --- a/novelwriter/gui/search.py +++ b/novelwriter/gui/search.py @@ -66,6 +66,7 @@ class GuiProjectSearch(QWidget): self._time = time() self._search = DocSearch() self._blocked = False + self._map: dict[str, tuple[int, float]] = {} # Header self.viewLabel = QLabel(self.tr("Project Search")) @@ -197,6 +198,7 @@ class GuiProjectSearch(QWidget): def closeProjectTasks(self) -> None: """Run close project tasks.""" + self._map = {} self.searchText.clear() self.searchResult.clear() return @@ -228,6 +230,20 @@ class GuiProjectSearch(QWidget): super().keyPressEvent(event) return + ## + # Public Slots + ## + + @pyqtSlot(str, float) + def textChanged(self, tHandle: str, timeStamp: float) -> None: + """Update search result for a specific document.""" + if timeStamp > self._map.get(tHandle, (0, 0.0))[1]: + start = time() + results, capped = self._search.searchText(SHARED.mainGui.docEditor.getText()) + self._displayResultSet(SHARED.project.tree[tHandle], results, capped) + logger.debug("Updated search for '%s' in %.3f ms", tHandle, 1000*(time() - start)) + return + ## # Private Slots ## @@ -238,14 +254,16 @@ class GuiProjectSearch(QWidget): if not self._blocked: qApp.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) start = time() + SHARED.mainGui.saveDocument() self._blocked = True + self._map = {} self.searchResult.clear() if text := self.searchText.text(): self._search.setUserRegEx(self.toggleRegEx.isChecked()) self._search.setCaseSensitive(self.toggleCase.isChecked()) self._search.setWholeWords(self.toggleWord.isChecked()) for item, results, capped in self._search.iterSearch(SHARED.project, text): - self._appendResultSet(item, results, capped) + self._displayResultSet(item, results, capped) logger.debug("Search took %.3f ms", 1000*(time() - start)) self._time = time() qApp.restoreOverrideCursor() @@ -293,11 +311,11 @@ class GuiProjectSearch(QWidget): # Internal Functions ## - def _appendResultSet( - self, nwItem: NWItem, results: list[tuple[int, int, str]], capped: bool + def _displayResultSet( + self, nwItem: NWItem | None, results: list[tuple[int, int, str]], capped: bool ) -> None: """Populate the result tree.""" - if results: + if results and nwItem: tHandle = nwItem.itemHandle docIcon = SHARED.theme.getItemIcon( nwItem.itemType, nwItem.itemClass, @@ -312,7 +330,11 @@ class GuiProjectSearch(QWidget): tItem.setText(self.C_COUNT, f"({len(results):n}{ext})") tItem.setTextAlignment(self.C_COUNT, Qt.AlignmentFlag.AlignRight) tItem.setForeground(self.C_COUNT, self.palette().highlight()) - self.searchResult.addTopLevelItem(tItem) + + index = self._map.get(tHandle, (self.searchResult.topLevelItemCount(), 0.0))[0] + self.searchResult.takeTopLevelItem(index) + self.searchResult.insertTopLevelItem(index, tItem) + self._map[tHandle] = (index, time()) rItems = [] for start, length, context in results: diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 28f58905..3a336577 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -262,6 +262,7 @@ class GuiMain(QMainWindow): self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle) self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem) self.docEditor.requestNewNoteCreation.connect(self.projView.createNewNote) + self.docEditor.docTextChanged.connect(self.projSearch.textChanged) self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle) self.docViewer.loadDocumentTagRequest.connect(self._followTag) From 247cd65bb63e405b357b8347a93dca56b06f50cf Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 28 Mar 2024 13:54:35 +0100 Subject: [PATCH 3/4] Only update search for documents in current results --- novelwriter/gui/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py index 95ef15ea..d0aafe0b 100644 --- a/novelwriter/gui/search.py +++ b/novelwriter/gui/search.py @@ -237,7 +237,7 @@ class GuiProjectSearch(QWidget): @pyqtSlot(str, float) def textChanged(self, tHandle: str, timeStamp: float) -> None: """Update search result for a specific document.""" - if timeStamp > self._map.get(tHandle, (0, 0.0))[1]: + if (entry := self._map.get(tHandle)) and timeStamp > entry[1]: start = time() results, capped = self._search.searchText(SHARED.mainGui.docEditor.getText()) self._displayResultSet(SHARED.project.tree[tHandle], results, capped) From c05463f0ae82568d4e21222b90cc49b9c33d5a71 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 28 Mar 2024 14:10:01 +0100 Subject: [PATCH 4/4] Update test coverage --- tests/test_gui/test_gui_search.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_gui/test_gui_search.py b/tests/test_gui/test_gui_search.py index 20a18b9c..bd8ed4be 100644 --- a/tests/test_gui/test_gui_search.py +++ b/tests/test_gui/test_gui_search.py @@ -20,6 +20,8 @@ along with this program. If not, see . """ from __future__ import annotations +from time import time + import pytest from PyQt5.QtCore import Qt @@ -58,6 +60,7 @@ def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum): assert result == (handle, 3, 5) # Move down + search.searchText.setFocus() qtbot.keyClick(search, Qt.Key.Key_Down) assert firstDoc.isSelected() is True @@ -119,6 +122,11 @@ def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum): assert search.searchResult.topLevelItemCount() == 10 assert totalCount() == 34 + # Re-run search should not change the result + search.textChanged(handle, time() + 1000.0) + assert search.searchResult.topLevelItemCount() == 10 + assert totalCount() == 34 + # qtbot.stop() nwGUI.closeProject()