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/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/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 a4e3e3aa..7be58fc0 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -26,19 +26,20 @@ 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
+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
@@ -304,6 +305,111 @@ class DocDuplicator:
# END Class DocDuplicator
+class DocSearch:
+
+ def __init__(self) -> None:
+ # RegEx Object
+ self._regEx = QRegularExpression()
+ self.setCaseSensitive(False)
+ self._words = False
+ self._escape = True
+
+ # Project Cache
+ self._uuid = ""
+ self._time = 0.0
+ self._cache: dict[str, str] = {}
+
+ return
+
+ ##
+ # 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 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 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())
+
+ num = len(search)
+ storage = project.storage
+ for item in project.tree:
+ if item.isFileType():
+ tHandle = item.itemHandle
+ if (text := self._cache.get(tHandle)) is None:
+ text = storage.getDocument(tHandle).readDocument() or ""
+ 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]
+ if context:
+ results.append((pos, num, context))
+ count += 1
+ if count >= nwConst.MAX_SEARCH_RESULT:
+ capped = True
+ break
+
+ yield item, results, capped
+
+ 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
+ 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
+
+
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/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/doceditor.py b/novelwriter/gui/doceditor.py
index 8cd72429..7c8c6747 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
@@ -603,13 +603,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."""
@@ -637,6 +639,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 +801,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
@@ -1368,9 +1374,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())
@@ -2609,8 +2616,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/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/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..27ef4b4b
--- /dev/null
+++ b/novelwriter/gui/search.py
@@ -0,0 +1,316 @@
+"""
+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 time import time
+
+from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot
+from PyQt5.QtGui import QCursor, QKeyEvent, QPalette
+from PyQt5.QtWidgets import (
+ QHBoxLayout, QHeaderView, QLabel, QLineEdit, QToolBar, QTreeWidget,
+ QTreeWidgetItem, QVBoxLayout, QWidget, qApp
+)
+
+from novelwriter import CONFIG, SHARED
+from novelwriter.common import checkInt
+from novelwriter.core.coretools import DocSearch
+from novelwriter.core.item import NWItem
+
+logger = logging.getLogger(__name__)
+
+CACHE_TIMEOUT = 120.0 # 2 minutes
+
+
+class GuiProjectSearch(QWidget):
+
+ C_NAME = 0
+ C_RESULT = 0
+ C_COUNT = 1
+
+ D_HANDLE = Qt.ItemDataRole.UserRole
+ D_RESULT = Qt.ItemDataRole.UserRole + 1
+
+ selectedItemChanged = pyqtSignal(str)
+ openDocumentSelectRequest = pyqtSignal(str, int, int, bool)
+
+ def __init__(self, parent: QWidget) -> None:
+ super().__init__(parent=parent)
+
+ logger.debug("Create: GuiProjectSearch")
+
+ 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"))
+ 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.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)
+ self.searchText.setPlaceholderText(self.tr("Search text ..."))
+ 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)
+ self.searchResult.setColumnCount(2)
+ self.searchResult.setIconSize(QSize(iPx, iPx))
+ self.searchResult.setIndentation(iPx)
+ 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()
+ self.headerBox.addWidget(self.viewLabel, 1)
+ self.headerBox.addWidget(self.searchOpt, 0)
+ self.headerBox.setContentsMargins(0, 0, 0, 0)
+
+ self.outerBox = QVBoxLayout()
+ 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(mPx)
+
+ 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)
+
+ 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
+
+ def processReturn(self) -> None:
+ """Process a return keypress forwarded from the main GUI."""
+ if self.searchText.hasFocus():
+ self._processSearch()
+ 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:
+ """Focus the search box and select its text, if any."""
+ self.searchText.setFocus()
+ self.searchText.selectAll()
+ return
+
+ def closeProjectTasks(self) -> None:
+ """Run close project tasks."""
+ self.searchText.clear()
+ self.searchResult.clear()
+ 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
+
+ ##
+ # Private Slots
+ ##
+
+ @pyqtSlot()
+ 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()
+ 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()
+ qApp.restoreOverrideCursor()
+ self._blocked = False
+ 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 _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
+
+ @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
+ ##
+
+ def _appendResultSet(
+ self, nwItem: NWItem, results: list[tuple[int, int, str]], capped: bool
+ ) -> None:
+ """Populate the result tree."""
+ if results:
+ tHandle = nwItem.itemHandle
+ docIcon = SHARED.theme.getItemIcon(
+ nwItem.itemType, nwItem.itemClass,
+ nwItem.itemLayout, nwItem.mainHeading
+ )
+ ext = "+" if capped else ""
+
+ tItem = QTreeWidgetItem()
+ 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 = []
+ 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)
+
+ parent = self.searchResult.indexFromItem(tItem)
+ for i in range(tItem.childCount()):
+ self.searchResult.setFirstColumnSpanned(i, parent, True)
+
+ qApp.processEvents()
+
+ return
+
+# END Class GuiProjectSearch
diff --git a/novelwriter/gui/sidebar.py b/novelwriter/gui/sidebar.py
index 9497444d..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,15 +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.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")))
@@ -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..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()
@@ -486,7 +488,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",
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index 21db48b6..28f58905 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
@@ -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,31 +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)
-
- # 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()
@@ -237,14 +220,16 @@ 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)
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)
@@ -261,6 +246,9 @@ class GuiMain(QMainWindow):
self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
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)
self.docEditor.docCountsChanged.connect(self.projView.updateCounts)
@@ -299,12 +287,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()
@@ -408,6 +390,7 @@ class GuiMain(QMainWindow):
self.outlineView.closeProjectTasks()
self.novelView.closeProjectTasks()
self.projView.closeProjectTasks()
+ self.projSearch.closeProjectTasks()
self.itemDetails.clearDetails()
self.mainStatus.clearStatus()
@@ -1031,12 +1014,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()
@@ -1077,6 +1063,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()
@@ -1147,6 +1134,15 @@ class GuiMain(QMainWindow):
self.viewDocument(tHandle=tHandle, sTitle=sTitle)
return
+ @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, changeFocus=changeFocus):
+ self.docEditor.setCursorSelection(selStart, selLength)
+ return
+
@pyqtSlot()
def _reloadViewer(self) -> None:
"""Reload the document in the viewer."""
@@ -1168,6 +1164,10 @@ 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)
+ self.projSearch.beginSearch()
elif view == nwView.OUTLINE:
self.mainStack.setCurrentWidget(self.outlineView)
return
@@ -1206,16 +1206,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()
@@ -1257,15 +1256,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:
@@ -1275,7 +1275,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
@@ -1284,9 +1284,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/novelwriter/shared.py b/novelwriter/shared.py
index 31b3c88a..15b66a9e 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,7 @@ class SharedData(QObject):
indexChangedTags = pyqtSignal(list, list)
indexCleared = pyqtSignal()
indexAvailable = pyqtSignal()
+ mainClockTick = pyqtSignal()
def __init__(self) -> None:
super().__init__()
@@ -79,6 +80,10 @@ class SharedData(QObject):
self._idleRefTime = time()
self._focusMode = False
+ self._clock = QTimer(self)
+ self._clock.setInterval(1000)
+ self._clock.timeout.connect(lambda: self.mainClockTick.emit())
+
return
##
@@ -158,6 +163,7 @@ class SharedData(QObject):
soon as the Main GUI is created to ensure the SHARED singleton
has the properties needed for operation.
"""
+ self._clock.start()
self._gui = gui
self._theme = theme
self._resetProject()
diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py
index d9d7342e..977a10be 100644
--- a/novelwriter/tools/manuscript.py
+++ b/novelwriter/tools/manuscript.py
@@ -796,7 +796,7 @@ class _PreviewWidget(QTextBrowser):
# Age Timer
self.ageTimer = QTimer(self)
- self.ageTimer.setInterval(10)
+ self.ageTimer.setInterval(10000)
self.ageTimer.timeout.connect(self._updateBuildAge)
self.ageTimer.start()
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
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_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
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"
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