Add global search feature (#1775)

This commit is contained in:
Veronica Berglyd Olsen
2024-03-26 10:58:01 +01:00
committed by GitHub
24 changed files with 861 additions and 142 deletions
@@ -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
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m20.547 14.707-0.7017-0.703-0.98054-0.98185c0.29717-0.90068 0.46343-1.859 0.46343-2.8578 0-5.0519-4.112-9.1639-9.1639-9.1639s-9.1639 4.112-9.1639 9.1639c0 5.0519 4.112 9.1639 9.1639 9.1639 0.99887 0 1.9585-0.16626 2.8591-0.46343l0.98185 0.98054 1.9794 1.9768 0.07986 0.07986 0.08378 0.072c0.78679 0.66242 1.7647 1.0264 2.7544 1.0264 2.2596 0 4.0976-1.838 4.0976-4.0989 0-1.0997-0.4294-2.1313-1.2096-2.9037zm-16.93-4.5427c0-3.6093 2.9364-6.5457 6.5457-6.5457s6.5457 2.9364 6.5457 6.5457-2.9364 6.5457-6.5457 6.5457-6.5457-2.9364-6.5457-6.5457z" fill="#aeaeae" stroke-width="1.3091"/>
</svg>

After

Width:  |  Height:  |  Size: 737 B

@@ -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
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" version="1.2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m20.547 14.707-0.7017-0.703-0.98054-0.98185c0.29717-0.90068 0.46343-1.859 0.46343-2.8578 0-5.0519-4.112-9.1639-9.1639-9.1639s-9.1639 4.112-9.1639 9.1639c0 5.0519 4.112 9.1639 9.1639 9.1639 0.99887 0 1.9585-0.16626 2.8591-0.46343l0.98185 0.98054 1.9794 1.9768 0.07986 0.07986 0.08378 0.072c0.78679 0.66242 1.7647 1.0264 2.7544 1.0264 2.2596 0 4.0976-1.838 4.0976-4.0989 0-1.0997-0.4294-2.1313-1.2096-2.9037zm-16.93-4.5427c0-3.6093 2.9364-6.5457 6.5457-6.5457s6.5457 2.9364 6.5457 6.5457-2.9364 6.5457-6.5457 6.5457-6.5457-2.9364-6.5457-6.5457z" fill-opacity=".78039" stroke-width="1.3091"/>
</svg>

After

Width:  |  Height:  |  Size: 744 B

+16 -10
View File
@@ -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
+1
View File
@@ -53,6 +53,7 @@ class nwConst:
# Gui Settings
STATUS_MSG_TIMEOUT = 15000 # milliseconds
MAX_SEARCH_RESULT = 1000
# Dialogs
DLG_FINISHED = 2
+112 -6
View File
@@ -26,19 +26,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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.
+1
View File
@@ -155,6 +155,7 @@ class nwView(Enum):
PROJECT = 1
NOVEL = 2
OUTLINE = 3
SEARCH = 4
# END Enum nwView
+23 -14
View File
@@ -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)
+6 -6
View File
@@ -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}")
+10 -1
View File
@@ -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:
+42 -19
View File
@@ -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("<b>%s</b>" % self.tr("Title"))
self.fileLabel = QLabel("<b>%s</b>" % self.tr("Document"))
self.itemLabel = QLabel("<b>%s</b>" % 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("<b>%s</b>" % self.tr("Characters"))
self.wCLabel = QLabel("<b>%s</b>" % self.tr("Words"))
self.pCLabel = QLabel("<b>%s</b>" % 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("<b>%s</b>" % 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("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY]))
self.focKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY]))
self.chrKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY]))
self.pltKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY]))
self.timKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY]))
self.wldKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY]))
self.objKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY]))
self.entKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY]))
self.cstKeyLabel = QLabel("<b>%s</b>" % 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("<b>%s</b>" % 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("<b>%s</b>" % 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)
+2 -1
View File
@@ -270,7 +270,8 @@ class GuiProjectToolBar(QWidget):
self.setAutoFillBackground(True)
# Widget Label
self.viewLabel = QLabel("<b>%s</b>" % 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)
+316
View File
@@ -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 20182024, 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 <https://www.gnu.org/licenses/>.
"""
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
+12 -4
View File
@@ -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)
+3 -1
View File
@@ -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",
+71 -70
View File
@@ -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
+7 -1
View File
@@ -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()
+1 -1
View File
@@ -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()
+4 -2
View File
@@ -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
+95 -2
View File
@@ -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."""
+1 -1
View File
@@ -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
+3 -3
View File
@@ -237,7 +237,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat
selItem = outlineTree.topLevelItem(0)
outlineTree.setCurrentItem(selItem)
assert outlineData.titleLabel.text() == "<b>Title</b>"
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() == "<b>Scene</b>"
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() == "<b>Section</b>"
assert outlineData.titleLabel.text() == "Section"
assert outlineData.titleValue.text() == "Scene One, Section Two"
assert outlineData.fileValue.text() == "Scene One"
assert outlineData.itemValue.text() == "Finished"
+125
View File
@@ -0,0 +1,125 @@
"""
novelWriter Main GUI Project Search Tester
============================================
This file is a part of novelWriter
Copyright 20182024, 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 <https://www.gnu.org/licenses/>.
"""
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