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_editor = mixed_edit.svg
view_novel = typ_book-grey.svg view_novel = typ_book-grey.svg
view_outline = typ_puzzle-outline.svg view_outline = typ_puzzle-outline.svg
view_search = typ_search-grey.svg
deco_doc_h0 = nw_deco-h0.svg deco_doc_h0 = nw_deco-h0.svg
deco_doc_h0_n = 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_editor = mixed_edit.svg
view_novel = typ_book-grey.svg view_novel = typ_book-grey.svg
view_outline = typ_puzzle-outline.svg view_outline = typ_puzzle-outline.svg
view_search = typ_search-grey.svg
deco_doc_h0 = nw_deco-h0.svg deco_doc_h0 = nw_deco-h0.svg
deco_doc_h0_n = 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 # State
self.showViewerPanel = True # The panel for the viewer is visible self.showViewerPanel = True # The panel for the viewer is visible
self.showEditToolBar = False # The document editor toolbar visibility 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.viewComments = True # Comments are shown in the viewer
self.viewSynopsis = True # Synopsis is shown in the viewer self.viewSynopsis = True # Synopsis is shown in the viewer
# Search Bar Switches # Search Box States
self.searchCase = False self.searchCase = False
self.searchWord = False self.searchWord = False
self.searchRegEx = False self.searchRegEx = False
self.searchLoop = False self.searchLoop = False
self.searchNextFile = False self.searchNextFile = False
self.searchMatchCap = False self.searchMatchCap = False
self.searchProjCase = False
self.searchProjWord = False
self.searchProjRegEx = False
# System and App Information # System and App Information
# ========================== # ==========================
@@ -619,7 +621,6 @@ class Config:
sec = "State" sec = "State"
self.showViewerPanel = conf.rdBool(sec, "showviewerpanel", self.showViewerPanel) self.showViewerPanel = conf.rdBool(sec, "showviewerpanel", self.showViewerPanel)
self.showEditToolBar = conf.rdBool(sec, "showedittoolbar", self.showEditToolBar) 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.viewComments = conf.rdBool(sec, "viewcomments", self.viewComments)
self.viewSynopsis = conf.rdBool(sec, "viewsynopsis", self.viewSynopsis) self.viewSynopsis = conf.rdBool(sec, "viewsynopsis", self.viewSynopsis)
self.searchCase = conf.rdBool(sec, "searchcase", self.searchCase) self.searchCase = conf.rdBool(sec, "searchcase", self.searchCase)
@@ -628,6 +629,9 @@ class Config:
self.searchLoop = conf.rdBool(sec, "searchloop", self.searchLoop) self.searchLoop = conf.rdBool(sec, "searchloop", self.searchLoop)
self.searchNextFile = conf.rdBool(sec, "searchnextfile", self.searchNextFile) self.searchNextFile = conf.rdBool(sec, "searchnextfile", self.searchNextFile)
self.searchMatchCap = conf.rdBool(sec, "searchmatchcap", self.searchMatchCap) 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 # Check Values
# ============ # ============
@@ -725,7 +729,6 @@ class Config:
conf["State"] = { conf["State"] = {
"showviewerpanel": str(self.showViewerPanel), "showviewerpanel": str(self.showViewerPanel),
"showedittoolbar": str(self.showEditToolBar), "showedittoolbar": str(self.showEditToolBar),
"useshortcodes": str(self.useShortcodes),
"viewcomments": str(self.viewComments), "viewcomments": str(self.viewComments),
"viewsynopsis": str(self.viewSynopsis), "viewsynopsis": str(self.viewSynopsis),
"searchcase": str(self.searchCase), "searchcase": str(self.searchCase),
@@ -734,6 +737,9 @@ class Config:
"searchloop": str(self.searchLoop), "searchloop": str(self.searchLoop),
"searchnextfile": str(self.searchNextFile), "searchnextfile": str(self.searchNextFile),
"searchmatchcap": str(self.searchMatchCap), "searchmatchcap": str(self.searchMatchCap),
"searchprojcase": str(self.searchProjCase),
"searchprojword": str(self.searchProjWord),
"searchprojregex": str(self.searchProjRegEx),
} }
# Write config file # Write config file
+1
View File
@@ -53,6 +53,7 @@ class nwConst:
# Gui Settings # Gui Settings
STATUS_MSG_TIMEOUT = 15000 # milliseconds STATUS_MSG_TIMEOUT = 15000 # milliseconds
MAX_SEARCH_RESULT = 1000
# Dialogs # Dialogs
DLG_FINISHED = 2 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 from __future__ import annotations
import shutil
import logging import logging
import shutil
from pathlib import Path
from functools import partial
from zipfile import ZipFile, is_zipfile
from collections.abc import Iterable 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 import CONFIG, SHARED
from novelwriter.common import isHandle, minmax, simplified 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.item import NWItem
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
from novelwriter.core.storage import NWStorageCreate from novelwriter.core.storage import NWStorageCreate
@@ -304,6 +305,111 @@ class DocDuplicator:
# END 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: class ProjectBuilder:
"""A class to build a new project from a set of user-defined """A class to build a new project from a set of user-defined
parameter provided by the New Project Wizard. parameter provided by the New Project Wizard.
+1
View File
@@ -155,6 +155,7 @@ class nwView(Enum):
PROJECT = 1 PROJECT = 1
NOVEL = 2 NOVEL = 2
OUTLINE = 3 OUTLINE = 3
SEARCH = 4
# END Enum nwView # END Enum nwView
+23 -14
View File
@@ -54,7 +54,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, transferCase 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.core.document import NWDocument
from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary
from novelwriter.extensions.eventfilters import WheelEventFilter from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -603,13 +603,15 @@ class GuiDocEditor(QPlainTextEdit):
# Setters # Setters
## ##
def setDocumentChanged(self, state: bool) -> bool: def setDocumentChanged(self, state: bool) -> None:
"""Keep track of the document changed variable, and emit the """Keep track of the document changed variable, and emit the
document change signal. document change signal.
""" """
self._docChanged = state if self._docChanged != state:
self.editedStatusChanged.emit(self._docChanged) logger.debug("Document changed status is '%s'", state)
return self._docChanged self._docChanged = state
self.editedStatusChanged.emit(self._docChanged)
return
def setCursorPosition(self, position: int) -> None: def setCursorPosition(self, position: int) -> None:
"""Move the cursor to a given position in the document.""" """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) logger.debug("Cursor moved to line %d", line)
return 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 # Spell Checking
## ##
@@ -791,11 +801,7 @@ class GuiDocEditor(QPlainTextEdit):
def anyFocus(self) -> bool: def anyFocus(self) -> bool:
"""Check if any widget or child widget has focus.""" """Check if any widget or child widget has focus."""
if self.hasFocus(): return self.hasFocus() or self.isAncestorOf(qApp.focusWidget())
return True
if self.isAncestorOf(qApp.focusWidget()):
return True
return False
def revealLocation(self) -> None: def revealLocation(self) -> None:
"""Tell the user where on the file system the file in the editor """Tell the user where on the file system the file in the editor
@@ -1368,9 +1374,10 @@ class GuiDocEditor(QPlainTextEdit):
cursor.setPosition(0) cursor.setPosition(0)
self.setTextCursor(cursor) self.setTextCursor(cursor)
# Search up to a maximum of 1000, and make sure certain special # Search up to a maximum of MAX_SEARCH_RESULT, and make sure
# searches like a regex search for .* don't loop infinitely # certain special searches like a regex search for .* don't loop
while self.find(searchFor, findOpt) and len(resE) <= 1000: # infinitely
while self.find(searchFor, findOpt) and len(resE) <= nwConst.MAX_SEARCH_RESULT:
cursor = self.textCursor() cursor = self.textCursor()
if cursor.hasSelection(): if cursor.hasSelection():
resS.append(cursor.selectionStart()) resS.append(cursor.selectionStart())
@@ -2609,8 +2616,10 @@ class GuiDocEditSearch(QFrame):
def setResultCount(self, currRes: int | None, resCount: int | None) -> None: def setResultCount(self, currRes: int | None, resCount: int | None) -> None:
"""Set the count values for the current search.""" """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) 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) minWidth = SHARED.theme.getTextWidth(f"{sResCount}//{sResCount}", self.boxFont)
self.resultLabel.setText(f"{sCurrRes}/{sResCount}") self.resultLabel.setText(f"{sCurrRes}/{sResCount}")
self.resultLabel.setMinimumWidth(minWidth) self.resultLabel.setMinimumWidth(minWidth)
+6 -6
View File
@@ -48,7 +48,7 @@ class GuiItemDetails(QWidget):
logger.debug("Create: GuiItemDetails") logger.debug("Create: GuiItemDetails")
# Internal Variables # Internal Variables
self._itemHandle = None self._handle = None
# Sizes # Sizes
hSp = CONFIG.pxInt(6) hSp = CONFIG.pxInt(6)
@@ -194,7 +194,7 @@ class GuiItemDetails(QWidget):
def clearDetails(self) -> None: def clearDetails(self) -> None:
"""Clear all the data values.""" """Clear all the data values."""
self._itemHandle = None self._handle = None
self.labelIcon.clear() self.labelIcon.clear()
self.labelData.clear() self.labelData.clear()
self.statusIcon.clear() self.statusIcon.clear()
@@ -210,11 +210,11 @@ class GuiItemDetails(QWidget):
def refreshDetails(self) -> None: def refreshDetails(self) -> None:
"""Reload the content of the details panel.""" """Reload the content of the details panel."""
self.updateViewBox(self._itemHandle) self.updateViewBox(self._handle)
def updateTheme(self) -> None: def updateTheme(self) -> None:
"""Update theme elements.""" """Update theme elements."""
self.updateViewBox(self._itemHandle) self.updateViewBox(self._handle)
return return
## ##
@@ -233,7 +233,7 @@ class GuiItemDetails(QWidget):
self.clearDetails() self.clearDetails()
return return
self._itemHandle = tHandle self._handle = tHandle
iPx = int(round(0.8*SHARED.theme.baseIconSize)) iPx = int(round(0.8*SHARED.theme.baseIconSize))
# Label # Label
@@ -295,7 +295,7 @@ class GuiItemDetails(QWidget):
"""Update the counts if the handle is the same as the one we're """Update the counts if the handle is the same as the one we're
already showing. Otherwise, do nothing. already showing. Otherwise, do nothing.
""" """
if tHandle == self._itemHandle: if tHandle == self._handle:
self.cCountData.setText(f"{cC:n}") self.cCountData.setText(f"{cC:n}")
self.wCountData.setText(f"{wC:n}") self.wCountData.setText(f"{wC:n}")
self.pCountData.setText(f"{pC: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 PyQt5.QtWidgets import QMenuBar, QAction
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.enum import nwDocAction, nwDocInsert, nwWidget
from novelwriter.common import openExternalPath from novelwriter.common import openExternalPath
from novelwriter.constants import nwConst, trConst, nwKeyWords, nwLabels, nwUnicode from novelwriter.constants import nwConst, trConst, nwKeyWords, nwLabels, nwUnicode
from novelwriter.enum import nwDocAction, nwDocInsert, nwView, nwWidget
from novelwriter.extensions.eventfilters import StatusTipFilter from novelwriter.extensions.eventfilters import StatusTipFilter
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
@@ -55,6 +55,7 @@ class GuiMainMenu(QMenuBar):
requestDocInsertText = pyqtSignal(str) requestDocInsertText = pyqtSignal(str)
requestDocKeyWordInsert = pyqtSignal(str) requestDocKeyWordInsert = pyqtSignal(str)
requestFocusChange = pyqtSignal(nwWidget) requestFocusChange = pyqtSignal(nwWidget)
requestViewChange = pyqtSignal(nwView)
def __init__(self, mainGui: GuiMain) -> None: def __init__(self, mainGui: GuiMain) -> None:
super().__init__(parent=mainGui) super().__init__(parent=mainGui)
@@ -861,6 +862,14 @@ class GuiMainMenu(QMenuBar):
self.aReplaceNext.setShortcut("Ctrl+Shift+1") self.aReplaceNext.setShortcut("Ctrl+Shift+1")
self.aReplaceNext.triggered.connect(lambda: self.mainGui.docEditor.replaceNext()) 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 return
def _buildToolsMenu(self) -> None: def _buildToolsMenu(self) -> None:
+42 -19
View File
@@ -798,14 +798,20 @@ class GuiOutlineDetails(QScrollArea):
hSpace = int(CONFIG.pxInt(10)) hSpace = int(CONFIG.pxInt(10))
vSpace = int(CONFIG.pxInt(4)) vSpace = int(CONFIG.pxInt(4))
bFont = SHARED.theme.guiFontB
# Details Area # Details Area
self.titleLabel = QLabel("<b>%s</b>" % self.tr("Title")) self.titleLabel = QLabel(self.tr("Title"))
self.fileLabel = QLabel("<b>%s</b>" % self.tr("Document")) self.fileLabel = QLabel(self.tr("Document"))
self.itemLabel = QLabel("<b>%s</b>" % self.tr("Status")) self.itemLabel = QLabel(self.tr("Status"))
self.titleValue = QLabel("") self.titleValue = QLabel("")
self.fileValue = QLabel("") self.fileValue = QLabel("")
self.itemValue = QLabel("") self.itemValue = QLabel("")
self.titleLabel.setFont(bFont)
self.fileLabel.setFont(bFont)
self.itemLabel.setFont(bFont)
self.titleValue.setMinimumWidth(minTitle) self.titleValue.setMinimumWidth(minTitle)
self.titleValue.setMaximumWidth(maxTitle) self.titleValue.setMaximumWidth(maxTitle)
self.fileValue.setMinimumWidth(minTitle) self.fileValue.setMinimumWidth(minTitle)
@@ -814,13 +820,17 @@ class GuiOutlineDetails(QScrollArea):
self.itemValue.setMaximumWidth(maxTitle) self.itemValue.setMaximumWidth(maxTitle)
# Stats Area # Stats Area
self.cCLabel = QLabel("<b>%s</b>" % self.tr("Characters")) self.cCLabel = QLabel(self.tr("Characters"))
self.wCLabel = QLabel("<b>%s</b>" % self.tr("Words")) self.wCLabel = QLabel(self.tr("Words"))
self.pCLabel = QLabel("<b>%s</b>" % self.tr("Paragraphs")) self.pCLabel = QLabel(self.tr("Paragraphs"))
self.cCValue = QLabel("") self.cCValue = QLabel("")
self.wCValue = QLabel("") self.wCValue = QLabel("")
self.pCValue = QLabel("") self.pCValue = QLabel("")
self.cCLabel.setFont(bFont)
self.wCLabel.setFont(bFont)
self.pCLabel.setFont(bFont)
self.cCValue.setMinimumWidth(wCount) self.cCValue.setMinimumWidth(wCount)
self.wCValue.setMinimumWidth(wCount) self.wCValue.setMinimumWidth(wCount)
self.pCValue.setMinimumWidth(wCount) self.pCValue.setMinimumWidth(wCount)
@@ -829,23 +839,36 @@ class GuiOutlineDetails(QScrollArea):
self.pCValue.setAlignment(Qt.AlignRight) self.pCValue.setAlignment(Qt.AlignRight)
# Synopsis # Synopsis
self.synopLabel = QLabel("<b>%s</b>" % self.tr("Synopsis")) self.synopLabel = QLabel(self.tr("Synopsis"))
self.synopLabel.setFont(bFont)
self.synopValue = QLabel("") self.synopValue = QLabel("")
self.synopLWrap = QHBoxLayout()
self.synopValue.setWordWrap(True) self.synopValue.setWordWrap(True)
self.synopValue.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.synopValue.setAlignment(Qt.AlignTop | Qt.AlignLeft)
self.synopLWrap = QHBoxLayout()
self.synopLWrap.addWidget(self.synopValue, 1) self.synopLWrap.addWidget(self.synopValue, 1)
# Tags # Tags
self.povKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])) self.povKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY]))
self.focKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])) self.focKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY]))
self.chrKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY])) self.chrKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY]))
self.pltKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])) self.pltKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY]))
self.timKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY])) self.timKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY]))
self.wldKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY])) self.wldKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY]))
self.objKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY])) self.objKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY]))
self.entKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY])) self.entKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY]))
self.cstKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_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.povKeyLWrap = QHBoxLayout()
self.focKeyLWrap = QHBoxLayout() self.focKeyLWrap = QHBoxLayout()
@@ -989,7 +1012,7 @@ class GuiOutlineDetails(QScrollArea):
def clearDetails(self) -> None: def clearDetails(self) -> None:
"""Clear all the data labels.""" """Clear all the data labels."""
self.titleLabel.setText("<b>%s</b>" % self.tr("Title")) self.titleLabel.setText(self.tr("Title"))
self.titleValue.setText("") self.titleValue.setText("")
self.fileValue.setText("") self.fileValue.setText("")
self.itemValue.setText("") self.itemValue.setText("")
@@ -1023,7 +1046,7 @@ class GuiOutlineDetails(QScrollArea):
novIdx = pIndex.getItemHeading(tHandle, sTitle) novIdx = pIndex.getItemHeading(tHandle, sTitle)
novRefs = pIndex.getReferences(tHandle, sTitle) novRefs = pIndex.getReferences(tHandle, sTitle)
if nwItem and novIdx: 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) self.titleValue.setText(novIdx.title)
itemStatus, _ = nwItem.getImportStatus(incIcon=False) itemStatus, _ = nwItem.getImportStatus(incIcon=False)
+2 -1
View File
@@ -270,7 +270,8 @@ class GuiProjectToolBar(QWidget):
self.setAutoFillBackground(True) self.setAutoFillBackground(True)
# Widget Label # 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.setContentsMargins(0, 0, 0, 0)
self.viewLabel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 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): class GuiSideBar(QWidget):
viewChangeRequested = pyqtSignal(nwView) requestViewChange = pyqtSignal(nwView)
def __init__(self, mainGui: GuiMain) -> None: def __init__(self, mainGui: GuiMain) -> None:
super().__init__(parent=mainGui) super().__init__(parent=mainGui)
@@ -61,15 +61,19 @@ class GuiSideBar(QWidget):
# Buttons # Buttons
self.tbProject = NIconToolButton(self, iPx) self.tbProject = NIconToolButton(self, iPx)
self.tbProject.setToolTip("{0} [Ctrl+T]".format(self.tr("Project Tree View"))) 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 = NIconToolButton(self, iPx)
self.tbNovel.setToolTip("{0} [Ctrl+T]".format(self.tr("Novel Tree View"))) 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 = NIconToolButton(self, iPx)
self.tbOutline.setToolTip("{0} [Ctrl+Shift+T]".format(self.tr("Novel Outline View"))) 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 = NIconToolButton(self, iPx)
self.tbBuild.setToolTip("{0} [F5]".format(self.tr("Build Manuscript"))) self.tbBuild.setToolTip("{0} [F5]".format(self.tr("Build Manuscript")))
@@ -99,6 +103,7 @@ class GuiSideBar(QWidget):
self.outerBox = QVBoxLayout() self.outerBox = QVBoxLayout()
self.outerBox.addWidget(self.tbProject) self.outerBox.addWidget(self.tbProject)
self.outerBox.addWidget(self.tbNovel) self.outerBox.addWidget(self.tbNovel)
self.outerBox.addWidget(self.tbSearch)
self.outerBox.addWidget(self.tbOutline) self.outerBox.addWidget(self.tbOutline)
self.outerBox.addWidget(self.tbBuild) self.outerBox.addWidget(self.tbBuild)
self.outerBox.addStretch(1) self.outerBox.addStretch(1)
@@ -129,6 +134,9 @@ class GuiSideBar(QWidget):
self.tbNovel.setIcon(SHARED.theme.getIcon("view_novel")) self.tbNovel.setIcon(SHARED.theme.getIcon("view_novel"))
self.tbNovel.setStyleSheet(buttonStyle) 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.setIcon(SHARED.theme.getIcon("view_outline"))
self.tbOutline.setStyleSheet(buttonStyle) self.tbOutline.setStyleSheet(buttonStyle)
+3 -1
View File
@@ -152,6 +152,8 @@ class GuiTheme:
# Fonts # Fonts
self.guiFont = qApp.font() self.guiFont = qApp.font()
self.guiFontB = qApp.font()
self.guiFontB.setBold(True)
qMetric = QFontMetrics(self.guiFont) qMetric = QFontMetrics(self.guiFont)
self.fontPointSize = self.guiFont.pointSizeF() self.fontPointSize = self.guiFont.pointSizeF()
@@ -486,7 +488,7 @@ class GuiIcons:
"build_excluded", "build_filtered", "build_included", "proj_chapter", "proj_details", "build_excluded", "build_filtered", "build_included", "proj_chapter", "proj_details",
"proj_document", "proj_folder", "proj_note", "proj_nwx", "proj_section", "proj_scene", "proj_document", "proj_folder", "proj_note", "proj_nwx", "proj_section", "proj_scene",
"proj_stats", "proj_title", "status_idle", "status_lang", "status_lines", "status_stats", "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 # Class Icons
"cls_archive", "cls_character", "cls_custom", "cls_entity", "cls_none", "cls_novel", "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 import CONFIG, SHARED, __hexversion__, __version__
from novelwriter.common import formatFileFilter, formatVersion, hexToInt
from novelwriter.constants import nwConst 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.about import GuiAbout
from novelwriter.dialogs.wordlist import GuiWordList
from novelwriter.dialogs.preferences import GuiPreferences from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.projectsettings import GuiProjectSettings from novelwriter.dialogs.projectsettings import GuiProjectSettings
from novelwriter.tools.welcome import GuiWelcome from novelwriter.dialogs.wordlist import GuiWordList
from novelwriter.tools.manuscript import GuiManuscript 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.dictionaries import GuiDictionaries
from novelwriter.tools.manuscript import GuiManuscript
from novelwriter.tools.noveldetails import GuiNovelDetails from novelwriter.tools.noveldetails import GuiNovelDetails
from novelwriter.tools.welcome import GuiWelcome
from novelwriter.tools.writingstats import GuiWritingStats 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__) logger = logging.getLogger(__name__)
@@ -123,6 +121,7 @@ class GuiMain(QMainWindow):
# Main GUI Elements # Main GUI Elements
self.mainStatus = GuiMainStatus(self) self.mainStatus = GuiMainStatus(self)
self.projView = GuiProjectView(self) self.projView = GuiProjectView(self)
self.projSearch = GuiProjectSearch(self)
self.novelView = GuiNovelView(self) self.novelView = GuiNovelView(self)
self.docEditor = GuiDocEditor(self) self.docEditor = GuiDocEditor(self)
self.docViewer = GuiDocViewer(self) self.docViewer = GuiDocViewer(self)
@@ -136,6 +135,7 @@ class GuiMain(QMainWindow):
self.projStack = QStackedWidget(self) self.projStack = QStackedWidget(self)
self.projStack.addWidget(self.projView) self.projStack.addWidget(self.projView)
self.projStack.addWidget(self.novelView) self.projStack.addWidget(self.novelView)
self.projStack.addWidget(self.projSearch)
self.projStack.currentChanged.connect(self._projStackChanged) self.projStack.currentChanged.connect(self._projStackChanged)
# Project Tree View # Project Tree View
@@ -154,6 +154,8 @@ class GuiMain(QMainWindow):
self.splitView.setHandleWidth(hWd) self.splitView.setHandleWidth(hWd)
self.splitView.setOpaqueResize(False) self.splitView.setOpaqueResize(False)
self.splitView.setSizes(CONFIG.viewPanePos) self.splitView.setSizes(CONFIG.viewPanePos)
self.splitView.setCollapsible(0, False)
self.splitView.setCollapsible(1, False)
# Splitter : Document Editor / Document Viewer # Splitter : Document Editor / Document Viewer
self.splitDocs = QSplitter(Qt.Horizontal, self) self.splitDocs = QSplitter(Qt.Horizontal, self)
@@ -161,6 +163,8 @@ class GuiMain(QMainWindow):
self.splitDocs.addWidget(self.splitView) self.splitDocs.addWidget(self.splitView)
self.splitDocs.setOpaqueResize(False) self.splitDocs.setOpaqueResize(False)
self.splitDocs.setHandleWidth(hWd) self.splitDocs.setHandleWidth(hWd)
self.splitDocs.setCollapsible(0, False)
self.splitDocs.setCollapsible(1, False)
# Splitter : Project Tree / Document Area # Splitter : Project Tree / Document Area
self.splitMain = QSplitter(Qt.Horizontal) self.splitMain = QSplitter(Qt.Horizontal)
@@ -170,6 +174,10 @@ class GuiMain(QMainWindow):
self.splitMain.setOpaqueResize(False) self.splitMain.setOpaqueResize(False)
self.splitMain.setHandleWidth(hWd) self.splitMain.setHandleWidth(hWd)
self.splitMain.setSizes(CONFIG.mainPanePos) 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 # Main Stack : Editor / Outline
self.mainStack = QStackedWidget(self) self.mainStack = QStackedWidget(self)
@@ -177,31 +185,6 @@ class GuiMain(QMainWindow):
self.mainStack.addWidget(self.outlineView) self.mainStack.addWidget(self.outlineView)
self.mainStack.currentChanged.connect(self._mainStackChanged) 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 # Editor / Viewer Default State
self.splitView.setVisible(False) self.splitView.setVisible(False)
self.docEditor.closeSearch() self.docEditor.closeSearch()
@@ -237,14 +220,16 @@ class GuiMain(QMainWindow):
SHARED.indexScannedText.connect(self.itemDetails.updateViewBox) SHARED.indexScannedText.connect(self.itemDetails.updateViewBox)
SHARED.indexCleared.connect(self.docViewerPanel.indexWasCleared) SHARED.indexCleared.connect(self.docViewerPanel.indexWasCleared)
SHARED.indexAvailable.connect(self.docViewerPanel.indexHasAppeared) SHARED.indexAvailable.connect(self.docViewerPanel.indexHasAppeared)
SHARED.mainClockTick.connect(self._timeTick)
self.mainMenu.requestDocAction.connect(self._passDocumentAction) self.mainMenu.requestDocAction.connect(self._passDocumentAction)
self.mainMenu.requestDocInsert.connect(self._passDocumentInsert) self.mainMenu.requestDocInsert.connect(self._passDocumentInsert)
self.mainMenu.requestDocInsertText.connect(self._passDocumentInsert) self.mainMenu.requestDocInsertText.connect(self._passDocumentInsert)
self.mainMenu.requestDocKeyWordInsert.connect(self.docEditor.insertKeyWord) self.mainMenu.requestDocKeyWordInsert.connect(self.docEditor.insertKeyWord)
self.mainMenu.requestFocusChange.connect(self.switchFocus) 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.selectedItemChanged.connect(self.itemDetails.updateViewBox)
self.projView.openDocumentRequest.connect(self._openDocument) self.projView.openDocumentRequest.connect(self._openDocument)
@@ -261,6 +246,9 @@ class GuiMain(QMainWindow):
self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox) self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
self.novelView.openDocumentRequest.connect(self._openDocument) 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.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus)
self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts) self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts)
self.docEditor.docCountsChanged.connect(self.projView.updateCounts) self.docEditor.docCountsChanged.connect(self.projView.updateCounts)
@@ -299,12 +287,6 @@ class GuiMain(QMainWindow):
self.asDocTimer = QTimer(self) self.asDocTimer = QTimer(self)
self.asDocTimer.timeout.connect(self._autoSaveDocument) 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 # Shortcuts and Actions
self._connectMenuActions() self._connectMenuActions()
@@ -408,6 +390,7 @@ class GuiMain(QMainWindow):
self.outlineView.closeProjectTasks() self.outlineView.closeProjectTasks()
self.novelView.closeProjectTasks() self.novelView.closeProjectTasks()
self.projView.closeProjectTasks() self.projView.closeProjectTasks()
self.projSearch.closeProjectTasks()
self.itemDetails.clearDetails() self.itemDetails.clearDetails()
self.mainStatus.clearStatus() self.mainStatus.clearStatus()
@@ -1031,12 +1014,15 @@ class GuiMain(QMainWindow):
self.novelView.setTreeFocus() self.novelView.setTreeFocus()
else: else:
self.projView.setTreeFocus() self.projView.setTreeFocus()
else: elif self.projStack.currentWidget() is self.novelView:
if self.novelView.treeHasFocus(): if self.novelView.treeHasFocus():
self._changeView(nwView.PROJECT) self._changeView(nwView.PROJECT)
self.projView.setTreeFocus() self.projView.setTreeFocus()
else: else:
self.novelView.setTreeFocus() self.novelView.setTreeFocus()
else:
self._changeView(nwView.PROJECT)
self.projView.setTreeFocus()
elif paneNo == nwWidget.EDITOR: elif paneNo == nwWidget.EDITOR:
self._changeView(nwView.EDITOR) self._changeView(nwView.EDITOR)
self.docEditor.setFocus() self.docEditor.setFocus()
@@ -1077,6 +1063,7 @@ class GuiMain(QMainWindow):
self.sideBar.updateTheme() self.sideBar.updateTheme()
self.projView.updateTheme() self.projView.updateTheme()
self.novelView.updateTheme() self.novelView.updateTheme()
self.projSearch.updateTheme()
self.outlineView.updateTheme() self.outlineView.updateTheme()
self.itemDetails.updateTheme() self.itemDetails.updateTheme()
self.mainStatus.updateTheme() self.mainStatus.updateTheme()
@@ -1147,6 +1134,15 @@ class GuiMain(QMainWindow):
self.viewDocument(tHandle=tHandle, sTitle=sTitle) self.viewDocument(tHandle=tHandle, sTitle=sTitle)
return 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() @pyqtSlot()
def _reloadViewer(self) -> None: def _reloadViewer(self) -> None:
"""Reload the document in the viewer.""" """Reload the document in the viewer."""
@@ -1168,6 +1164,10 @@ class GuiMain(QMainWindow):
elif view == nwView.NOVEL: elif view == nwView.NOVEL:
self.mainStack.setCurrentWidget(self.splitMain) self.mainStack.setCurrentWidget(self.splitMain)
self.projStack.setCurrentWidget(self.novelView) 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: elif view == nwView.OUTLINE:
self.mainStack.setCurrentWidget(self.outlineView) self.mainStack.setCurrentWidget(self.outlineView)
return return
@@ -1206,16 +1206,15 @@ class GuiMain(QMainWindow):
@pyqtSlot() @pyqtSlot()
def _timeTick(self) -> None: def _timeTick(self) -> None:
"""Process time tick of the main timer.""" """Process time tick of the main timer."""
if not SHARED.hasProject: if SHARED.hasProject:
return currTime = time()
currTime = time() editIdle = currTime - self.docEditor.lastActive > CONFIG.userIdleTime
editIdle = currTime - self.docEditor.lastActive > CONFIG.userIdleTime userIdle = qApp.applicationState() != Qt.ApplicationActive
userIdle = qApp.applicationState() != Qt.ApplicationActive self.mainStatus.setUserIdle(editIdle or userIdle)
self.mainStatus.setUserIdle(editIdle or userIdle) SHARED.updateIdleTime(currTime, editIdle or userIdle)
SHARED.updateIdleTime(currTime, editIdle or userIdle) self.mainStatus.updateTime(idleTime=SHARED.projectIdleTime)
self.mainStatus.updateTime(idleTime=SHARED.projectIdleTime) if CONFIG.memInfo and int(currTime) % 5 == 0: # pragma: no cover
if CONFIG.memInfo and int(currTime) % 5 == 0: # pragma: no cover self.mainStatus.memInfo()
self.mainStatus.memInfo()
return return
@pyqtSlot() @pyqtSlot()
@@ -1257,15 +1256,16 @@ class GuiMain(QMainWindow):
@pyqtSlot() @pyqtSlot()
def _keyPressReturn(self) -> None: def _keyPressReturn(self) -> None:
"""Forward the return/enter keypress to the function that opens """Process a return or enter keypress in the main window."""
the currently selected item. if self.projStack.currentWidget() == self.projSearch:
""" self.projSearch.processReturn()
self.openSelectedItem() else:
self.openSelectedItem()
return return
@pyqtSlot() @pyqtSlot()
def _keyPressEscape(self) -> None: def _keyPressEscape(self) -> None:
"""Process escape keypress in the main window.""" """Process an escape keypress in the main window."""
if self.docEditor.docSearch.isVisible(): if self.docEditor.docSearch.isVisible():
self.docEditor.closeSearch() self.docEditor.closeSearch()
elif SHARED.focusMode: elif SHARED.focusMode:
@@ -1275,7 +1275,7 @@ class GuiMain(QMainWindow):
@pyqtSlot(int) @pyqtSlot(int)
def _mainStackChanged(self, index: int) -> None: def _mainStackChanged(self, index: int) -> None:
"""Process main window tab change.""" """Process main window tab change."""
if index == self.idxOutlineView: if self.mainStack.widget(index) == self.outlineView:
if SHARED.hasProject: if SHARED.hasProject:
self.outlineView.refreshTree() self.outlineView.refreshTree()
return return
@@ -1284,9 +1284,10 @@ class GuiMain(QMainWindow):
def _projStackChanged(self, index: int) -> None: def _projStackChanged(self, index: int) -> None:
"""Process project view tab change.""" """Process project view tab change."""
sHandle = None sHandle = None
if index == self.idxProjView: widget = self.projStack.widget(index)
if widget == self.projView:
sHandle = self.projView.getSelectedHandle() sHandle = self.projView.getSelectedHandle()
elif index == self.idxNovelView: elif widget == self.novelView:
sHandle, _ = self.novelView.getSelectedHandle() sHandle, _ = self.novelView.getSelectedHandle()
self.itemDetails.updateViewBox(sHandle) self.itemDetails.updateViewBox(sHandle)
return return
+7 -1
View File
@@ -30,7 +30,7 @@ from time import time
from typing import TYPE_CHECKING, TypeVar from typing import TYPE_CHECKING, TypeVar
from pathlib import Path 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 PyQt5.QtWidgets import QFileDialog, QMessageBox, QWidget
from novelwriter.common import formatFileFilter from novelwriter.common import formatFileFilter
@@ -62,6 +62,7 @@ class SharedData(QObject):
indexChangedTags = pyqtSignal(list, list) indexChangedTags = pyqtSignal(list, list)
indexCleared = pyqtSignal() indexCleared = pyqtSignal()
indexAvailable = pyqtSignal() indexAvailable = pyqtSignal()
mainClockTick = pyqtSignal()
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@@ -79,6 +80,10 @@ class SharedData(QObject):
self._idleRefTime = time() self._idleRefTime = time()
self._focusMode = False self._focusMode = False
self._clock = QTimer(self)
self._clock.setInterval(1000)
self._clock.timeout.connect(lambda: self.mainClockTick.emit())
return return
## ##
@@ -158,6 +163,7 @@ class SharedData(QObject):
soon as the Main GUI is created to ensure the SHARED singleton soon as the Main GUI is created to ensure the SHARED singleton
has the properties needed for operation. has the properties needed for operation.
""" """
self._clock.start()
self._gui = gui self._gui = gui
self._theme = theme self._theme = theme
self._resetProject() self._resetProject()
+1 -1
View File
@@ -796,7 +796,7 @@ class _PreviewWidget(QTextBrowser):
# Age Timer # Age Timer
self.ageTimer = QTimer(self) self.ageTimer = QTimer(self)
self.ageTimer.setInterval(10) self.ageTimer.setInterval(10000)
self.ageTimer.timeout.connect(self._updateBuildAge) self.ageTimer.timeout.connect(self._updateBuildAge)
self.ageTimer.start() self.ageTimer.start()
+4 -2
View File
@@ -1,5 +1,5 @@
[Meta] [Meta]
timestamp = 2024-02-09 12:05:00 timestamp = 2024-03-25 11:57:35
[Main] [Main]
theme = default theme = default
@@ -69,7 +69,6 @@ useridletime = 300
[State] [State]
showviewerpanel = True showviewerpanel = True
showedittoolbar = False showedittoolbar = False
useshortcodes = False
viewcomments = True viewcomments = True
viewsynopsis = True viewsynopsis = True
searchcase = False searchcase = False
@@ -78,4 +77,7 @@ searchregex = False
searchloop = False searchloop = False
searchnextfile = False searchnextfile = False
searchmatchcap = 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 mocked import causeOSError
from novelwriter import CONFIG 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.project import NWProject
from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter, ProjectBuilder
@pytest.mark.core @pytest.mark.core
@@ -401,6 +403,97 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
# END Test testCoreTools_DocDuplicator # 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 @pytest.mark.core
def testCoreTools_ProjectBuilderWrapper(monkeypatch, caplog, fncPath, mockGUI): def testCoreTools_ProjectBuilderWrapper(monkeypatch, caplog, fncPath, mockGUI):
"""Test the wrapper function of the project builder.""" """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() novelView.setTreeFocus()
nwGUI.projStack.setCurrentIndex(nwGUI.idxNovelView) nwGUI.projStack.setCurrentWidget(nwGUI.novelView)
nwGUI.rebuildIndex() nwGUI.rebuildIndex()
novelTree._populateTree(rootHandle=None) novelTree._populateTree(rootHandle=None)
assert novelTree.topLevelItemCount() == 3 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) selItem = outlineTree.topLevelItem(0)
outlineTree.setCurrentItem(selItem) outlineTree.setCurrentItem(selItem)
assert outlineData.titleLabel.text() == "<b>Title</b>" assert outlineData.titleLabel.text() == "Title"
assert outlineData.titleValue.text() == "Lorem Ipsum" assert outlineData.titleValue.text() == "Lorem Ipsum"
assert outlineData.fileValue.text() == "Lorem Ipsum" assert outlineData.fileValue.text() == "Lorem Ipsum"
assert outlineData.itemValue.text() == "Finished" assert outlineData.itemValue.text() == "Finished"
@@ -256,7 +256,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat
assert tHandle == "88243afbe5ed8" assert tHandle == "88243afbe5ed8"
assert sTitle == "T0001" assert sTitle == "T0001"
assert outlineData.titleLabel.text() == "<b>Scene</b>" assert outlineData.titleLabel.text() == "Scene"
assert outlineData.titleValue.text() == "Scene One" assert outlineData.titleValue.text() == "Scene One"
assert outlineData.fileValue.text() == "Scene One" assert outlineData.fileValue.text() == "Scene One"
assert outlineData.itemValue.text() == "Finished" assert outlineData.itemValue.text() == "Finished"
@@ -274,7 +274,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat
assert tHandle == "88243afbe5ed8" assert tHandle == "88243afbe5ed8"
assert sTitle == "T0002" 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.titleValue.text() == "Scene One, Section Two"
assert outlineData.fileValue.text() == "Scene One" assert outlineData.fileValue.text() == "Scene One"
assert outlineData.itemValue.text() == "Finished" 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