From 102885da6e98aa045b02599d5fabbf8ee912738a Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 21 Mar 2024 22:16:40 +0100
Subject: [PATCH 01/14] Add sidebar button for global search
---
novelwriter/assets/icons/typicons_dark/icons.conf | 1 +
.../assets/icons/typicons_dark/typ_search-grey.svg | 4 ++++
novelwriter/assets/icons/typicons_light/icons.conf | 1 +
.../assets/icons/typicons_light/typ_search-grey.svg | 4 ++++
novelwriter/enum.py | 1 +
novelwriter/gui/sidebar.py | 8 ++++++++
novelwriter/gui/theme.py | 2 +-
7 files changed, 20 insertions(+), 1 deletion(-)
create mode 100644 novelwriter/assets/icons/typicons_dark/typ_search-grey.svg
create mode 100644 novelwriter/assets/icons/typicons_light/typ_search-grey.svg
diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf
index e183b1a0..75a95c5d 100644
--- a/novelwriter/assets/icons/typicons_dark/icons.conf
+++ b/novelwriter/assets/icons/typicons_dark/icons.conf
@@ -108,6 +108,7 @@ view_build = typ_export-grey.svg
view_editor = mixed_edit.svg
view_novel = typ_book-grey.svg
view_outline = typ_puzzle-outline.svg
+view_search = typ_search-grey.svg
deco_doc_h0 = nw_deco-h0.svg
deco_doc_h0_n = nw_deco-h0.svg
diff --git a/novelwriter/assets/icons/typicons_dark/typ_search-grey.svg b/novelwriter/assets/icons/typicons_dark/typ_search-grey.svg
new file mode 100644
index 00000000..5a8a7330
--- /dev/null
+++ b/novelwriter/assets/icons/typicons_dark/typ_search-grey.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf
index 9763734e..57ad7afa 100644
--- a/novelwriter/assets/icons/typicons_light/icons.conf
+++ b/novelwriter/assets/icons/typicons_light/icons.conf
@@ -108,6 +108,7 @@ view_build = typ_export-grey.svg
view_editor = mixed_edit.svg
view_novel = typ_book-grey.svg
view_outline = typ_puzzle-outline.svg
+view_search = typ_search-grey.svg
deco_doc_h0 = nw_deco-h0.svg
deco_doc_h0_n = nw_deco-h0.svg
diff --git a/novelwriter/assets/icons/typicons_light/typ_search-grey.svg b/novelwriter/assets/icons/typicons_light/typ_search-grey.svg
new file mode 100644
index 00000000..e6249537
--- /dev/null
+++ b/novelwriter/assets/icons/typicons_light/typ_search-grey.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/novelwriter/enum.py b/novelwriter/enum.py
index b7e38e40..d905e587 100644
--- a/novelwriter/enum.py
+++ b/novelwriter/enum.py
@@ -155,6 +155,7 @@ class nwView(Enum):
PROJECT = 1
NOVEL = 2
OUTLINE = 3
+ SEARCH = 4
# END Enum nwView
diff --git a/novelwriter/gui/sidebar.py b/novelwriter/gui/sidebar.py
index 9497444d..00111af8 100644
--- a/novelwriter/gui/sidebar.py
+++ b/novelwriter/gui/sidebar.py
@@ -67,6 +67,10 @@ class GuiSideBar(QWidget):
self.tbNovel.setToolTip("{0} [Ctrl+T]".format(self.tr("Novel Tree View")))
self.tbNovel.clicked.connect(lambda: self.viewChangeRequested.emit(nwView.NOVEL))
+ self.tbSearch = NIconToolButton(self, iPx)
+ self.tbSearch.setToolTip("{0} [Ctrl+Shift+F]".format(self.tr("Search Project")))
+ self.tbSearch.clicked.connect(lambda: self.viewChangeRequested.emit(nwView.SEARCH))
+
self.tbOutline = NIconToolButton(self, iPx)
self.tbOutline.setToolTip("{0} [Ctrl+Shift+T]".format(self.tr("Novel Outline View")))
self.tbOutline.clicked.connect(lambda: self.viewChangeRequested.emit(nwView.OUTLINE))
@@ -99,6 +103,7 @@ class GuiSideBar(QWidget):
self.outerBox = QVBoxLayout()
self.outerBox.addWidget(self.tbProject)
self.outerBox.addWidget(self.tbNovel)
+ self.outerBox.addWidget(self.tbSearch)
self.outerBox.addWidget(self.tbOutline)
self.outerBox.addWidget(self.tbBuild)
self.outerBox.addStretch(1)
@@ -129,6 +134,9 @@ class GuiSideBar(QWidget):
self.tbNovel.setIcon(SHARED.theme.getIcon("view_novel"))
self.tbNovel.setStyleSheet(buttonStyle)
+ self.tbSearch.setIcon(SHARED.theme.getIcon("view_search"))
+ self.tbSearch.setStyleSheet(buttonStyle)
+
self.tbOutline.setIcon(SHARED.theme.getIcon("view_outline"))
self.tbOutline.setStyleSheet(buttonStyle)
diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py
index 8ffe396e..0342b57f 100644
--- a/novelwriter/gui/theme.py
+++ b/novelwriter/gui/theme.py
@@ -486,7 +486,7 @@ class GuiIcons:
"build_excluded", "build_filtered", "build_included", "proj_chapter", "proj_details",
"proj_document", "proj_folder", "proj_note", "proj_nwx", "proj_section", "proj_scene",
"proj_stats", "proj_title", "status_idle", "status_lang", "status_lines", "status_stats",
- "status_time", "view_build", "view_editor", "view_novel", "view_outline",
+ "status_time", "view_build", "view_editor", "view_novel", "view_outline", "view_search",
# Class Icons
"cls_archive", "cls_character", "cls_custom", "cls_entity", "cls_none", "cls_novel",
From 9ddbe2f7892553c9a4f87bf4d40c38dd3082c856 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 21 Mar 2024 23:04:47 +0100
Subject: [PATCH 02/14] Add basic global search widget
---
novelwriter/gui/mainmenu.py | 11 ++++-
novelwriter/gui/outline.py | 61 ++++++++++++++++-------
novelwriter/gui/projtree.py | 3 +-
novelwriter/gui/search.py | 99 +++++++++++++++++++++++++++++++++++++
novelwriter/gui/sidebar.py | 10 ++--
novelwriter/gui/theme.py | 2 +
novelwriter/guimain.py | 46 +++++++++--------
7 files changed, 186 insertions(+), 46 deletions(-)
create mode 100644 novelwriter/gui/search.py
diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py
index 98f1fb9d..4a9c198d 100644
--- a/novelwriter/gui/mainmenu.py
+++ b/novelwriter/gui/mainmenu.py
@@ -33,9 +33,9 @@ from PyQt5.QtCore import QUrl, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QMenuBar, QAction
from novelwriter import CONFIG, SHARED
-from novelwriter.enum import nwDocAction, nwDocInsert, nwWidget
from novelwriter.common import openExternalPath
from novelwriter.constants import nwConst, trConst, nwKeyWords, nwLabels, nwUnicode
+from novelwriter.enum import nwDocAction, nwDocInsert, nwView, nwWidget
from novelwriter.extensions.eventfilters import StatusTipFilter
if TYPE_CHECKING: # pragma: no cover
@@ -55,6 +55,7 @@ class GuiMainMenu(QMenuBar):
requestDocInsertText = pyqtSignal(str)
requestDocKeyWordInsert = pyqtSignal(str)
requestFocusChange = pyqtSignal(nwWidget)
+ requestViewChange = pyqtSignal(nwView)
def __init__(self, mainGui: GuiMain) -> None:
super().__init__(parent=mainGui)
@@ -861,6 +862,14 @@ class GuiMainMenu(QMenuBar):
self.aReplaceNext.setShortcut("Ctrl+Shift+1")
self.aReplaceNext.triggered.connect(lambda: self.mainGui.docEditor.replaceNext())
+ # Search > Separator
+ self.srcMenu.addSeparator()
+
+ # Search > Find in Project
+ self.aFindProj = self.srcMenu.addAction(self.tr("Find in Project"))
+ self.aFindProj.setShortcut("Ctrl+Shift+F")
+ self.aFindProj.triggered.connect(lambda: self.requestViewChange.emit(nwView.SEARCH))
+
return
def _buildToolsMenu(self) -> None:
diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py
index 35247799..8bb7cc0c 100644
--- a/novelwriter/gui/outline.py
+++ b/novelwriter/gui/outline.py
@@ -798,14 +798,20 @@ class GuiOutlineDetails(QScrollArea):
hSpace = int(CONFIG.pxInt(10))
vSpace = int(CONFIG.pxInt(4))
+ bFont = SHARED.theme.guiFontB
+
# Details Area
- self.titleLabel = QLabel("%s" % self.tr("Title"))
- self.fileLabel = QLabel("%s" % self.tr("Document"))
- self.itemLabel = QLabel("%s" % self.tr("Status"))
+ self.titleLabel = QLabel(self.tr("Title"))
+ self.fileLabel = QLabel(self.tr("Document"))
+ self.itemLabel = QLabel(self.tr("Status"))
self.titleValue = QLabel("")
self.fileValue = QLabel("")
self.itemValue = QLabel("")
+ self.titleLabel.setFont(bFont)
+ self.fileLabel.setFont(bFont)
+ self.itemLabel.setFont(bFont)
+
self.titleValue.setMinimumWidth(minTitle)
self.titleValue.setMaximumWidth(maxTitle)
self.fileValue.setMinimumWidth(minTitle)
@@ -814,13 +820,17 @@ class GuiOutlineDetails(QScrollArea):
self.itemValue.setMaximumWidth(maxTitle)
# Stats Area
- self.cCLabel = QLabel("%s" % self.tr("Characters"))
- self.wCLabel = QLabel("%s" % self.tr("Words"))
- self.pCLabel = QLabel("%s" % self.tr("Paragraphs"))
+ self.cCLabel = QLabel(self.tr("Characters"))
+ self.wCLabel = QLabel(self.tr("Words"))
+ self.pCLabel = QLabel(self.tr("Paragraphs"))
self.cCValue = QLabel("")
self.wCValue = QLabel("")
self.pCValue = QLabel("")
+ self.cCLabel.setFont(bFont)
+ self.wCLabel.setFont(bFont)
+ self.pCLabel.setFont(bFont)
+
self.cCValue.setMinimumWidth(wCount)
self.wCValue.setMinimumWidth(wCount)
self.pCValue.setMinimumWidth(wCount)
@@ -829,23 +839,36 @@ class GuiOutlineDetails(QScrollArea):
self.pCValue.setAlignment(Qt.AlignRight)
# Synopsis
- self.synopLabel = QLabel("%s" % self.tr("Synopsis"))
+ self.synopLabel = QLabel(self.tr("Synopsis"))
+ self.synopLabel.setFont(bFont)
+
self.synopValue = QLabel("")
- self.synopLWrap = QHBoxLayout()
self.synopValue.setWordWrap(True)
self.synopValue.setAlignment(Qt.AlignTop | Qt.AlignLeft)
+
+ self.synopLWrap = QHBoxLayout()
self.synopLWrap.addWidget(self.synopValue, 1)
# Tags
- self.povKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY]))
- self.focKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY]))
- self.chrKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY]))
- self.pltKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY]))
- self.timKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY]))
- self.wldKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY]))
- self.objKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY]))
- self.entKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY]))
- self.cstKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY]))
+ self.povKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY]))
+ self.focKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY]))
+ self.chrKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY]))
+ self.pltKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY]))
+ self.timKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY]))
+ self.wldKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY]))
+ self.objKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY]))
+ self.entKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY]))
+ self.cstKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY]))
+
+ self.povKeyLabel.setFont(bFont)
+ self.focKeyLabel.setFont(bFont)
+ self.chrKeyLabel.setFont(bFont)
+ self.pltKeyLabel.setFont(bFont)
+ self.timKeyLabel.setFont(bFont)
+ self.wldKeyLabel.setFont(bFont)
+ self.objKeyLabel.setFont(bFont)
+ self.entKeyLabel.setFont(bFont)
+ self.cstKeyLabel.setFont(bFont)
self.povKeyLWrap = QHBoxLayout()
self.focKeyLWrap = QHBoxLayout()
@@ -989,7 +1012,7 @@ class GuiOutlineDetails(QScrollArea):
def clearDetails(self) -> None:
"""Clear all the data labels."""
- self.titleLabel.setText("%s" % self.tr("Title"))
+ self.titleLabel.setText(self.tr("Title"))
self.titleValue.setText("")
self.fileValue.setText("")
self.itemValue.setText("")
@@ -1023,7 +1046,7 @@ class GuiOutlineDetails(QScrollArea):
novIdx = pIndex.getItemHeading(tHandle, sTitle)
novRefs = pIndex.getReferences(tHandle, sTitle)
if nwItem and novIdx:
- self.titleLabel.setText("%s" % self.tr(self.LVL_MAP.get(novIdx.level, "H1")))
+ self.titleLabel.setText(self.tr(self.LVL_MAP.get(novIdx.level, "H1")))
self.titleValue.setText(novIdx.title)
itemStatus, _ = nwItem.getImportStatus(incIcon=False)
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 7299aff0..d78b84d5 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -270,7 +270,8 @@ class GuiProjectToolBar(QWidget):
self.setAutoFillBackground(True)
# Widget Label
- self.viewLabel = QLabel("%s" % self.tr("Project Content"))
+ self.viewLabel = QLabel(self.tr("Project Content"))
+ self.viewLabel.setFont(SHARED.theme.guiFontB)
self.viewLabel.setContentsMargins(0, 0, 0, 0)
self.viewLabel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
new file mode 100644
index 00000000..70b84de8
--- /dev/null
+++ b/novelwriter/gui/search.py
@@ -0,0 +1,99 @@
+"""
+novelWriter – GUI Project Search
+================================
+
+File History:
+Created: 2024-03-21 [2.4b1] GuiProjectSearch
+
+This file is a part of novelWriter
+Copyright 2018–2024, Veronica Berglyd Olsen
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+from __future__ import annotations
+
+import logging
+
+from PyQt5.QtGui import QPalette
+from PyQt5.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QTreeWidget, QVBoxLayout, QWidget
+
+from novelwriter import CONFIG, SHARED
+from novelwriter.extensions.modified import NIconToolButton
+from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
+
+logger = logging.getLogger(__name__)
+
+
+class GuiProjectSearch(QWidget):
+
+ def __init__(self, parent: QWidget) -> None:
+ super().__init__(parent=parent)
+
+ logger.debug("Create: GuiProjectSearch")
+
+ iPx = SHARED.theme.baseIconSize
+ mPx = CONFIG.pxInt(2)
+
+ # Header
+ self.viewLabel = QLabel(self.tr("Project Search"))
+ self.viewLabel.setFont(SHARED.theme.guiFontB)
+ self.viewLabel.setContentsMargins(mPx, mPx, 0, mPx)
+
+ # Controls
+ self.searchBox = QLineEdit(self)
+ self.searchBox.setPlaceholderText(self.tr("Search text ..."))
+
+ self.searchButton = NIconToolButton(self, iPx)
+
+ self.searchBar = QHBoxLayout()
+ self.searchBar.addWidget(self.searchBox)
+ self.searchBar.addWidget(self.searchButton)
+
+ # Search Result
+ self.searchResult = QTreeWidget(self)
+ self.searchResult.setHeaderHidden(True)
+
+ # Assemble
+ self.outerBox = QVBoxLayout()
+ self.outerBox.addWidget(self.viewLabel, 0)
+ self.outerBox.addLayout(self.searchBar, 0)
+ self.outerBox.addWidget(self.searchResult, 1)
+ self.outerBox.setContentsMargins(0, 0, 0, 0)
+ self.outerBox.setSpacing(0)
+
+ self.setLayout(self.outerBox)
+ self.updateTheme()
+
+ logger.debug("Ready: GuiProjectSearch")
+
+ return
+
+ ##
+ # Methods
+ ##
+
+ def updateTheme(self) -> None:
+ """Update theme elements."""
+ qPalette = self.palette()
+ 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"))
+
+ return
+
+# END Class GuiProjectSearch
diff --git a/novelwriter/gui/sidebar.py b/novelwriter/gui/sidebar.py
index 00111af8..dfc936c5 100644
--- a/novelwriter/gui/sidebar.py
+++ b/novelwriter/gui/sidebar.py
@@ -45,7 +45,7 @@ logger = logging.getLogger(__name__)
class GuiSideBar(QWidget):
- viewChangeRequested = pyqtSignal(nwView)
+ requestViewChange = pyqtSignal(nwView)
def __init__(self, mainGui: GuiMain) -> None:
super().__init__(parent=mainGui)
@@ -61,19 +61,19 @@ class GuiSideBar(QWidget):
# Buttons
self.tbProject = NIconToolButton(self, iPx)
self.tbProject.setToolTip("{0} [Ctrl+T]".format(self.tr("Project Tree View")))
- self.tbProject.clicked.connect(lambda: self.viewChangeRequested.emit(nwView.PROJECT))
+ self.tbProject.clicked.connect(lambda: self.requestViewChange.emit(nwView.PROJECT))
self.tbNovel = NIconToolButton(self, iPx)
self.tbNovel.setToolTip("{0} [Ctrl+T]".format(self.tr("Novel Tree View")))
- self.tbNovel.clicked.connect(lambda: self.viewChangeRequested.emit(nwView.NOVEL))
+ self.tbNovel.clicked.connect(lambda: self.requestViewChange.emit(nwView.NOVEL))
self.tbSearch = NIconToolButton(self, iPx)
self.tbSearch.setToolTip("{0} [Ctrl+Shift+F]".format(self.tr("Search Project")))
- self.tbSearch.clicked.connect(lambda: self.viewChangeRequested.emit(nwView.SEARCH))
+ self.tbSearch.clicked.connect(lambda: self.requestViewChange.emit(nwView.SEARCH))
self.tbOutline = NIconToolButton(self, iPx)
self.tbOutline.setToolTip("{0} [Ctrl+Shift+T]".format(self.tr("Novel Outline View")))
- self.tbOutline.clicked.connect(lambda: self.viewChangeRequested.emit(nwView.OUTLINE))
+ self.tbOutline.clicked.connect(lambda: self.requestViewChange.emit(nwView.OUTLINE))
self.tbBuild = NIconToolButton(self, iPx)
self.tbBuild.setToolTip("{0} [F5]".format(self.tr("Build Manuscript")))
diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py
index 0342b57f..f40642c4 100644
--- a/novelwriter/gui/theme.py
+++ b/novelwriter/gui/theme.py
@@ -152,6 +152,8 @@ class GuiTheme:
# Fonts
self.guiFont = qApp.font()
+ self.guiFontB = qApp.font()
+ self.guiFontB.setBold(True)
qMetric = QFontMetrics(self.guiFont)
self.fontPointSize = self.guiFont.pointSizeF()
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 21db48b6..5983b2dc 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -38,33 +38,31 @@ from PyQt5.QtWidgets import (
)
from novelwriter import CONFIG, SHARED, __hexversion__, __version__
+from novelwriter.common import formatFileFilter, formatVersion, hexToInt
from novelwriter.constants import nwConst
-from novelwriter.gui.theme import GuiTheme
-from novelwriter.gui.sidebar import GuiSideBar
-from novelwriter.gui.outline import GuiOutlineView
-from novelwriter.gui.mainmenu import GuiMainMenu
-from novelwriter.gui.projtree import GuiProjectView
-from novelwriter.gui.doceditor import GuiDocEditor
-from novelwriter.gui.docviewer import GuiDocViewer
-from novelwriter.gui.noveltree import GuiNovelView
-from novelwriter.gui.statusbar import GuiMainStatus
-from novelwriter.gui.itemdetails import GuiItemDetails
-from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
from novelwriter.dialogs.about import GuiAbout
-from novelwriter.dialogs.wordlist import GuiWordList
from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.projectsettings import GuiProjectSettings
-from novelwriter.tools.welcome import GuiWelcome
-from novelwriter.tools.manuscript import GuiManuscript
+from novelwriter.dialogs.wordlist import GuiWordList
+from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemType, nwWidget, nwView
+from novelwriter.gui.doceditor import GuiDocEditor
+from novelwriter.gui.docviewer import GuiDocViewer
+from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
+from novelwriter.gui.itemdetails import GuiItemDetails
+from novelwriter.gui.mainmenu import GuiMainMenu
+from novelwriter.gui.noveltree import GuiNovelView
+from novelwriter.gui.outline import GuiOutlineView
+from novelwriter.gui.projtree import GuiProjectView
+from novelwriter.gui.search import GuiProjectSearch
+from novelwriter.gui.sidebar import GuiSideBar
+from novelwriter.gui.statusbar import GuiMainStatus
+from novelwriter.gui.theme import GuiTheme
from novelwriter.tools.dictionaries import GuiDictionaries
+from novelwriter.tools.manuscript import GuiManuscript
from novelwriter.tools.noveldetails import GuiNovelDetails
+from novelwriter.tools.welcome import GuiWelcome
from novelwriter.tools.writingstats import GuiWritingStats
-from novelwriter.enum import (
- nwDocAction, nwDocInsert, nwDocMode, nwItemType, nwWidget, nwView
-)
-from novelwriter.common import formatFileFilter, formatVersion, hexToInt
-
logger = logging.getLogger(__name__)
@@ -123,6 +121,7 @@ class GuiMain(QMainWindow):
# Main GUI Elements
self.mainStatus = GuiMainStatus(self)
self.projView = GuiProjectView(self)
+ self.projSearch = GuiProjectSearch(self)
self.novelView = GuiNovelView(self)
self.docEditor = GuiDocEditor(self)
self.docViewer = GuiDocViewer(self)
@@ -136,6 +135,7 @@ class GuiMain(QMainWindow):
self.projStack = QStackedWidget(self)
self.projStack.addWidget(self.projView)
self.projStack.addWidget(self.novelView)
+ self.projStack.addWidget(self.projSearch)
self.projStack.currentChanged.connect(self._projStackChanged)
# Project Tree View
@@ -190,6 +190,7 @@ class GuiMain(QMainWindow):
self.idxOutlineView = self.mainStack.indexOf(self.outlineView)
self.idxProjView = self.projStack.indexOf(self.projView)
self.idxNovelView = self.projStack.indexOf(self.novelView)
+ self.idxProjSearch = self.projStack.indexOf(self.projSearch)
# Splitter Behaviour
self.splitMain.setCollapsible(self.idxTree, False)
@@ -243,8 +244,9 @@ class GuiMain(QMainWindow):
self.mainMenu.requestDocInsertText.connect(self._passDocumentInsert)
self.mainMenu.requestDocKeyWordInsert.connect(self.docEditor.insertKeyWord)
self.mainMenu.requestFocusChange.connect(self.switchFocus)
+ self.mainMenu.requestViewChange.connect(self._changeView)
- self.sideBar.viewChangeRequested.connect(self._changeView)
+ self.sideBar.requestViewChange.connect(self._changeView)
self.projView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
self.projView.openDocumentRequest.connect(self._openDocument)
@@ -1077,6 +1079,7 @@ class GuiMain(QMainWindow):
self.sideBar.updateTheme()
self.projView.updateTheme()
self.novelView.updateTheme()
+ self.projSearch.updateTheme()
self.outlineView.updateTheme()
self.itemDetails.updateTheme()
self.mainStatus.updateTheme()
@@ -1168,6 +1171,9 @@ class GuiMain(QMainWindow):
elif view == nwView.NOVEL:
self.mainStack.setCurrentWidget(self.splitMain)
self.projStack.setCurrentWidget(self.novelView)
+ elif view == nwView.SEARCH:
+ self.mainStack.setCurrentWidget(self.splitMain)
+ self.projStack.setCurrentWidget(self.projSearch)
elif view == nwView.OUTLINE:
self.mainStack.setCurrentWidget(self.outlineView)
return
From 9153707075997c9af21443f5088d6ef20370d41c Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sat, 23 Mar 2024 23:54:41 +0100
Subject: [PATCH 03/14] Add basic string search
---
novelwriter/core/coretools.py | 28 +++++++++++++++++++++
novelwriter/gui/search.py | 47 ++++++++++++++++++++++++++++++++---
2 files changed, 71 insertions(+), 4 deletions(-)
diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py
index a4e3e3aa..9e87e595 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -304,6 +304,34 @@ class DocDuplicator:
# END Class DocDuplicator
+class DocSearch:
+
+ def __init__(self, project: NWProject) -> None:
+ self._project = project
+ return
+
+ def iterSearch(self, search: str) -> Iterable[tuple[NWItem, list[tuple[int, int, str]]]]:
+ """Iteratively search through documents in the project."""
+ num = len(search)
+ cap = min(num+100, 100)
+ storage = self._project.storage
+ for item in self._project.tree:
+ if item.isFileType():
+ text = storage.getDocument(item.itemHandle).readDocument() or ""
+ prev = 0
+ results = []
+ count = 0
+ while (pos := text.find(search, prev)) >= 0 and count < 100:
+ count += 1
+ context = text[pos:pos+cap].partition("\n")[0]
+ results.append((pos, num, context))
+ prev = pos + num
+ yield item, results
+ return
+
+# END Class DocSearch
+
+
class ProjectBuilder:
"""A class to build a new project from a set of user-defined
parameter provided by the New Project Wizard.
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index 70b84de8..72826e85 100644
--- a/novelwriter/gui/search.py
+++ b/novelwriter/gui/search.py
@@ -25,10 +25,16 @@ from __future__ import annotations
import logging
+from PyQt5.QtCore import pyqtSlot
from PyQt5.QtGui import QPalette
-from PyQt5.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QTreeWidget, QVBoxLayout, QWidget
+from PyQt5.QtWidgets import (
+ QHBoxLayout, QLabel, QLineEdit, 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
@@ -51,13 +57,14 @@ class GuiProjectSearch(QWidget):
self.viewLabel.setContentsMargins(mPx, mPx, 0, mPx)
# Controls
- self.searchBox = QLineEdit(self)
- self.searchBox.setPlaceholderText(self.tr("Search text ..."))
+ 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.searchBox)
+ self.searchBar.addWidget(self.searchText)
self.searchBar.addWidget(self.searchButton)
# Search Result
@@ -96,4 +103,36 @@ class GuiProjectSearch(QWidget):
return
+ ##
+ # Private Slots
+ ##
+
+ @pyqtSlot()
+ def _processSearch(self) -> None:
+ """Perform a search."""
+ self.searchResult.clear()
+ if text := self.searchText.text():
+ search = DocSearch(SHARED.project)
+ for item, results in search.iterSearch(text):
+ self._appendResultSet(item, results)
+ return
+
+ ##
+ # Internal Functions
+ ##
+
+ def _appendResultSet(self, item: NWItem, results: list[tuple[int, int, str]]) -> None:
+ """Populate the result tree."""
+ if results:
+ tItem = QTreeWidgetItem()
+ tItem.setText(0, f"{item.itemName} ({len(results)})")
+ rItems = []
+ for start, end, context in results:
+ rItem = QTreeWidgetItem()
+ rItem.setText(0, context)
+ rItems.append(rItem)
+ tItem.addChildren(rItems)
+ self.searchResult.addTopLevelItem(tItem)
+ return
+
# END Class GuiProjectSearch
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 04/14] 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
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 05/14] 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"
From a4d8c22cd0f42dee422ae3f377136de9f7dda29f Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Sun, 24 Mar 2024 16:24:48 +0100
Subject: [PATCH 06/14] Add better keyboard navigation support
---
novelwriter/gui/search.py | 47 +++++++++++++++++++++++++++++++++++----
novelwriter/guimain.py | 8 ++++---
2 files changed, 48 insertions(+), 7 deletions(-)
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index 674008c4..aaf60c12 100644
--- a/novelwriter/gui/search.py
+++ b/novelwriter/gui/search.py
@@ -26,7 +26,7 @@ from __future__ import annotations
import logging
from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot
-from PyQt5.QtGui import QPalette
+from PyQt5.QtGui import QKeyEvent, QPalette
from PyQt5.QtWidgets import (
QHBoxLayout, QLabel, QLineEdit, QToolBar, QTreeWidget, QTreeWidgetItem,
QVBoxLayout, QWidget
@@ -45,7 +45,7 @@ class GuiProjectSearch(QWidget):
D_HANDLE = Qt.ItemDataRole.UserRole
D_RESULT = Qt.ItemDataRole.UserRole + 1
- openDocumentSelectRequest = pyqtSignal(str, int, int)
+ openDocumentSelectRequest = pyqtSignal(str, int, int, bool)
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
@@ -91,6 +91,7 @@ class GuiProjectSearch(QWidget):
self.searchResult.setIconSize(QSize(iPx, iPx))
self.searchResult.setIndentation(iPx)
self.searchResult.itemPressed.connect(self._searchResultSelected)
+ self.searchResult.itemDoubleClicked.connect(self._searchResultDoubleClicked)
# Assemble
self.headerBox = QHBoxLayout()
@@ -130,9 +131,38 @@ class GuiProjectSearch(QWidget):
return
def processReturn(self) -> None:
- """Process a return key press forwarded from main GUI."""
+ """Process a return keypress forwarded from the main GUI."""
if self.searchText.hasFocus():
self._processSearch()
+ elif items := self.searchResult.selectedItems():
+ self._searchResultSelected(items[0], 0)
+ return
+
+ ##
+ # Events
+ ##
+
+ def keyPressEvent(self, event: QKeyEvent) -> None:
+ """Process key press events. This handles up and down arrow key
+ presses to jump between search text box and result tree.
+ """
+ if (
+ event.key() == Qt.Key.Key_Down
+ and self.searchText.hasFocus()
+ and (first := self.searchResult.topLevelItem(0))
+ ):
+ first.setSelected(True)
+ self.searchResult.setFocus()
+ elif (
+ event.key() == Qt.Key.Key_Up
+ and self.searchResult.hasFocus()
+ and (first := self.searchResult.topLevelItem(0))
+ and first.isSelected()
+ ):
+ first.setSelected(False)
+ self.searchText.setFocus()
+ else:
+ super().keyPressEvent(event)
return
##
@@ -157,7 +187,16 @@ class GuiProjectSearch(QWidget):
"""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)
+ str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1), False
+ )
+ return
+
+ @pyqtSlot("QTreeWidgetItem*", int)
+ def _searchResultDoubleClicked(self, item: QTreeWidgetItem, column: int) -> None:
+ """Process search result double click."""
+ 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), True
)
return
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index c247c218..e51aa7f2 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -1152,10 +1152,12 @@ class GuiMain(QMainWindow):
self.viewDocument(tHandle=tHandle, sTitle=sTitle)
return
- @pyqtSlot(str, int, int)
- def _openDocumentSelection(self, tHandle: str, selStart: int, selLength: int) -> None:
+ @pyqtSlot(str, int, int, bool)
+ def _openDocumentSelection(
+ self, tHandle: str, selStart: int, selLength: int, changeFocus: bool
+ ) -> None:
"""Open a document and select a section of the text."""
- if self.openDocument(tHandle):
+ if self.openDocument(tHandle, changeFocus=changeFocus):
self.docEditor.setCursorSelection(selStart, selLength)
return
From 33e0895cdfcbb930750adf22d706a538c64bf85c Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 25 Mar 2024 11:44:56 +0100
Subject: [PATCH 07/14] Improve handling of focus switch with search widget
---
novelwriter/core/coretools.py | 5 ++-
novelwriter/gui/search.py | 6 ++++
novelwriter/guimain.py | 47 ++++++++++------------------
tests/test_gui/test_gui_noveltree.py | 2 +-
4 files changed, 26 insertions(+), 34 deletions(-)
diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py
index 7ecd04d7..d007a771 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -318,10 +318,9 @@ class DocSearch:
def iterSearch(self, search: str) -> Iterable[tuple[NWItem, list[tuple[int, int, str]]]]:
"""Iteratively search through documents in the project."""
num = len(search)
- cap = min(num+100, 100)
storage = self._project.storage
regEx = QRegularExpression(self._buildPattern(search), self._rxOpts)
- print(regEx.pattern())
+ logger.debug("Searching with pattern '%s'", regEx.pattern())
for item in self._project.tree:
if item.isFileType():
text = storage.getDocument(item.itemHandle).readDocument() or ""
@@ -331,7 +330,7 @@ class DocSearch:
rxMatch = rxItt.next()
pos = rxMatch.capturedStart()
num = rxMatch.capturedLength()
- context = text[pos:pos+cap].partition("\n")[0]
+ context = text[pos:pos+100].partition("\n")[0]
results.append((pos, num, context))
yield item, results
return
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index aaf60c12..a91c5a4e 100644
--- a/novelwriter/gui/search.py
+++ b/novelwriter/gui/search.py
@@ -138,6 +138,12 @@ class GuiProjectSearch(QWidget):
self._searchResultSelected(items[0], 0)
return
+ def beginSearch(self) -> None:
+ """Focus the search box and select its text, if any."""
+ self.searchText.setFocus()
+ self.searchText.selectAll()
+ return
+
##
# Events
##
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index e51aa7f2..77293e0d 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -154,6 +154,8 @@ class GuiMain(QMainWindow):
self.splitView.setHandleWidth(hWd)
self.splitView.setOpaqueResize(False)
self.splitView.setSizes(CONFIG.viewPanePos)
+ self.splitView.setCollapsible(0, False)
+ self.splitView.setCollapsible(1, False)
# Splitter : Document Editor / Document Viewer
self.splitDocs = QSplitter(Qt.Horizontal, self)
@@ -161,6 +163,8 @@ class GuiMain(QMainWindow):
self.splitDocs.addWidget(self.splitView)
self.splitDocs.setOpaqueResize(False)
self.splitDocs.setHandleWidth(hWd)
+ self.splitDocs.setCollapsible(0, False)
+ self.splitDocs.setCollapsible(1, False)
# Splitter : Project Tree / Document Area
self.splitMain = QSplitter(Qt.Horizontal)
@@ -170,6 +174,10 @@ class GuiMain(QMainWindow):
self.splitMain.setOpaqueResize(False)
self.splitMain.setHandleWidth(hWd)
self.splitMain.setSizes(CONFIG.mainPanePos)
+ self.splitMain.setCollapsible(0, False)
+ self.splitMain.setCollapsible(0, False)
+ self.splitMain.setStretchFactor(1, 0)
+ self.splitMain.setStretchFactor(1, 1)
# Main Stack : Editor / Outline
self.mainStack = QStackedWidget(self)
@@ -177,32 +185,6 @@ class GuiMain(QMainWindow):
self.mainStack.addWidget(self.outlineView)
self.mainStack.currentChanged.connect(self._mainStackChanged)
- # Indices of Splitter Widgets
- self.idxTree = self.splitMain.indexOf(self.treePane)
- self.idxMain = self.splitMain.indexOf(self.splitDocs)
- self.idxEditor = self.splitDocs.indexOf(self.docEditor)
- self.idxViewer = self.splitDocs.indexOf(self.splitView)
- self.idxViewDoc = self.splitView.indexOf(self.docViewer)
- self.idxViewDocPanel = self.splitView.indexOf(self.docViewerPanel)
-
- # Indices of Stack Widgets
- self.idxEditorView = self.mainStack.indexOf(self.splitMain)
- self.idxOutlineView = self.mainStack.indexOf(self.outlineView)
- self.idxProjView = self.projStack.indexOf(self.projView)
- self.idxNovelView = self.projStack.indexOf(self.novelView)
- self.idxProjSearch = self.projStack.indexOf(self.projSearch)
-
- # Splitter Behaviour
- self.splitMain.setCollapsible(self.idxTree, False)
- self.splitMain.setCollapsible(self.idxMain, False)
- self.splitDocs.setCollapsible(self.idxEditor, False)
- self.splitDocs.setCollapsible(self.idxViewer, False)
- self.splitView.setCollapsible(self.idxViewDoc, False)
- self.splitView.setCollapsible(self.idxViewDocPanel, False)
-
- self.splitMain.setStretchFactor(self.idxTree, 0)
- self.splitMain.setStretchFactor(self.idxMain, 1)
-
# Editor / Viewer Default State
self.splitView.setVisible(False)
self.docEditor.closeSearch()
@@ -1035,12 +1017,15 @@ class GuiMain(QMainWindow):
self.novelView.setTreeFocus()
else:
self.projView.setTreeFocus()
- else:
+ elif self.projStack.currentWidget() is self.novelView:
if self.novelView.treeHasFocus():
self._changeView(nwView.PROJECT)
self.projView.setTreeFocus()
else:
self.novelView.setTreeFocus()
+ else:
+ self._changeView(nwView.PROJECT)
+ self.projView.setTreeFocus()
elif paneNo == nwWidget.EDITOR:
self._changeView(nwView.EDITOR)
self.docEditor.setFocus()
@@ -1185,6 +1170,7 @@ class GuiMain(QMainWindow):
elif view == nwView.SEARCH:
self.mainStack.setCurrentWidget(self.splitMain)
self.projStack.setCurrentWidget(self.projSearch)
+ self.projSearch.beginSearch()
elif view == nwView.OUTLINE:
self.mainStack.setCurrentWidget(self.outlineView)
return
@@ -1293,7 +1279,7 @@ class GuiMain(QMainWindow):
@pyqtSlot(int)
def _mainStackChanged(self, index: int) -> None:
"""Process main window tab change."""
- if index == self.idxOutlineView:
+ if self.mainStack.widget(index) == self.outlineView:
if SHARED.hasProject:
self.outlineView.refreshTree()
return
@@ -1302,9 +1288,10 @@ class GuiMain(QMainWindow):
def _projStackChanged(self, index: int) -> None:
"""Process project view tab change."""
sHandle = None
- if index == self.idxProjView:
+ widget = self.projStack.widget(index)
+ if widget == self.projView:
sHandle = self.projView.getSelectedHandle()
- elif index == self.idxNovelView:
+ elif widget == self.novelView:
sHandle, _ = self.novelView.getSelectedHandle()
self.itemDetails.updateViewBox(sHandle)
return
diff --git a/tests/test_gui/test_gui_noveltree.py b/tests/test_gui/test_gui_noveltree.py
index 25dfbf61..120b66be 100644
--- a/tests/test_gui/test_gui_noveltree.py
+++ b/tests/test_gui/test_gui_noveltree.py
@@ -85,7 +85,7 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
novelView.setTreeFocus()
- nwGUI.projStack.setCurrentIndex(nwGUI.idxNovelView)
+ nwGUI.projStack.setCurrentWidget(nwGUI.novelView)
nwGUI.rebuildIndex()
novelTree._populateTree(rootHandle=None)
assert novelTree.topLevelItemCount() == 3
From c6944876f1aaf129dcefb6e725c8ee8347911226 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 25 Mar 2024 11:59:05 +0100
Subject: [PATCH 08/14] Save project search toggle state
---
novelwriter/config.py | 26 +++++++++++++--------
novelwriter/gui/search.py | 24 +++++++++++++++++++
tests/reference/baseConfig_novelwriter.conf | 6 +++--
3 files changed, 44 insertions(+), 12 deletions(-)
diff --git a/novelwriter/config.py b/novelwriter/config.py
index 85786119..670433a0 100644
--- a/novelwriter/config.py
+++ b/novelwriter/config.py
@@ -183,17 +183,19 @@ class Config:
# State
self.showViewerPanel = True # The panel for the viewer is visible
self.showEditToolBar = False # The document editor toolbar visibility
- self.useShortcodes = False # Use shortcodes for basic formatting
self.viewComments = True # Comments are shown in the viewer
self.viewSynopsis = True # Synopsis is shown in the viewer
- # Search Bar Switches
- self.searchCase = False
- self.searchWord = False
- self.searchRegEx = False
- self.searchLoop = False
- self.searchNextFile = False
- self.searchMatchCap = False
+ # Search Box States
+ self.searchCase = False
+ self.searchWord = False
+ self.searchRegEx = False
+ self.searchLoop = False
+ self.searchNextFile = False
+ self.searchMatchCap = False
+ self.searchProjCase = False
+ self.searchProjWord = False
+ self.searchProjRegEx = False
# System and App Information
# ==========================
@@ -619,7 +621,6 @@ class Config:
sec = "State"
self.showViewerPanel = conf.rdBool(sec, "showviewerpanel", self.showViewerPanel)
self.showEditToolBar = conf.rdBool(sec, "showedittoolbar", self.showEditToolBar)
- self.useShortcodes = conf.rdBool(sec, "useshortcodes", self.useShortcodes)
self.viewComments = conf.rdBool(sec, "viewcomments", self.viewComments)
self.viewSynopsis = conf.rdBool(sec, "viewsynopsis", self.viewSynopsis)
self.searchCase = conf.rdBool(sec, "searchcase", self.searchCase)
@@ -628,6 +629,9 @@ class Config:
self.searchLoop = conf.rdBool(sec, "searchloop", self.searchLoop)
self.searchNextFile = conf.rdBool(sec, "searchnextfile", self.searchNextFile)
self.searchMatchCap = conf.rdBool(sec, "searchmatchcap", self.searchMatchCap)
+ self.searchProjCase = conf.rdBool(sec, "searchprojcase", self.searchProjCase)
+ self.searchProjWord = conf.rdBool(sec, "searchprojword", self.searchProjWord)
+ self.searchProjRegEx = conf.rdBool(sec, "searchprojregex", self.searchProjRegEx)
# Check Values
# ============
@@ -725,7 +729,6 @@ class Config:
conf["State"] = {
"showviewerpanel": str(self.showViewerPanel),
"showedittoolbar": str(self.showEditToolBar),
- "useshortcodes": str(self.useShortcodes),
"viewcomments": str(self.viewComments),
"viewsynopsis": str(self.viewSynopsis),
"searchcase": str(self.searchCase),
@@ -734,6 +737,9 @@ class Config:
"searchloop": str(self.searchLoop),
"searchnextfile": str(self.searchNextFile),
"searchmatchcap": str(self.searchMatchCap),
+ "searchprojcase": str(self.searchProjCase),
+ "searchprojword": str(self.searchProjWord),
+ "searchprojregex": str(self.searchProjRegEx),
}
# Write config file
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index a91c5a4e..d87d9ab4 100644
--- a/novelwriter/gui/search.py
+++ b/novelwriter/gui/search.py
@@ -68,12 +68,18 @@ class GuiProjectSearch(QWidget):
self.toggleCase = self.searchOpt.addAction(self.tr("Case Sensitive"))
self.toggleCase.setCheckable(True)
+ self.toggleCase.setChecked(CONFIG.searchProjCase)
+ self.toggleCase.toggled.connect(self._toggleCase)
self.toggleWord = self.searchOpt.addAction(self.tr("Whole Words Only"))
self.toggleWord.setCheckable(True)
+ self.toggleWord.setChecked(CONFIG.searchProjWord)
+ self.toggleWord.toggled.connect(self._toggleWord)
self.toggleRegEx = self.searchOpt.addAction(self.tr("RegEx Mode"))
self.toggleRegEx.setCheckable(True)
+ self.toggleRegEx.setChecked(CONFIG.searchProjRegEx)
+ self.toggleRegEx.toggled.connect(self._toggleRegEx)
# Search Box
self.searchText = QLineEdit(self)
@@ -206,6 +212,24 @@ class GuiProjectSearch(QWidget):
)
return
+ @pyqtSlot(bool)
+ def _toggleCase(self, state: bool) -> None:
+ """Enable/disable case sensitive mode."""
+ CONFIG.searchProjCase = state
+ return
+
+ @pyqtSlot(bool)
+ def _toggleWord(self, state: bool) -> None:
+ """Enable/disable whole word search mode."""
+ CONFIG.searchProjWord = state
+ return
+
+ @pyqtSlot(bool)
+ def _toggleRegEx(self, state: bool) -> None:
+ """Enable/disable regular expression search mode."""
+ CONFIG.searchProjRegEx = state
+ return
+
##
# Internal Functions
##
diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf
index 998394a0..4e218943 100644
--- a/tests/reference/baseConfig_novelwriter.conf
+++ b/tests/reference/baseConfig_novelwriter.conf
@@ -1,5 +1,5 @@
[Meta]
-timestamp = 2024-02-09 12:05:00
+timestamp = 2024-03-25 11:57:35
[Main]
theme = default
@@ -69,7 +69,6 @@ useridletime = 300
[State]
showviewerpanel = True
showedittoolbar = False
-useshortcodes = False
viewcomments = True
viewsynopsis = True
searchcase = False
@@ -78,4 +77,7 @@ searchregex = False
searchloop = False
searchnextfile = False
searchmatchcap = False
+searchprojcase = False
+searchprojword = False
+searchprojregex = False
From 16954c3bbad0263939b80444e1cb4be75471bb2a Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 25 Mar 2024 16:19:51 +0100
Subject: [PATCH 09/14] Add search caching
---
novelwriter/constants.py | 1 +
novelwriter/core/coretools.py | 80 ++++++++++++++++++++++++++++-------
novelwriter/gui/doceditor.py | 25 +++++++----
novelwriter/gui/search.py | 49 +++++++++++++++++----
novelwriter/guimain.py | 2 +
5 files changed, 125 insertions(+), 32 deletions(-)
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index 96e3db99..a1e2b84a 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -53,6 +53,7 @@ class nwConst:
# Gui Settings
STATUS_MSG_TIMEOUT = 15000 # milliseconds
+ MAX_SEARCH_RESULT = 1000
# Dialogs
DLG_FINISHED = 2
diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py
index d007a771..6492ed7d 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -306,25 +306,73 @@ class DocDuplicator:
class DocSearch:
- def __init__(self, project: NWProject, regEx: bool, doCase: bool, wholeWords: bool) -> None:
- self._project = project
- self._escape = not regEx
- self._words = wholeWords
- self._rxOpts = QRegularExpression.PatternOption.UseUnicodePropertiesOption
- if not doCase:
- self._rxOpts |= QRegularExpression.PatternOption.CaseInsensitiveOption
+ def __init__(self) -> None:
+ # RegEx Object
+ self._regEx = QRegularExpression()
+ self.setCaseSensitive(False)
+ self._words = False
+ self._escape = True
+
+ # Project Cache
+ self._uuid = ""
+ self._cache: dict[str, str] = {}
+
return
- def iterSearch(self, search: str) -> Iterable[tuple[NWItem, list[tuple[int, int, str]]]]:
- """Iteratively search through documents in the project."""
+ ##
+ # Methods
+ ##
+
+ def setCaseSensitive(self, state: bool) -> None:
+ """Set the case sensitive search flag."""
+ opts = QRegularExpression.PatternOption.UseUnicodePropertiesOption
+ if not state:
+ opts |= QRegularExpression.PatternOption.CaseInsensitiveOption
+ self._regEx.setPatternOptions(opts)
+ return
+
+ def setWholeWords(self, state: bool) -> None:
+ """Set the whole words search flag."""
+ self._words = state
+ return
+
+ def setUserRegEx(self, state: bool) -> None:
+ """Set the escape flag to the opposite state."""
+ self._escape = not state
+ return
+
+ def clearTextCache(self, tHandle: str | None) -> None:
+ """Clear text cache for a given item, or all items if None."""
+ if tHandle is None:
+ self._cache = {}
+ logger.debug("Search cache cleared")
+ elif tHandle in self._cache:
+ self._cache.pop(tHandle, None)
+ logger.debug("Search cache cleared for '%s'", tHandle)
+ return
+
+ def iterSearch(
+ self, project: NWProject, search: str
+ ) -> Iterable[tuple[NWItem, list[tuple[int, int, str]]]]:
+ """Iteratively search through documents in a project."""
+ if project.data.uuid != self._uuid:
+ self.clearTextCache(None)
+
+ self._uuid = project.data.uuid
+ self._regEx.setPattern(self._buildPattern(search))
+ logger.debug("Searching with pattern '%s'", self._regEx.pattern())
+
num = len(search)
- storage = self._project.storage
- regEx = QRegularExpression(self._buildPattern(search), self._rxOpts)
- logger.debug("Searching with pattern '%s'", regEx.pattern())
- for item in self._project.tree:
+ storage = project.storage
+ for item in project.tree:
if item.isFileType():
- text = storage.getDocument(item.itemHandle).readDocument() or ""
- rxItt = regEx.globalMatch(text)
+ tHandle = item.itemHandle
+ text = self._cache.get(tHandle)
+ if text is None:
+ text = storage.getDocument(tHandle).readDocument() or ""
+ self._cache[tHandle] = text
+
+ rxItt = self._regEx.globalMatch(text)
results = []
while rxItt.hasNext():
rxMatch = rxItt.next()
@@ -332,7 +380,9 @@ class DocSearch:
num = rxMatch.capturedLength()
context = text[pos:pos+100].partition("\n")[0]
results.append((pos, num, context))
+
yield item, results
+
return
##
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index 6f6edfb6..46a0d52e 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -54,7 +54,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, transferCase
-from novelwriter.constants import nwKeyWords, nwShortcode, nwUnicode
+from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
from novelwriter.core.document import NWDocument
from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary
from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -91,6 +91,7 @@ class GuiDocEditor(QPlainTextEdit):
# Custom Signals
statusMessage = pyqtSignal(str)
+ docTextSaved = pyqtSignal(str)
docCountsChanged = pyqtSignal(str, int, int, int)
editedStatusChanged = pyqtSignal(bool)
loadDocumentTagRequest = pyqtSignal(str, Enum)
@@ -488,6 +489,7 @@ class GuiDocEditor(QPlainTextEdit):
return False
self.setDocumentChanged(False)
+ self.docTextSaved.emit(tHandle)
oldHeader = self._nwItem.mainHeading
oldCount = SHARED.project.index.getHandleHeaderCount(tHandle)
@@ -603,13 +605,15 @@ class GuiDocEditor(QPlainTextEdit):
# Setters
##
- def setDocumentChanged(self, state: bool) -> bool:
+ def setDocumentChanged(self, state: bool) -> None:
"""Keep track of the document changed variable, and emit the
document change signal.
"""
- self._docChanged = state
- self.editedStatusChanged.emit(self._docChanged)
- return self._docChanged
+ if self._docChanged != state:
+ logger.debug("Document changed status is '%s'", state)
+ self._docChanged = state
+ self.editedStatusChanged.emit(self._docChanged)
+ return
def setCursorPosition(self, position: int) -> None:
"""Move the cursor to a given position in the document."""
@@ -1372,9 +1376,10 @@ class GuiDocEditor(QPlainTextEdit):
cursor.setPosition(0)
self.setTextCursor(cursor)
- # Search up to a maximum of 1000, and make sure certain special
- # searches like a regex search for .* don't loop infinitely
- while self.find(searchFor, findOpt) and len(resE) <= 1000:
+ # Search up to a maximum of MAX_SEARCH_RESULT, and make sure
+ # certain special searches like a regex search for .* don't loop
+ # infinitely
+ while self.find(searchFor, findOpt) and len(resE) <= nwConst.MAX_SEARCH_RESULT:
cursor = self.textCursor()
if cursor.hasSelection():
resS.append(cursor.selectionStart())
@@ -2613,8 +2618,10 @@ class GuiDocEditSearch(QFrame):
def setResultCount(self, currRes: int | None, resCount: int | None) -> None:
"""Set the count values for the current search."""
+ lim = nwConst.MAX_SEARCH_RESULT
+ numCount = f"{lim:n}+" if (resCount or 0) > lim else f"{resCount:n}"
sCurrRes = "?" if currRes is None else str(currRes)
- sResCount = "?" if resCount is None else "1000+" if resCount > 1000 else str(resCount)
+ sResCount = "?" if resCount is None else numCount
minWidth = SHARED.theme.getTextWidth(f"{sResCount}//{sResCount}", self.boxFont)
self.resultLabel.setText(f"{sCurrRes}/{sResCount}")
self.resultLabel.setMinimumWidth(minWidth)
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index d87d9ab4..600d28bd 100644
--- a/novelwriter/gui/search.py
+++ b/novelwriter/gui/search.py
@@ -29,11 +29,12 @@ 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
+ 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
@@ -55,6 +56,8 @@ class GuiProjectSearch(QWidget):
iPx = SHARED.theme.baseIconSize
mPx = CONFIG.pxInt(2)
+ self._search = DocSearch()
+
# Header
self.viewLabel = QLabel(self.tr("Project Search"))
self.viewLabel.setFont(SHARED.theme.guiFontB)
@@ -150,6 +153,12 @@ class GuiProjectSearch(QWidget):
self.searchText.selectAll()
return
+ def closeProjectTasks(self) -> None:
+ """Run close project tasks."""
+ self.searchText.clear()
+ self.searchResult.clear()
+ return
+
##
# Events
##
@@ -177,6 +186,16 @@ class GuiProjectSearch(QWidget):
super().keyPressEvent(event)
return
+ ##
+ # Public Slots
+ ##
+
+ @pyqtSlot(str)
+ def clearSearchCache(self, tHandle: str) -> None:
+ """Process document content change."""
+ self._search.clearTextCache(tHandle)
+ return
+
##
# Private Slots
##
@@ -186,11 +205,10 @@ class GuiProjectSearch(QWidget):
"""Perform a search."""
self.searchResult.clear()
if text := self.searchText.text():
- search = DocSearch(
- SHARED.project, self.toggleRegEx.isChecked(),
- self.toggleCase.isChecked(), self.toggleWord.isChecked()
- )
- for item, results in search.iterSearch(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)
return
@@ -234,6 +252,10 @@ 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:
"""Populate the result tree."""
if results:
@@ -242,20 +264,31 @@ 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}+"
tItem = QTreeWidgetItem()
- tItem.setText(0, f"{nwItem.itemName} ({len(results)})")
+ tItem.setText(0, f"{nwItem.itemName} ({numResult})")
tItem.setIcon(0, docIcon)
tItem.setData(0, self.D_HANDLE, tHandle)
+ self.searchResult.addTopLevelItem(tItem)
+
rItems = []
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)
+
+ qApp.processEvents()
+
return
# END Class GuiProjectSearch
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 77293e0d..256fead4 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -260,6 +260,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.docTextSaved.connect(self.projSearch.clearSearchCache)
self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle)
self.docViewer.loadDocumentTagRequest.connect(self._followTag)
@@ -394,6 +395,7 @@ class GuiMain(QMainWindow):
self.outlineView.closeProjectTasks()
self.novelView.closeProjectTasks()
self.projView.closeProjectTasks()
+ self.projSearch.closeProjectTasks()
self.itemDetails.clearDetails()
self.mainStatus.clearStatus()
From 01a4ec8d761ecfcde14b690e1fa740a975da4b5a Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 25 Mar 2024 17:37:18 +0100
Subject: [PATCH 10/14] Make some GUI improvements
---
novelwriter/core/coretools.py | 15 ++++++--
novelwriter/gui/search.py | 70 +++++++++++++++++++++++------------
novelwriter/guimain.py | 1 +
3 files changed, 59 insertions(+), 27 deletions(-)
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)
From 8a12b03a1fe5fa0927080b7cf5b933514a59e9bb Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 25 Mar 2024 18:13:05 +0100
Subject: [PATCH 11/14] Add clocks to shared class and add search cache
clearing
---
novelwriter/gui/search.py | 43 ++++++++++++++++++++++++---------
novelwriter/guimain.py | 26 ++++++++------------
novelwriter/shared.py | 14 ++++++++++-
novelwriter/tools/manuscript.py | 6 +----
4 files changed, 55 insertions(+), 34 deletions(-)
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index 9c3ed4ca..3bd28945 100644
--- a/novelwriter/gui/search.py
+++ b/novelwriter/gui/search.py
@@ -23,9 +23,10 @@ along with this program. If not, see .
"""
from __future__ import annotations
-import time
import logging
+from time import time
+
from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QKeyEvent, QPalette
from PyQt5.QtWidgets import (
@@ -40,6 +41,8 @@ from novelwriter.core.item import NWItem
logger = logging.getLogger(__name__)
+CACHE_TIMEOUT = 120.0 # 2 minutes
+
class GuiProjectSearch(QWidget):
@@ -50,8 +53,8 @@ class GuiProjectSearch(QWidget):
D_HANDLE = Qt.ItemDataRole.UserRole
D_RESULT = Qt.ItemDataRole.UserRole + 1
- openDocumentSelectRequest = pyqtSignal(str, int, int, bool)
selectedItemChanged = pyqtSignal(str)
+ openDocumentSelectRequest = pyqtSignal(str, int, int, bool)
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
@@ -61,7 +64,9 @@ class GuiProjectSearch(QWidget):
iPx = SHARED.theme.baseIconSize
mPx = CONFIG.pxInt(2)
+ self._time = time()
self._search = DocSearch()
+ self._blocked = False
# Header
self.viewLabel = QLabel(self.tr("Project Search"))
@@ -130,6 +135,8 @@ class GuiProjectSearch(QWidget):
self.setLayout(self.outerBox)
self.updateTheme()
+ SHARED.slowClockTick.connect(self._cacheTimeout)
+
logger.debug("Ready: GuiProjectSearch")
return
@@ -205,7 +212,8 @@ class GuiProjectSearch(QWidget):
@pyqtSlot(str)
def clearSearchCache(self, tHandle: str) -> None:
"""Process document content change."""
- self._search.clearTextCache(tHandle)
+ if not self._blocked:
+ self._search.clearTextCache(tHandle)
return
##
@@ -215,15 +223,19 @@ 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, capped in self._search.iterSearch(SHARED.project, text):
- self._appendResultSet(item, results, capped)
- logger.debug("Search took %.3f ms", 1000*(time.time() - start))
+ if not self._blocked:
+ start = time()
+ self._blocked = True
+ 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)
+ logger.debug("Search took %.3f ms", 1000*(time() - start))
+ self._time = time()
+ self._blocked = False
return
@pyqtSlot()
@@ -272,6 +284,13 @@ class GuiProjectSearch(QWidget):
CONFIG.searchProjRegEx = state
return
+ @pyqtSlot()
+ def _cacheTimeout(self) -> None:
+ """Clear the cache after a period of inactivity."""
+ if not self._blocked and time() - self._time > CACHE_TIMEOUT:
+ self._search.clearTextCache(None)
+ return
+
##
# Internal Functions
##
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 49623cd4..978b1581 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -220,6 +220,7 @@ class GuiMain(QMainWindow):
SHARED.indexScannedText.connect(self.itemDetails.updateViewBox)
SHARED.indexCleared.connect(self.docViewerPanel.indexWasCleared)
SHARED.indexAvailable.connect(self.docViewerPanel.indexHasAppeared)
+ SHARED.mainClockTick.connect(self._timeTick)
self.mainMenu.requestDocAction.connect(self._passDocumentAction)
self.mainMenu.requestDocInsert.connect(self._passDocumentInsert)
@@ -287,12 +288,6 @@ class GuiMain(QMainWindow):
self.asDocTimer = QTimer(self)
self.asDocTimer.timeout.connect(self._autoSaveDocument)
- # Main Clock
- self.mainTimer = QTimer(self)
- self.mainTimer.setInterval(1000)
- self.mainTimer.timeout.connect(self._timeTick)
- self.mainTimer.start()
-
# Shortcuts and Actions
self._connectMenuActions()
@@ -1212,16 +1207,15 @@ class GuiMain(QMainWindow):
@pyqtSlot()
def _timeTick(self) -> None:
"""Process time tick of the main timer."""
- if not SHARED.hasProject:
- return
- currTime = time()
- editIdle = currTime - self.docEditor.lastActive > CONFIG.userIdleTime
- userIdle = qApp.applicationState() != Qt.ApplicationActive
- self.mainStatus.setUserIdle(editIdle or userIdle)
- SHARED.updateIdleTime(currTime, editIdle or userIdle)
- self.mainStatus.updateTime(idleTime=SHARED.projectIdleTime)
- if CONFIG.memInfo and int(currTime) % 5 == 0: # pragma: no cover
- self.mainStatus.memInfo()
+ if SHARED.hasProject:
+ currTime = time()
+ editIdle = currTime - self.docEditor.lastActive > CONFIG.userIdleTime
+ userIdle = qApp.applicationState() != Qt.ApplicationActive
+ self.mainStatus.setUserIdle(editIdle or userIdle)
+ SHARED.updateIdleTime(currTime, editIdle or userIdle)
+ self.mainStatus.updateTime(idleTime=SHARED.projectIdleTime)
+ if CONFIG.memInfo and int(currTime) % 5 == 0: # pragma: no cover
+ self.mainStatus.memInfo()
return
@pyqtSlot()
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index 31b3c88a..88b2e316 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -30,7 +30,7 @@ from time import time
from typing import TYPE_CHECKING, TypeVar
from pathlib import Path
-from PyQt5.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal
+from PyQt5.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal
from PyQt5.QtWidgets import QFileDialog, QMessageBox, QWidget
from novelwriter.common import formatFileFilter
@@ -62,6 +62,8 @@ class SharedData(QObject):
indexChangedTags = pyqtSignal(list, list)
indexCleared = pyqtSignal()
indexAvailable = pyqtSignal()
+ mainClockTick = pyqtSignal()
+ slowClockTick = pyqtSignal()
def __init__(self) -> None:
super().__init__()
@@ -79,6 +81,14 @@ class SharedData(QObject):
self._idleRefTime = time()
self._focusMode = False
+ self._fClock = QTimer(self)
+ self._fClock.setInterval(1000)
+ self._fClock.timeout.connect(lambda: self.mainClockTick.emit())
+
+ self._sClock = QTimer(self)
+ self._sClock.setInterval(10000)
+ self._sClock.timeout.connect(lambda: self.slowClockTick.emit())
+
return
##
@@ -158,6 +168,8 @@ class SharedData(QObject):
soon as the Main GUI is created to ensure the SHARED singleton
has the properties needed for operation.
"""
+ self._fClock.start()
+ self._sClock.start()
self._gui = gui
self._theme = theme
self._resetProject()
diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py
index d9d7342e..fd62e34d 100644
--- a/novelwriter/tools/manuscript.py
+++ b/novelwriter/tools/manuscript.py
@@ -794,11 +794,7 @@ class _PreviewWidget(QTextBrowser):
self._updateDocMargins()
self._updateBuildAge()
- # Age Timer
- self.ageTimer = QTimer(self)
- self.ageTimer.setInterval(10)
- self.ageTimer.timeout.connect(self._updateBuildAge)
- self.ageTimer.start()
+ SHARED.slowClockTick.connect(self._updateBuildAge)
return
From bdc1f0a5d4ad78d930e7c73557c2bf95552117b1 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 25 Mar 2024 22:31:37 +0100
Subject: [PATCH 12/14] Handle the caching in the DocSearch class
---
novelwriter/core/coretools.py | 28 ++++++++++------------------
novelwriter/gui/doceditor.py | 2 --
novelwriter/gui/itemdetails.py | 12 ++++++------
novelwriter/gui/search.py | 20 --------------------
novelwriter/guimain.py | 1 -
5 files changed, 16 insertions(+), 47 deletions(-)
diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py
index 9e6bb512..7be58fc0 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -26,13 +26,14 @@ along with this program. If not, see .
"""
from __future__ import annotations
-import shutil
import logging
+import shutil
-from pathlib import Path
-from functools import partial
-from zipfile import ZipFile, is_zipfile
from collections.abc import Iterable
+from functools import partial
+from pathlib import Path
+from time import time
+from zipfile import ZipFile, is_zipfile
from PyQt5.QtCore import QCoreApplication, QRegularExpression
@@ -315,6 +316,7 @@ class DocSearch:
# Project Cache
self._uuid = ""
+ self._time = 0.0
self._cache: dict[str, str] = {}
return
@@ -341,24 +343,15 @@ class DocSearch:
self._escape = not state
return
- def clearTextCache(self, tHandle: str | None) -> None:
- """Clear text cache for a given item, or all items if None."""
- if tHandle is None:
- self._cache = {}
- logger.debug("Search cache cleared")
- elif tHandle in self._cache:
- self._cache.pop(tHandle, None)
- logger.debug("Search cache cleared for '%s'", tHandle)
- return
-
def iterSearch(
self, project: NWProject, search: 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)
+ if project.data.uuid != self._uuid or time() - self._time > 20.0:
+ self._cache = {}
self._uuid = project.data.uuid
+ self._time = time()
self._regEx.setPattern(self._buildPattern(search))
logger.debug("Searching with pattern '%s'", self._regEx.pattern())
@@ -367,8 +360,7 @@ class DocSearch:
for item in project.tree:
if item.isFileType():
tHandle = item.itemHandle
- text = self._cache.get(tHandle)
- if text is None:
+ if (text := self._cache.get(tHandle)) is None:
text = storage.getDocument(tHandle).readDocument() or ""
self._cache[tHandle] = text
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index 46a0d52e..7c8c6747 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -91,7 +91,6 @@ class GuiDocEditor(QPlainTextEdit):
# Custom Signals
statusMessage = pyqtSignal(str)
- docTextSaved = pyqtSignal(str)
docCountsChanged = pyqtSignal(str, int, int, int)
editedStatusChanged = pyqtSignal(bool)
loadDocumentTagRequest = pyqtSignal(str, Enum)
@@ -489,7 +488,6 @@ class GuiDocEditor(QPlainTextEdit):
return False
self.setDocumentChanged(False)
- self.docTextSaved.emit(tHandle)
oldHeader = self._nwItem.mainHeading
oldCount = SHARED.project.index.getHandleHeaderCount(tHandle)
diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py
index 2c9a99ff..27c3a017 100644
--- a/novelwriter/gui/itemdetails.py
+++ b/novelwriter/gui/itemdetails.py
@@ -48,7 +48,7 @@ class GuiItemDetails(QWidget):
logger.debug("Create: GuiItemDetails")
# Internal Variables
- self._itemHandle = None
+ self._handle = None
# Sizes
hSp = CONFIG.pxInt(6)
@@ -194,7 +194,7 @@ class GuiItemDetails(QWidget):
def clearDetails(self) -> None:
"""Clear all the data values."""
- self._itemHandle = None
+ self._handle = None
self.labelIcon.clear()
self.labelData.clear()
self.statusIcon.clear()
@@ -210,11 +210,11 @@ class GuiItemDetails(QWidget):
def refreshDetails(self) -> None:
"""Reload the content of the details panel."""
- self.updateViewBox(self._itemHandle)
+ self.updateViewBox(self._handle)
def updateTheme(self) -> None:
"""Update theme elements."""
- self.updateViewBox(self._itemHandle)
+ self.updateViewBox(self._handle)
return
##
@@ -233,7 +233,7 @@ class GuiItemDetails(QWidget):
self.clearDetails()
return
- self._itemHandle = tHandle
+ self._handle = tHandle
iPx = int(round(0.8*SHARED.theme.baseIconSize))
# Label
@@ -295,7 +295,7 @@ class GuiItemDetails(QWidget):
"""Update the counts if the handle is the same as the one we're
already showing. Otherwise, do nothing.
"""
- if tHandle == self._itemHandle:
+ if tHandle == self._handle:
self.cCountData.setText(f"{cC:n}")
self.wCountData.setText(f"{wC:n}")
self.pCountData.setText(f"{pC:n}")
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index 3bd28945..e4e1be38 100644
--- a/novelwriter/gui/search.py
+++ b/novelwriter/gui/search.py
@@ -135,8 +135,6 @@ class GuiProjectSearch(QWidget):
self.setLayout(self.outerBox)
self.updateTheme()
- SHARED.slowClockTick.connect(self._cacheTimeout)
-
logger.debug("Ready: GuiProjectSearch")
return
@@ -205,17 +203,6 @@ class GuiProjectSearch(QWidget):
super().keyPressEvent(event)
return
- ##
- # Public Slots
- ##
-
- @pyqtSlot(str)
- def clearSearchCache(self, tHandle: str) -> None:
- """Process document content change."""
- if not self._blocked:
- self._search.clearTextCache(tHandle)
- return
-
##
# Private Slots
##
@@ -284,13 +271,6 @@ class GuiProjectSearch(QWidget):
CONFIG.searchProjRegEx = state
return
- @pyqtSlot()
- def _cacheTimeout(self) -> None:
- """Clear the cache after a period of inactivity."""
- if not self._blocked and time() - self._time > CACHE_TIMEOUT:
- self._search.clearTextCache(None)
- return
-
##
# Internal Functions
##
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 978b1581..28f58905 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -262,7 +262,6 @@ 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.docTextSaved.connect(self.projSearch.clearSearchCache)
self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle)
self.docViewer.loadDocumentTagRequest.connect(self._followTag)
From eb0fac8a43d491747f9dc0a52eafc65e1f2f02f5 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 26 Mar 2024 00:26:24 +0100
Subject: [PATCH 13/14] Improve test coverage
---
novelwriter/gui/search.py | 25 +++--
tests/test_core/test_core_coretools.py | 97 ++++++++++++++++++-
tests/test_gui/test_gui_search.py | 125 +++++++++++++++++++++++++
3 files changed, 232 insertions(+), 15 deletions(-)
create mode 100644 tests/test_gui/test_gui_search.py
diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py
index e4e1be38..27ef4b4b 100644
--- a/novelwriter/gui/search.py
+++ b/novelwriter/gui/search.py
@@ -28,7 +28,7 @@ import logging
from time import time
from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot
-from PyQt5.QtGui import QKeyEvent, QPalette
+from PyQt5.QtGui import QCursor, QKeyEvent, QPalette
from PyQt5.QtWidgets import (
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QToolBar, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget, qApp
@@ -110,7 +110,6 @@ class GuiProjectSearch(QWidget):
self.searchResult.setColumnCount(2)
self.searchResult.setIconSize(QSize(iPx, iPx))
self.searchResult.setIndentation(iPx)
- self.searchResult.itemPressed.connect(self._searchResultPressed)
self.searchResult.itemDoubleClicked.connect(self._searchResultDoubleClicked)
self.searchResult.itemSelectionChanged.connect(self._searchResultSelected)
@@ -160,8 +159,15 @@ class GuiProjectSearch(QWidget):
"""Process a return keypress forwarded from the main GUI."""
if self.searchText.hasFocus():
self._processSearch()
- elif items := self.searchResult.selectedItems():
- self._searchResultPressed(items[0], 0)
+ elif (
+ self.searchResult.hasFocus()
+ and (items := self.searchResult.selectedItems())
+ and (data := items[0].data(0, self.D_RESULT))
+ and len(data) == 3
+ ):
+ self.openDocumentSelectRequest.emit(
+ str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1), False
+ )
return
def beginSearch(self) -> None:
@@ -211,6 +217,7 @@ class GuiProjectSearch(QWidget):
def _processSearch(self) -> None:
"""Perform a search."""
if not self._blocked:
+ qApp.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
start = time()
self._blocked = True
self.searchResult.clear()
@@ -222,6 +229,7 @@ class GuiProjectSearch(QWidget):
self._appendResultSet(item, results, capped)
logger.debug("Search took %.3f ms", 1000*(time() - start))
self._time = time()
+ qApp.restoreOverrideCursor()
self._blocked = False
return
@@ -235,15 +243,6 @@ class GuiProjectSearch(QWidget):
self.selectedItemChanged.emit(str(data))
return
- @pyqtSlot("QTreeWidgetItem*", int)
- 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
- )
- return
-
@pyqtSlot("QTreeWidgetItem*", int)
def _searchResultDoubleClicked(self, item: QTreeWidgetItem, column: int) -> None:
"""Process search result double click."""
diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py
index 85ebedfd..1e205156 100644
--- a/tests/test_core/test_core_coretools.py
+++ b/tests/test_core/test_core_coretools.py
@@ -32,9 +32,11 @@ from tools import C, NWD_IGNORE, buildTestProject, cmpFiles, XML_IGNORE
from mocked import causeOSError
from novelwriter import CONFIG
-from novelwriter.constants import nwFiles, nwItemClass
+from novelwriter.constants import nwConst, nwFiles, nwItemClass
+from novelwriter.core.coretools import (
+ DocDuplicator, DocMerger, DocSearch, DocSplitter, ProjectBuilder
+)
from novelwriter.core.project import NWProject
-from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter, ProjectBuilder
@pytest.mark.core
@@ -401,6 +403,97 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
# END Test testCoreTools_DocDuplicator
+@pytest.mark.core
+def testCoreTools_DocSearch(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText):
+ """Test the DocDuplicator utility."""
+ project = NWProject()
+ mockRnd.reset()
+ buildTestProject(project, fncPath)
+ project.storage.getDocument(C.hSceneDoc).writeDocument(
+ "### New Scene\n\n" + "\n\n".join(ipsumText)
+ )
+
+ search = DocSearch()
+
+ # Defaults
+ # ========
+
+ result = [(i.itemHandle, r, c) for i, r, c in search.iterSearch(project, "Scene")]
+ assert result[0] == (C.hTitlePage, [], False)
+ assert result[1] == (C.hChapterDoc, [], False)
+ assert result[2] == (C.hSceneDoc, [(8, 5, "Scene")], False)
+
+ # Cache
+ assert list(search._cache.keys()) == [C.hTitlePage, C.hChapterDoc, C.hSceneDoc]
+
+ # Patterns
+ # ========
+
+ # Escape Using QRegularExpression
+ with monkeypatch.context() as mp:
+ mp.setattr(CONFIG, "verQtValue", 0x050f00)
+ assert search._buildPattern("[A-Za-z0-9_]+") == r"\[A\-Za\-z0\-9_\]\+"
+
+ # Escape Using Custom Implementation
+ with monkeypatch.context() as mp:
+ mp.setattr(CONFIG, "verQtValue", 0x050d00)
+ assert search._buildPattern("[A-Za-z0-9_]+") == r"\[A\-Za\-z0\-9_\]\+"
+
+ # Whole Words
+ search.setWholeWords(True)
+ search.setUserRegEx(True)
+ assert search._buildPattern("Hi") == r"\bHi\b"
+ assert search._buildPattern(r"\bHi") == r"\bHi\b"
+ assert search._buildPattern(r"Hi\b") == r"\bHi\b"
+ assert search._buildPattern(r"\bHi\b") == r"\bHi\b"
+ search.setWholeWords(False)
+ search.setUserRegEx(False)
+
+ # Test Settings
+ # =============
+
+ def pruneResult(result, index):
+ temp = [(i.itemHandle, r, c) for i, r, c in result][index][1]
+ return [(s, n, c.split()[0]) for s, n, c in temp]
+
+ # Defaults
+ assert pruneResult(search.iterSearch(project, "Lorem"), 2) == [
+ (15, 5, "Lorem"), (754, 5, "lorem"), (2056, 5, "lorem,"), (2209, 5, "lorem"),
+ (2425, 5, "lorem"), (2840, 5, "lorem."), (3399, 5, "lorem"),
+ ]
+
+ # Whole Words
+ search.setWholeWords(True)
+ assert pruneResult(search.iterSearch(project, "Lor"), 2) == []
+ search.setWholeWords(False)
+ assert pruneResult(search.iterSearch(project, "Lor"), 2) == [
+ (15, 3, "Lorem"), (29, 3, "lor"), (754, 3, "lorem"), (2056, 3, "lorem,"),
+ (2209, 3, "lorem"), (2425, 3, "lorem"), (2840, 3, "lorem."), (3328, 3, "lor."),
+ (3399, 3, "lorem"),
+ ]
+
+ # As RegEx
+ search.setWholeWords(False)
+ search.setUserRegEx(True)
+ assert pruneResult(search.iterSearch(project, r"Lor\b"), 2) == [
+ (29, 3, "lor"), (3328, 3, "lor."),
+ ]
+
+ # Max Results
+ with monkeypatch.context() as mp:
+ mp.setattr(nwConst, "MAX_SEARCH_RESULT", 3)
+ assert pruneResult(search.iterSearch(project, "Lorem"), 2) == [
+ (15, 5, "Lorem"), (754, 5, "lorem"), (2056, 5, "lorem,"),
+ ]
+
+ # Case Sensitive
+ search.setCaseSensitive(True)
+ assert pruneResult(search.iterSearch(project, "Lorem"), 2) == [(15, 5, "Lorem")]
+ search.setCaseSensitive(False)
+
+# END Test testCoreTools_DocSearch
+
+
@pytest.mark.core
def testCoreTools_ProjectBuilderWrapper(monkeypatch, caplog, fncPath, mockGUI):
"""Test the wrapper function of the project builder."""
diff --git a/tests/test_gui/test_gui_search.py b/tests/test_gui/test_gui_search.py
new file mode 100644
index 00000000..20a18b9c
--- /dev/null
+++ b/tests/test_gui/test_gui_search.py
@@ -0,0 +1,125 @@
+"""
+novelWriter – Main GUI Project Search Tester
+============================================
+
+This file is a part of novelWriter
+Copyright 2018–2024, Veronica Berglyd Olsen
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+from __future__ import annotations
+
+import pytest
+
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import QAction
+
+from novelwriter.enum import nwView
+from novelwriter.gui.search import GuiProjectSearch
+
+
+@pytest.mark.gui
+def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
+ """Test navigating the novel tree."""
+ nwGUI.openProject(prjLipsum)
+ nwGUI._changeView(nwView.SEARCH)
+ search = nwGUI.projSearch
+
+ def totalCount():
+ nonlocal search
+ res = search.searchResult
+ return sum(
+ int(res.topLevelItem(i).text(GuiProjectSearch.C_COUNT).strip("()"))
+ for i in range(res.topLevelItemCount())
+ )
+
+ # Plain search
+ search.searchText.setText("Lorem")
+ search.searchAction.activate(QAction.ActionEvent.Trigger)
+ assert search.searchResult.topLevelItemCount() == 14
+ assert totalCount() == 42
+
+ firstDoc = search.searchResult.topLevelItem(0)
+ firstResult = firstDoc.child(0)
+ assert firstDoc is not None
+ handle = firstDoc.data(GuiProjectSearch.C_RESULT, GuiProjectSearch.D_HANDLE)
+ result = firstResult.data(GuiProjectSearch.C_RESULT, GuiProjectSearch.D_RESULT)
+ assert result == (handle, 3, 5)
+
+ # Move down
+ qtbot.keyClick(search, Qt.Key.Key_Down)
+ assert firstDoc.isSelected() is True
+
+ # Move up
+ qtbot.keyClick(search, Qt.Key.Key_Up)
+ assert firstDoc.isSelected() is False
+
+ # Move right does nothing
+ qtbot.keyClick(search, Qt.Key.Key_Right)
+ assert firstDoc.isSelected() is False
+
+ # Selecting updates details
+ firstDoc.setSelected(True)
+ assert nwGUI.itemDetails._handle == handle
+
+ # Press return
+ search.searchResult.setFocus()
+ search.searchResult.clearSelection()
+ firstResult.setSelected(True)
+ with monkeypatch.context() as mp:
+ mp.setattr(search.searchResult, "hasFocus", lambda *a: True)
+ with qtbot.waitSignal(search.openDocumentSelectRequest, timeout=1000) as signal:
+ qtbot.keyClick(search, Qt.Key.Key_Return)
+ assert signal.args == [handle, 3, 5, False]
+
+ assert nwGUI.docEditor.docHandle == handle
+ assert nwGUI.docEditor.textCursor().selectedText() == "Lorem"
+
+ # Double-click
+ with qtbot.waitSignal(search.openDocumentSelectRequest, timeout=1000) as signal:
+ search._searchResultDoubleClicked(firstResult, 0)
+ assert signal.args == [handle, 3, 5, True]
+
+ # Case Sensitive
+ search.toggleCase.setChecked(True)
+ search.searchAction.activate(QAction.ActionEvent.Trigger)
+ assert search.searchResult.topLevelItemCount() == 7
+ assert totalCount() == 17
+ search.toggleCase.setChecked(False)
+
+ # Whole Words
+ search.searchText.setText("dolor")
+ with monkeypatch.context() as mp:
+ mp.setattr(search.searchText, "hasFocus", lambda *a: True)
+ qtbot.keyClick(search, Qt.Key.Key_Return)
+
+ assert search.searchResult.topLevelItemCount() == 10
+ assert totalCount() == 34
+
+ search.toggleWord.setChecked(True)
+ search.searchAction.activate(QAction.ActionEvent.Trigger)
+ assert search.searchResult.topLevelItemCount() == 10
+ assert totalCount() == 33
+
+ # RegEx
+ search.toggleRegEx.setChecked(True)
+ search.searchText.setText("(dolor|dolorem)")
+ search.searchAction.activate(QAction.ActionEvent.Trigger)
+ assert search.searchResult.topLevelItemCount() == 10
+ assert totalCount() == 34
+
+ # qtbot.stop()
+ nwGUI.closeProject()
+
+# END Test testGuiDocSearch_Main
From e64fb28d892953cb7695a11f7080dc62657684a9 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 26 Mar 2024 00:29:58 +0100
Subject: [PATCH 14/14] Revert some timer changes
---
novelwriter/shared.py | 14 ++++----------
novelwriter/tools/manuscript.py | 6 +++++-
2 files changed, 9 insertions(+), 11 deletions(-)
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index 88b2e316..15b66a9e 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -63,7 +63,6 @@ class SharedData(QObject):
indexCleared = pyqtSignal()
indexAvailable = pyqtSignal()
mainClockTick = pyqtSignal()
- slowClockTick = pyqtSignal()
def __init__(self) -> None:
super().__init__()
@@ -81,13 +80,9 @@ class SharedData(QObject):
self._idleRefTime = time()
self._focusMode = False
- self._fClock = QTimer(self)
- self._fClock.setInterval(1000)
- self._fClock.timeout.connect(lambda: self.mainClockTick.emit())
-
- self._sClock = QTimer(self)
- self._sClock.setInterval(10000)
- self._sClock.timeout.connect(lambda: self.slowClockTick.emit())
+ self._clock = QTimer(self)
+ self._clock.setInterval(1000)
+ self._clock.timeout.connect(lambda: self.mainClockTick.emit())
return
@@ -168,8 +163,7 @@ class SharedData(QObject):
soon as the Main GUI is created to ensure the SHARED singleton
has the properties needed for operation.
"""
- self._fClock.start()
- self._sClock.start()
+ self._clock.start()
self._gui = gui
self._theme = theme
self._resetProject()
diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py
index fd62e34d..977a10be 100644
--- a/novelwriter/tools/manuscript.py
+++ b/novelwriter/tools/manuscript.py
@@ -794,7 +794,11 @@ class _PreviewWidget(QTextBrowser):
self._updateDocMargins()
self._updateBuildAge()
- SHARED.slowClockTick.connect(self._updateBuildAge)
+ # Age Timer
+ self.ageTimer = QTimer(self)
+ self.ageTimer.setInterval(10000)
+ self.ageTimer.timeout.connect(self._updateBuildAge)
+ self.ageTimer.start()
return