diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 6492ed7d..9e6bb512 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -38,7 +38,7 @@ from PyQt5.QtCore import QCoreApplication, QRegularExpression from novelwriter import CONFIG, SHARED from novelwriter.common import isHandle, minmax, simplified -from novelwriter.constants import nwFiles, nwItemClass +from novelwriter.constants import nwConst, nwFiles, nwItemClass from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.core.storage import NWStorageCreate @@ -353,7 +353,7 @@ class DocSearch: def iterSearch( self, project: NWProject, search: str - ) -> Iterable[tuple[NWItem, list[tuple[int, int, str]]]]: + ) -> Iterable[tuple[NWItem, list[tuple[int, int, str]], bool]]: """Iteratively search through documents in a project.""" if project.data.uuid != self._uuid: self.clearTextCache(None) @@ -373,15 +373,22 @@ class DocSearch: self._cache[tHandle] = text 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] - results.append((pos, num, context)) + if context: + results.append((pos, num, context)) + count += 1 + if count >= nwConst.MAX_SEARCH_RESULT: + capped = True + break - yield item, results + yield item, results, capped return diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py index 600d28bd..9c3ed4ca 100644 --- a/novelwriter/gui/search.py +++ b/novelwriter/gui/search.py @@ -23,18 +23,18 @@ along with this program. If not, see . """ from __future__ import annotations +import time import logging from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot from PyQt5.QtGui import QKeyEvent, QPalette from PyQt5.QtWidgets import ( - QHBoxLayout, QLabel, QLineEdit, QToolBar, QTreeWidget, QTreeWidgetItem, - QVBoxLayout, QWidget, qApp + QHBoxLayout, QHeaderView, QLabel, QLineEdit, QToolBar, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget, qApp ) from novelwriter import CONFIG, SHARED from novelwriter.common import checkInt -from novelwriter.constants import nwConst from novelwriter.core.coretools import DocSearch from novelwriter.core.item import NWItem @@ -43,10 +43,15 @@ logger = logging.getLogger(__name__) class GuiProjectSearch(QWidget): + C_NAME = 0 + C_RESULT = 0 + C_COUNT = 1 + D_HANDLE = Qt.ItemDataRole.UserRole D_RESULT = Qt.ItemDataRole.UserRole + 1 openDocumentSelectRequest = pyqtSignal(str, int, int, bool) + selectedItemChanged = pyqtSignal(str) def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) @@ -97,10 +102,17 @@ class GuiProjectSearch(QWidget): # Search Result self.searchResult = QTreeWidget(self) self.searchResult.setHeaderHidden(True) + self.searchResult.setColumnCount(2) self.searchResult.setIconSize(QSize(iPx, iPx)) self.searchResult.setIndentation(iPx) - self.searchResult.itemPressed.connect(self._searchResultSelected) + self.searchResult.itemPressed.connect(self._searchResultPressed) self.searchResult.itemDoubleClicked.connect(self._searchResultDoubleClicked) + self.searchResult.itemSelectionChanged.connect(self._searchResultSelected) + + treeHeader = self.searchResult.header() + treeHeader.setStretchLastSection(False) + treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch) + treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeMode.ResizeToContents) # Assemble self.headerBox = QHBoxLayout() @@ -144,7 +156,7 @@ class GuiProjectSearch(QWidget): if self.searchText.hasFocus(): self._processSearch() elif items := self.searchResult.selectedItems(): - self._searchResultSelected(items[0], 0) + self._searchResultPressed(items[0], 0) return def beginSearch(self) -> None: @@ -203,18 +215,30 @@ class GuiProjectSearch(QWidget): @pyqtSlot() def _processSearch(self) -> None: """Perform a search.""" + start = time.time() 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 in self._search.iterSearch(SHARED.project, text): - self._appendResultSet(item, results) + for item, results, capped in self._search.iterSearch(SHARED.project, text): + self._appendResultSet(item, results, capped) + logger.debug("Search took %.3f ms", 1000*(time.time() - start)) + return + + @pyqtSlot() + def _searchResultSelected(self) -> None: + """Process search result selection.""" + if items := self.searchResult.selectedItems(): + if (data := items[0].data(0, self.D_RESULT)) and len(data) == 3: + self.selectedItemChanged.emit(str(data[0])) + elif data := items[0].data(0, self.D_HANDLE): + self.selectedItemChanged.emit(str(data)) return @pyqtSlot("QTreeWidgetItem*", int) - def _searchResultSelected(self, item: QTreeWidgetItem, column: int) -> None: - """Process search result selection.""" + def _searchResultPressed(self, item: QTreeWidgetItem, column: int) -> None: + """Process search result pressed.""" 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), False @@ -252,11 +276,9 @@ class GuiProjectSearch(QWidget): # Internal Functions ## - def _initSearch(self) -> None: - """Initialise the search.""" - return - - def _appendResultSet(self, nwItem: NWItem, results: list[tuple[int, int, str]]) -> None: + def _appendResultSet( + self, nwItem: NWItem, results: list[tuple[int, int, str]], capped: bool + ) -> None: """Populate the result tree.""" if results: tHandle = nwItem.itemHandle @@ -264,17 +286,15 @@ class GuiProjectSearch(QWidget): nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, nwItem.mainHeading ) - lim = nwConst.MAX_SEARCH_RESULT - count = len(results) - numResult = f"{count:n}" - if count > lim: - results = results[:lim] - numResult = f"{lim:n}+" + ext = "+" if capped else "" tItem = QTreeWidgetItem() - tItem.setText(0, f"{nwItem.itemName} ({numResult})") - tItem.setIcon(0, docIcon) - tItem.setData(0, self.D_HANDLE, tHandle) + tItem.setText(self.C_NAME, nwItem.itemName) + tItem.setIcon(self.C_NAME, docIcon) + tItem.setData(self.C_NAME, self.D_HANDLE, tHandle) + 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) rItems = [] @@ -287,6 +307,10 @@ class GuiProjectSearch(QWidget): tItem.addChildren(rItems) tItem.setExpanded(True) + parent = self.searchResult.indexFromItem(tItem) + for i in range(tItem.childCount()): + self.searchResult.setFirstColumnSpanned(i, parent, True) + qApp.processEvents() return diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 256fead4..49623cd4 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -246,6 +246,7 @@ class GuiMain(QMainWindow): self.novelView.openDocumentRequest.connect(self._openDocument) self.projSearch.openDocumentSelectRequest.connect(self._openDocumentSelection) + self.projSearch.selectedItemChanged.connect(self.itemDetails.updateViewBox) self.docEditor.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus) self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts)