From becb404203291104b9f874b0c33d4c1806e8ef24 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 24 Mar 2024 15:56:22 +0100 Subject: [PATCH] Complete handling of search results --- novelwriter/core/coretools.py | 9 ++++-- novelwriter/gui/doceditor.py | 14 ++++++---- novelwriter/gui/search.py | 45 ++++++++++++++++++++++++++---- novelwriter/guimain.py | 20 +++++++++---- tests/test_gui/test_gui_outline.py | 6 ++-- 5 files changed, 73 insertions(+), 21 deletions(-) diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 0a36bc81..7ecd04d7 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -306,10 +306,10 @@ class DocDuplicator: class DocSearch: - def __init__(self, project: NWProject, regEx: bool, doCase: bool, wordsOnly: bool) -> None: + def __init__(self, project: NWProject, regEx: bool, doCase: bool, wholeWords: bool) -> None: self._project = project self._escape = not regEx - self._words = wordsOnly and not regEx + self._words = wholeWords self._rxOpts = QRegularExpression.PatternOption.UseUnicodePropertiesOption if not doCase: self._rxOpts |= QRegularExpression.PatternOption.CaseInsensitiveOption @@ -354,7 +354,10 @@ class DocSearch: else: escaped += f"\\{c}" search = escaped - return f"\\b{search}\\b" if self._words else search + if self._words: + search = search if search.startswith("\\b") else f"\\b{search}" + search = search if search.endswith("\\b") else f"{search}\\b" + return search # END Class DocSearch diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 8cd72429..6f6edfb6 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -637,6 +637,14 @@ class GuiDocEditor(QPlainTextEdit): logger.debug("Cursor moved to line %d", line) return + def setCursorSelection(self, selStart: int, selLength: int) -> None: + """Make a text selection.""" + cursor = self.textCursor() + cursor.setPosition(selStart, QTextCursor.MoveMode.MoveAnchor) + cursor.setPosition(selStart + selLength, QTextCursor.MoveMode.KeepAnchor) + self.setTextCursor(cursor) + return + ## # Spell Checking ## @@ -791,11 +799,7 @@ class GuiDocEditor(QPlainTextEdit): def anyFocus(self) -> bool: """Check if any widget or child widget has focus.""" - if self.hasFocus(): - return True - if self.isAncestorOf(qApp.focusWidget()): - return True - return False + return self.hasFocus() or self.isAncestorOf(qApp.focusWidget()) def revealLocation(self) -> None: """Tell the user where on the file system the file in the editor diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py index 3568a3f1..674008c4 100644 --- a/novelwriter/gui/search.py +++ b/novelwriter/gui/search.py @@ -25,7 +25,7 @@ from __future__ import annotations import logging -from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot from PyQt5.QtGui import QPalette from PyQt5.QtWidgets import ( QHBoxLayout, QLabel, QLineEdit, QToolBar, QTreeWidget, QTreeWidgetItem, @@ -33,6 +33,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter import CONFIG, SHARED +from novelwriter.common import checkInt from novelwriter.core.coretools import DocSearch from novelwriter.core.item import NWItem @@ -41,6 +42,11 @@ logger = logging.getLogger(__name__) class GuiProjectSearch(QWidget): + D_HANDLE = Qt.ItemDataRole.UserRole + D_RESULT = Qt.ItemDataRole.UserRole + 1 + + openDocumentSelectRequest = pyqtSignal(str, int, int) + def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) @@ -69,10 +75,11 @@ class GuiProjectSearch(QWidget): self.toggleRegEx = self.searchOpt.addAction(self.tr("RegEx Mode")) self.toggleRegEx.setCheckable(True) - # Controls + # Search Box self.searchText = QLineEdit(self) self.searchText.setPlaceholderText(self.tr("Search text ...")) self.searchText.setClearButtonEnabled(True) + self.searchAction = self.searchText.addAction( SHARED.theme.getIcon("search"), QLineEdit.ActionPosition.TrailingPosition ) @@ -81,6 +88,9 @@ class GuiProjectSearch(QWidget): # Search Result self.searchResult = QTreeWidget(self) self.searchResult.setHeaderHidden(True) + self.searchResult.setIconSize(QSize(iPx, iPx)) + self.searchResult.setIndentation(iPx) + self.searchResult.itemPressed.connect(self._searchResultSelected) # Assemble self.headerBox = QHBoxLayout() @@ -119,6 +129,12 @@ class GuiProjectSearch(QWidget): return + def processReturn(self) -> None: + """Process a return key press forwarded from main GUI.""" + if self.searchText.hasFocus(): + self._processSearch() + return + ## # Private Slots ## @@ -136,21 +152,40 @@ class GuiProjectSearch(QWidget): self._appendResultSet(item, results) return + @pyqtSlot("QTreeWidgetItem*", int) + def _searchResultSelected(self, item: QTreeWidgetItem, column: int) -> None: + """Process search result selection.""" + if (data := item.data(0, self.D_RESULT)) and len(data) == 3: + self.openDocumentSelectRequest.emit( + str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1) + ) + return + ## # Internal Functions ## - def _appendResultSet(self, item: NWItem, results: list[tuple[int, int, str]]) -> None: + def _appendResultSet(self, nwItem: NWItem, results: list[tuple[int, int, str]]) -> None: """Populate the result tree.""" if results: + tHandle = nwItem.itemHandle + docIcon = SHARED.theme.getItemIcon( + nwItem.itemType, nwItem.itemClass, + nwItem.itemLayout, nwItem.mainHeading + ) + tItem = QTreeWidgetItem() - tItem.setText(0, f"{item.itemName} ({len(results)})") + tItem.setText(0, f"{nwItem.itemName} ({len(results)})") + tItem.setIcon(0, docIcon) + tItem.setData(0, self.D_HANDLE, tHandle) rItems = [] - for start, end, context in results: + for start, length, context in results: rItem = QTreeWidgetItem() rItem.setText(0, context) + rItem.setData(0, self.D_RESULT, (tHandle, start, length)) rItems.append(rItem) tItem.addChildren(rItems) + tItem.setExpanded(True) self.searchResult.addTopLevelItem(tItem) return diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 5983b2dc..c247c218 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -263,6 +263,8 @@ class GuiMain(QMainWindow): self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox) self.novelView.openDocumentRequest.connect(self._openDocument) + self.projSearch.openDocumentSelectRequest.connect(self._openDocumentSelection) + self.docEditor.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus) self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts) self.docEditor.docCountsChanged.connect(self.projView.updateCounts) @@ -1150,6 +1152,13 @@ class GuiMain(QMainWindow): self.viewDocument(tHandle=tHandle, sTitle=sTitle) return + @pyqtSlot(str, int, int) + def _openDocumentSelection(self, tHandle: str, selStart: int, selLength: int) -> None: + """Open a document and select a section of the text.""" + if self.openDocument(tHandle): + self.docEditor.setCursorSelection(selStart, selLength) + return + @pyqtSlot() def _reloadViewer(self) -> None: """Reload the document in the viewer.""" @@ -1263,15 +1272,16 @@ class GuiMain(QMainWindow): @pyqtSlot() def _keyPressReturn(self) -> None: - """Forward the return/enter keypress to the function that opens - the currently selected item. - """ - self.openSelectedItem() + """Process a return or enter keypress in the main window.""" + if self.projStack.currentWidget() == self.projSearch: + self.projSearch.processReturn() + else: + self.openSelectedItem() return @pyqtSlot() def _keyPressEscape(self) -> None: - """Process escape keypress in the main window.""" + """Process an escape keypress in the main window.""" if self.docEditor.docSearch.isVisible(): self.docEditor.closeSearch() elif SHARED.focusMode: diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py index 48e4a06e..1aa0565f 100644 --- a/tests/test_gui/test_gui_outline.py +++ b/tests/test_gui/test_gui_outline.py @@ -237,7 +237,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat selItem = outlineTree.topLevelItem(0) outlineTree.setCurrentItem(selItem) - assert outlineData.titleLabel.text() == "Title" + assert outlineData.titleLabel.text() == "Title" assert outlineData.titleValue.text() == "Lorem Ipsum" assert outlineData.fileValue.text() == "Lorem Ipsum" assert outlineData.itemValue.text() == "Finished" @@ -256,7 +256,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat assert tHandle == "88243afbe5ed8" assert sTitle == "T0001" - assert outlineData.titleLabel.text() == "Scene" + assert outlineData.titleLabel.text() == "Scene" assert outlineData.titleValue.text() == "Scene One" assert outlineData.fileValue.text() == "Scene One" assert outlineData.itemValue.text() == "Finished" @@ -274,7 +274,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat assert tHandle == "88243afbe5ed8" assert sTitle == "T0002" - assert outlineData.titleLabel.text() == "Section" + assert outlineData.titleLabel.text() == "Section" assert outlineData.titleValue.text() == "Scene One, Section Two" assert outlineData.fileValue.text() == "Scene One" assert outlineData.itemValue.text() == "Finished"