Add global search feature (#1775)
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -53,6 +53,7 @@ class nwConst:
|
||||
|
||||
# Gui Settings
|
||||
STATUS_MSG_TIMEOUT = 15000 # milliseconds
|
||||
MAX_SEARCH_RESULT = 1000
|
||||
|
||||
# Dialogs
|
||||
DLG_FINISHED = 2
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -155,6 +155,7 @@ class nwView(Enum):
|
||||
PROJECT = 1
|
||||
NOVEL = 2
|
||||
OUTLINE = 3
|
||||
SEARCH = 4
|
||||
|
||||
# END Enum nwView
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
novelWriter – GUI Project Search
|
||||
================================
|
||||
|
||||
File History:
|
||||
Created: 2024-03-21 [2.4b1] GuiProjectSearch
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
novelWriter – Main GUI Project Search Tester
|
||||
============================================
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, Veronica Berglyd Olsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <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
|
||||
Reference in New Issue
Block a user