From 026aeb3d82b42c8a2d1843238b4c44f1bf7c7e52 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 24 Mar 2024 14:52:32 +0100 Subject: [PATCH] Use regex search instead --- novelwriter/core/coretools.py | 41 +++++++++++++++++++----- novelwriter/gui/search.py | 59 +++++++++++++++++++++++------------ 2 files changed, 73 insertions(+), 27 deletions(-) diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 9e87e595..0a36bc81 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -34,7 +34,7 @@ from functools import partial from zipfile import ZipFile, is_zipfile from collections.abc import Iterable -from PyQt5.QtCore import QCoreApplication +from PyQt5.QtCore import QCoreApplication, QRegularExpression from novelwriter import CONFIG, SHARED from novelwriter.common import isHandle, minmax, simplified @@ -306,8 +306,13 @@ class DocDuplicator: class DocSearch: - def __init__(self, project: NWProject) -> None: + def __init__(self, project: NWProject, regEx: bool, doCase: bool, wordsOnly: bool) -> None: self._project = project + self._escape = not regEx + self._words = wordsOnly and not regEx + self._rxOpts = QRegularExpression.PatternOption.UseUnicodePropertiesOption + if not doCase: + self._rxOpts |= QRegularExpression.PatternOption.CaseInsensitiveOption return def iterSearch(self, search: str) -> Iterable[tuple[NWItem, list[tuple[int, int, str]]]]: @@ -315,20 +320,42 @@ class DocSearch: num = len(search) cap = min(num+100, 100) storage = self._project.storage + regEx = QRegularExpression(self._buildPattern(search), self._rxOpts) + print(regEx.pattern()) for item in self._project.tree: if item.isFileType(): text = storage.getDocument(item.itemHandle).readDocument() or "" - prev = 0 + rxItt = regEx.globalMatch(text) results = [] - count = 0 - while (pos := text.find(search, prev)) >= 0 and count < 100: - count += 1 + while rxItt.hasNext(): + rxMatch = rxItt.next() + pos = rxMatch.capturedStart() + num = rxMatch.capturedLength() context = text[pos:pos+cap].partition("\n")[0] results.append((pos, num, context)) - prev = pos + num yield item, results return + ## + # Internal Functions + ## + + def _buildPattern(self, search: str) -> str: + """Build the search pattern string.""" + if self._escape: + if CONFIG.verQtValue >= 0x050f00: + search = QRegularExpression.escape(search) + else: + # For older Qt versions, we escape manually + escaped = "" + for c in search: + if c.isalnum() or c == "_": + escaped += c + else: + escaped += f"\\{c}" + search = escaped + return f"\\b{search}\\b" if self._words else search + # END Class DocSearch diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py index 72826e85..3568a3f1 100644 --- a/novelwriter/gui/search.py +++ b/novelwriter/gui/search.py @@ -25,18 +25,16 @@ from __future__ import annotations import logging -from PyQt5.QtCore import pyqtSlot +from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtGui import QPalette from PyQt5.QtWidgets import ( - QHBoxLayout, QLabel, QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, - QWidget + QHBoxLayout, QLabel, QLineEdit, QToolBar, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget ) from novelwriter import CONFIG, SHARED from novelwriter.core.coretools import DocSearch from novelwriter.core.item import NWItem -from novelwriter.extensions.modified import NIconToolButton -from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON logger = logging.getLogger(__name__) @@ -56,28 +54,46 @@ class GuiProjectSearch(QWidget): self.viewLabel.setFont(SHARED.theme.guiFontB) self.viewLabel.setContentsMargins(mPx, mPx, 0, mPx) + # Options + self.searchOpt = QToolBar(self) + self.searchOpt.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + self.searchOpt.setIconSize(QSize(iPx, iPx)) + self.searchOpt.setContentsMargins(0, 0, 0, 0) + + self.toggleCase = self.searchOpt.addAction(self.tr("Case Sensitive")) + self.toggleCase.setCheckable(True) + + self.toggleWord = self.searchOpt.addAction(self.tr("Whole Words Only")) + self.toggleWord.setCheckable(True) + + self.toggleRegEx = self.searchOpt.addAction(self.tr("RegEx Mode")) + self.toggleRegEx.setCheckable(True) + # Controls self.searchText = QLineEdit(self) self.searchText.setPlaceholderText(self.tr("Search text ...")) - - self.searchButton = NIconToolButton(self, iPx) - self.searchButton.clicked.connect(self._processSearch) - - self.searchBar = QHBoxLayout() - self.searchBar.addWidget(self.searchText) - self.searchBar.addWidget(self.searchButton) + self.searchText.setClearButtonEnabled(True) + self.searchAction = self.searchText.addAction( + SHARED.theme.getIcon("search"), QLineEdit.ActionPosition.TrailingPosition + ) + self.searchAction.triggered.connect(self._processSearch) # Search Result self.searchResult = QTreeWidget(self) self.searchResult.setHeaderHidden(True) # Assemble + self.headerBox = QHBoxLayout() + self.headerBox.addWidget(self.viewLabel, 1) + self.headerBox.addWidget(self.searchOpt, 0) + self.headerBox.setContentsMargins(0, 0, 0, 0) + self.outerBox = QVBoxLayout() - self.outerBox.addWidget(self.viewLabel, 0) - self.outerBox.addLayout(self.searchBar, 0) + self.outerBox.addLayout(self.headerBox, 0) + self.outerBox.addWidget(self.searchText, 0) self.outerBox.addWidget(self.searchResult, 1) self.outerBox.setContentsMargins(0, 0, 0, 0) - self.outerBox.setSpacing(0) + self.outerBox.setSpacing(mPx) self.setLayout(self.outerBox) self.updateTheme() @@ -96,10 +112,10 @@ class GuiProjectSearch(QWidget): qPalette.setBrush(QPalette.ColorRole.Window, qPalette.base()) self.setPalette(qPalette) - buttonStyle = SHARED.theme.getStyleSheet(STYLES_MIN_TOOLBUTTON) - self.searchButton.setStyleSheet(buttonStyle) - - self.searchButton.setIcon(SHARED.theme.getIcon("search")) + self.searchAction.setIcon(SHARED.theme.getIcon("search")) + self.toggleCase.setIcon(SHARED.theme.getIcon("search_case")) + self.toggleWord.setIcon(SHARED.theme.getIcon("search_word")) + self.toggleRegEx.setIcon(SHARED.theme.getIcon("search_regex")) return @@ -112,7 +128,10 @@ class GuiProjectSearch(QWidget): """Perform a search.""" self.searchResult.clear() if text := self.searchText.text(): - search = DocSearch(SHARED.project) + search = DocSearch( + SHARED.project, self.toggleRegEx.isChecked(), + self.toggleCase.isChecked(), self.toggleWord.isChecked() + ) for item, results in search.iterSearch(text): self._appendResultSet(item, results) return