diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index e183b1a0..75a95c5d 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -108,6 +108,7 @@ view_build = typ_export-grey.svg view_editor = mixed_edit.svg view_novel = typ_book-grey.svg view_outline = typ_puzzle-outline.svg +view_search = typ_search-grey.svg deco_doc_h0 = nw_deco-h0.svg deco_doc_h0_n = nw_deco-h0.svg diff --git a/novelwriter/assets/icons/typicons_dark/typ_search-grey.svg b/novelwriter/assets/icons/typicons_dark/typ_search-grey.svg new file mode 100644 index 00000000..5a8a7330 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_search-grey.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 9763734e..57ad7afa 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -108,6 +108,7 @@ view_build = typ_export-grey.svg view_editor = mixed_edit.svg view_novel = typ_book-grey.svg view_outline = typ_puzzle-outline.svg +view_search = typ_search-grey.svg deco_doc_h0 = nw_deco-h0.svg deco_doc_h0_n = nw_deco-h0.svg diff --git a/novelwriter/assets/icons/typicons_light/typ_search-grey.svg b/novelwriter/assets/icons/typicons_light/typ_search-grey.svg new file mode 100644 index 00000000..e6249537 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_search-grey.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/config.py b/novelwriter/config.py index 85786119..670433a0 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -183,17 +183,19 @@ class Config: # State self.showViewerPanel = True # The panel for the viewer is visible self.showEditToolBar = False # The document editor toolbar visibility - self.useShortcodes = False # Use shortcodes for basic formatting self.viewComments = True # Comments are shown in the viewer self.viewSynopsis = True # Synopsis is shown in the viewer - # Search Bar Switches - self.searchCase = False - self.searchWord = False - self.searchRegEx = False - self.searchLoop = False - self.searchNextFile = False - self.searchMatchCap = False + # Search Box States + self.searchCase = False + self.searchWord = False + self.searchRegEx = False + self.searchLoop = False + self.searchNextFile = False + self.searchMatchCap = False + self.searchProjCase = False + self.searchProjWord = False + self.searchProjRegEx = False # System and App Information # ========================== @@ -619,7 +621,6 @@ class Config: sec = "State" self.showViewerPanel = conf.rdBool(sec, "showviewerpanel", self.showViewerPanel) self.showEditToolBar = conf.rdBool(sec, "showedittoolbar", self.showEditToolBar) - self.useShortcodes = conf.rdBool(sec, "useshortcodes", self.useShortcodes) self.viewComments = conf.rdBool(sec, "viewcomments", self.viewComments) self.viewSynopsis = conf.rdBool(sec, "viewsynopsis", self.viewSynopsis) self.searchCase = conf.rdBool(sec, "searchcase", self.searchCase) @@ -628,6 +629,9 @@ class Config: self.searchLoop = conf.rdBool(sec, "searchloop", self.searchLoop) self.searchNextFile = conf.rdBool(sec, "searchnextfile", self.searchNextFile) self.searchMatchCap = conf.rdBool(sec, "searchmatchcap", self.searchMatchCap) + self.searchProjCase = conf.rdBool(sec, "searchprojcase", self.searchProjCase) + self.searchProjWord = conf.rdBool(sec, "searchprojword", self.searchProjWord) + self.searchProjRegEx = conf.rdBool(sec, "searchprojregex", self.searchProjRegEx) # Check Values # ============ @@ -725,7 +729,6 @@ class Config: conf["State"] = { "showviewerpanel": str(self.showViewerPanel), "showedittoolbar": str(self.showEditToolBar), - "useshortcodes": str(self.useShortcodes), "viewcomments": str(self.viewComments), "viewsynopsis": str(self.viewSynopsis), "searchcase": str(self.searchCase), @@ -734,6 +737,9 @@ class Config: "searchloop": str(self.searchLoop), "searchnextfile": str(self.searchNextFile), "searchmatchcap": str(self.searchMatchCap), + "searchprojcase": str(self.searchProjCase), + "searchprojword": str(self.searchProjWord), + "searchprojregex": str(self.searchProjRegEx), } # Write config file diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 96e3db99..a1e2b84a 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -53,6 +53,7 @@ class nwConst: # Gui Settings STATUS_MSG_TIMEOUT = 15000 # milliseconds + MAX_SEARCH_RESULT = 1000 # Dialogs DLG_FINISHED = 2 diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index a4e3e3aa..7be58fc0 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -26,19 +26,20 @@ along with this program. If not, see . """ from __future__ import annotations -import shutil import logging +import shutil -from pathlib import Path -from functools import partial -from zipfile import ZipFile, is_zipfile from collections.abc import Iterable +from functools import partial +from pathlib import Path +from time import time +from zipfile import ZipFile, is_zipfile -from PyQt5.QtCore import QCoreApplication +from PyQt5.QtCore import QCoreApplication, QRegularExpression from novelwriter import CONFIG, SHARED from novelwriter.common import isHandle, minmax, simplified -from novelwriter.constants import nwFiles, nwItemClass +from novelwriter.constants import nwConst, nwFiles, nwItemClass from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.core.storage import NWStorageCreate @@ -304,6 +305,111 @@ class DocDuplicator: # END Class DocDuplicator +class DocSearch: + + def __init__(self) -> None: + # RegEx Object + self._regEx = QRegularExpression() + self.setCaseSensitive(False) + self._words = False + self._escape = True + + # Project Cache + self._uuid = "" + self._time = 0.0 + self._cache: dict[str, str] = {} + + return + + ## + # Methods + ## + + def setCaseSensitive(self, state: bool) -> None: + """Set the case sensitive search flag.""" + opts = QRegularExpression.PatternOption.UseUnicodePropertiesOption + if not state: + opts |= QRegularExpression.PatternOption.CaseInsensitiveOption + self._regEx.setPatternOptions(opts) + return + + def setWholeWords(self, state: bool) -> None: + """Set the whole words search flag.""" + self._words = state + return + + def setUserRegEx(self, state: bool) -> None: + """Set the escape flag to the opposite state.""" + self._escape = not state + return + + def iterSearch( + self, project: NWProject, search: str + ) -> Iterable[tuple[NWItem, list[tuple[int, int, str]], bool]]: + """Iteratively search through documents in a project.""" + if project.data.uuid != self._uuid or time() - self._time > 20.0: + self._cache = {} + + self._uuid = project.data.uuid + self._time = time() + self._regEx.setPattern(self._buildPattern(search)) + logger.debug("Searching with pattern '%s'", self._regEx.pattern()) + + num = len(search) + storage = project.storage + for item in project.tree: + if item.isFileType(): + tHandle = item.itemHandle + if (text := self._cache.get(tHandle)) is None: + text = storage.getDocument(tHandle).readDocument() or "" + self._cache[tHandle] = text + + rxItt = self._regEx.globalMatch(text) + count = 0 + capped = False + results = [] + while rxItt.hasNext(): + rxMatch = rxItt.next() + pos = rxMatch.capturedStart() + num = rxMatch.capturedLength() + context = text[pos:pos+100].partition("\n")[0] + if context: + results.append((pos, num, context)) + count += 1 + if count >= nwConst.MAX_SEARCH_RESULT: + capped = True + break + + yield item, results, capped + + return + + ## + # Internal Functions + ## + + def _buildPattern(self, search: str) -> str: + """Build the search pattern string.""" + if self._escape: + if CONFIG.verQtValue >= 0x050f00: + search = QRegularExpression.escape(search) + else: + # For older Qt versions, we escape manually + escaped = "" + for c in search: + if c.isalnum() or c == "_": + escaped += c + else: + escaped += f"\\{c}" + search = escaped + if self._words: + search = search if search.startswith("\\b") else f"\\b{search}" + search = search if search.endswith("\\b") else f"{search}\\b" + return search + +# END Class DocSearch + + class ProjectBuilder: """A class to build a new project from a set of user-defined parameter provided by the New Project Wizard. diff --git a/novelwriter/enum.py b/novelwriter/enum.py index b7e38e40..d905e587 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -155,6 +155,7 @@ class nwView(Enum): PROJECT = 1 NOVEL = 2 OUTLINE = 3 + SEARCH = 4 # END Enum nwView diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 8cd72429..7c8c6747 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -54,7 +54,7 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.common import minmax, transferCase -from novelwriter.constants import nwKeyWords, nwShortcode, nwUnicode +from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode from novelwriter.core.document import NWDocument from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary from novelwriter.extensions.eventfilters import WheelEventFilter @@ -603,13 +603,15 @@ class GuiDocEditor(QPlainTextEdit): # Setters ## - def setDocumentChanged(self, state: bool) -> bool: + def setDocumentChanged(self, state: bool) -> None: """Keep track of the document changed variable, and emit the document change signal. """ - self._docChanged = state - self.editedStatusChanged.emit(self._docChanged) - return self._docChanged + if self._docChanged != state: + logger.debug("Document changed status is '%s'", state) + self._docChanged = state + self.editedStatusChanged.emit(self._docChanged) + return def setCursorPosition(self, position: int) -> None: """Move the cursor to a given position in the document.""" @@ -637,6 +639,14 @@ class GuiDocEditor(QPlainTextEdit): logger.debug("Cursor moved to line %d", line) return + def setCursorSelection(self, selStart: int, selLength: int) -> None: + """Make a text selection.""" + cursor = self.textCursor() + cursor.setPosition(selStart, QTextCursor.MoveMode.MoveAnchor) + cursor.setPosition(selStart + selLength, QTextCursor.MoveMode.KeepAnchor) + self.setTextCursor(cursor) + return + ## # Spell Checking ## @@ -791,11 +801,7 @@ class GuiDocEditor(QPlainTextEdit): def anyFocus(self) -> bool: """Check if any widget or child widget has focus.""" - if self.hasFocus(): - return True - if self.isAncestorOf(qApp.focusWidget()): - return True - return False + return self.hasFocus() or self.isAncestorOf(qApp.focusWidget()) def revealLocation(self) -> None: """Tell the user where on the file system the file in the editor @@ -1368,9 +1374,10 @@ class GuiDocEditor(QPlainTextEdit): cursor.setPosition(0) self.setTextCursor(cursor) - # Search up to a maximum of 1000, and make sure certain special - # searches like a regex search for .* don't loop infinitely - while self.find(searchFor, findOpt) and len(resE) <= 1000: + # Search up to a maximum of MAX_SEARCH_RESULT, and make sure + # certain special searches like a regex search for .* don't loop + # infinitely + while self.find(searchFor, findOpt) and len(resE) <= nwConst.MAX_SEARCH_RESULT: cursor = self.textCursor() if cursor.hasSelection(): resS.append(cursor.selectionStart()) @@ -2609,8 +2616,10 @@ class GuiDocEditSearch(QFrame): def setResultCount(self, currRes: int | None, resCount: int | None) -> None: """Set the count values for the current search.""" + lim = nwConst.MAX_SEARCH_RESULT + numCount = f"{lim:n}+" if (resCount or 0) > lim else f"{resCount:n}" sCurrRes = "?" if currRes is None else str(currRes) - sResCount = "?" if resCount is None else "1000+" if resCount > 1000 else str(resCount) + sResCount = "?" if resCount is None else numCount minWidth = SHARED.theme.getTextWidth(f"{sResCount}//{sResCount}", self.boxFont) self.resultLabel.setText(f"{sCurrRes}/{sResCount}") self.resultLabel.setMinimumWidth(minWidth) diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py index 2c9a99ff..27c3a017 100644 --- a/novelwriter/gui/itemdetails.py +++ b/novelwriter/gui/itemdetails.py @@ -48,7 +48,7 @@ class GuiItemDetails(QWidget): logger.debug("Create: GuiItemDetails") # Internal Variables - self._itemHandle = None + self._handle = None # Sizes hSp = CONFIG.pxInt(6) @@ -194,7 +194,7 @@ class GuiItemDetails(QWidget): def clearDetails(self) -> None: """Clear all the data values.""" - self._itemHandle = None + self._handle = None self.labelIcon.clear() self.labelData.clear() self.statusIcon.clear() @@ -210,11 +210,11 @@ class GuiItemDetails(QWidget): def refreshDetails(self) -> None: """Reload the content of the details panel.""" - self.updateViewBox(self._itemHandle) + self.updateViewBox(self._handle) def updateTheme(self) -> None: """Update theme elements.""" - self.updateViewBox(self._itemHandle) + self.updateViewBox(self._handle) return ## @@ -233,7 +233,7 @@ class GuiItemDetails(QWidget): self.clearDetails() return - self._itemHandle = tHandle + self._handle = tHandle iPx = int(round(0.8*SHARED.theme.baseIconSize)) # Label @@ -295,7 +295,7 @@ class GuiItemDetails(QWidget): """Update the counts if the handle is the same as the one we're already showing. Otherwise, do nothing. """ - if tHandle == self._itemHandle: + if tHandle == self._handle: self.cCountData.setText(f"{cC:n}") self.wCountData.setText(f"{wC:n}") self.pCountData.setText(f"{pC:n}") diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 98f1fb9d..4a9c198d 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -33,9 +33,9 @@ from PyQt5.QtCore import QUrl, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import QMenuBar, QAction from novelwriter import CONFIG, SHARED -from novelwriter.enum import nwDocAction, nwDocInsert, nwWidget from novelwriter.common import openExternalPath from novelwriter.constants import nwConst, trConst, nwKeyWords, nwLabels, nwUnicode +from novelwriter.enum import nwDocAction, nwDocInsert, nwView, nwWidget from novelwriter.extensions.eventfilters import StatusTipFilter if TYPE_CHECKING: # pragma: no cover @@ -55,6 +55,7 @@ class GuiMainMenu(QMenuBar): requestDocInsertText = pyqtSignal(str) requestDocKeyWordInsert = pyqtSignal(str) requestFocusChange = pyqtSignal(nwWidget) + requestViewChange = pyqtSignal(nwView) def __init__(self, mainGui: GuiMain) -> None: super().__init__(parent=mainGui) @@ -861,6 +862,14 @@ class GuiMainMenu(QMenuBar): self.aReplaceNext.setShortcut("Ctrl+Shift+1") self.aReplaceNext.triggered.connect(lambda: self.mainGui.docEditor.replaceNext()) + # Search > Separator + self.srcMenu.addSeparator() + + # Search > Find in Project + self.aFindProj = self.srcMenu.addAction(self.tr("Find in Project")) + self.aFindProj.setShortcut("Ctrl+Shift+F") + self.aFindProj.triggered.connect(lambda: self.requestViewChange.emit(nwView.SEARCH)) + return def _buildToolsMenu(self) -> None: diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index 35247799..8bb7cc0c 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -798,14 +798,20 @@ class GuiOutlineDetails(QScrollArea): hSpace = int(CONFIG.pxInt(10)) vSpace = int(CONFIG.pxInt(4)) + bFont = SHARED.theme.guiFontB + # Details Area - self.titleLabel = QLabel("%s" % self.tr("Title")) - self.fileLabel = QLabel("%s" % self.tr("Document")) - self.itemLabel = QLabel("%s" % self.tr("Status")) + self.titleLabel = QLabel(self.tr("Title")) + self.fileLabel = QLabel(self.tr("Document")) + self.itemLabel = QLabel(self.tr("Status")) self.titleValue = QLabel("") self.fileValue = QLabel("") self.itemValue = QLabel("") + self.titleLabel.setFont(bFont) + self.fileLabel.setFont(bFont) + self.itemLabel.setFont(bFont) + self.titleValue.setMinimumWidth(minTitle) self.titleValue.setMaximumWidth(maxTitle) self.fileValue.setMinimumWidth(minTitle) @@ -814,13 +820,17 @@ class GuiOutlineDetails(QScrollArea): self.itemValue.setMaximumWidth(maxTitle) # Stats Area - self.cCLabel = QLabel("%s" % self.tr("Characters")) - self.wCLabel = QLabel("%s" % self.tr("Words")) - self.pCLabel = QLabel("%s" % self.tr("Paragraphs")) + self.cCLabel = QLabel(self.tr("Characters")) + self.wCLabel = QLabel(self.tr("Words")) + self.pCLabel = QLabel(self.tr("Paragraphs")) self.cCValue = QLabel("") self.wCValue = QLabel("") self.pCValue = QLabel("") + self.cCLabel.setFont(bFont) + self.wCLabel.setFont(bFont) + self.pCLabel.setFont(bFont) + self.cCValue.setMinimumWidth(wCount) self.wCValue.setMinimumWidth(wCount) self.pCValue.setMinimumWidth(wCount) @@ -829,23 +839,36 @@ class GuiOutlineDetails(QScrollArea): self.pCValue.setAlignment(Qt.AlignRight) # Synopsis - self.synopLabel = QLabel("%s" % self.tr("Synopsis")) + self.synopLabel = QLabel(self.tr("Synopsis")) + self.synopLabel.setFont(bFont) + self.synopValue = QLabel("") - self.synopLWrap = QHBoxLayout() self.synopValue.setWordWrap(True) self.synopValue.setAlignment(Qt.AlignTop | Qt.AlignLeft) + + self.synopLWrap = QHBoxLayout() self.synopLWrap.addWidget(self.synopValue, 1) # Tags - self.povKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])) - self.focKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])) - self.chrKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY])) - self.pltKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])) - self.timKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY])) - self.wldKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY])) - self.objKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY])) - self.entKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY])) - self.cstKeyLabel = QLabel("%s" % trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY])) + self.povKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])) + self.focKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY])) + self.chrKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY])) + self.pltKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY])) + self.timKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY])) + self.wldKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY])) + self.objKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY])) + self.entKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY])) + self.cstKeyLabel = QLabel(trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY])) + + self.povKeyLabel.setFont(bFont) + self.focKeyLabel.setFont(bFont) + self.chrKeyLabel.setFont(bFont) + self.pltKeyLabel.setFont(bFont) + self.timKeyLabel.setFont(bFont) + self.wldKeyLabel.setFont(bFont) + self.objKeyLabel.setFont(bFont) + self.entKeyLabel.setFont(bFont) + self.cstKeyLabel.setFont(bFont) self.povKeyLWrap = QHBoxLayout() self.focKeyLWrap = QHBoxLayout() @@ -989,7 +1012,7 @@ class GuiOutlineDetails(QScrollArea): def clearDetails(self) -> None: """Clear all the data labels.""" - self.titleLabel.setText("%s" % self.tr("Title")) + self.titleLabel.setText(self.tr("Title")) self.titleValue.setText("") self.fileValue.setText("") self.itemValue.setText("") @@ -1023,7 +1046,7 @@ class GuiOutlineDetails(QScrollArea): novIdx = pIndex.getItemHeading(tHandle, sTitle) novRefs = pIndex.getReferences(tHandle, sTitle) if nwItem and novIdx: - self.titleLabel.setText("%s" % self.tr(self.LVL_MAP.get(novIdx.level, "H1"))) + self.titleLabel.setText(self.tr(self.LVL_MAP.get(novIdx.level, "H1"))) self.titleValue.setText(novIdx.title) itemStatus, _ = nwItem.getImportStatus(incIcon=False) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 7299aff0..d78b84d5 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -270,7 +270,8 @@ class GuiProjectToolBar(QWidget): self.setAutoFillBackground(True) # Widget Label - self.viewLabel = QLabel("%s" % self.tr("Project Content")) + self.viewLabel = QLabel(self.tr("Project Content")) + self.viewLabel.setFont(SHARED.theme.guiFontB) self.viewLabel.setContentsMargins(0, 0, 0, 0) self.viewLabel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py new file mode 100644 index 00000000..27ef4b4b --- /dev/null +++ b/novelwriter/gui/search.py @@ -0,0 +1,316 @@ +""" +novelWriter – GUI Project Search +================================ + +File History: +Created: 2024-03-21 [2.4b1] GuiProjectSearch + +This file is a part of novelWriter +Copyright 2018–2024, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import logging + +from time import time + +from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot +from PyQt5.QtGui import QCursor, QKeyEvent, QPalette +from PyQt5.QtWidgets import ( + QHBoxLayout, QHeaderView, QLabel, QLineEdit, QToolBar, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget, qApp +) + +from novelwriter import CONFIG, SHARED +from novelwriter.common import checkInt +from novelwriter.core.coretools import DocSearch +from novelwriter.core.item import NWItem + +logger = logging.getLogger(__name__) + +CACHE_TIMEOUT = 120.0 # 2 minutes + + +class GuiProjectSearch(QWidget): + + C_NAME = 0 + C_RESULT = 0 + C_COUNT = 1 + + D_HANDLE = Qt.ItemDataRole.UserRole + D_RESULT = Qt.ItemDataRole.UserRole + 1 + + selectedItemChanged = pyqtSignal(str) + openDocumentSelectRequest = pyqtSignal(str, int, int, bool) + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent=parent) + + logger.debug("Create: GuiProjectSearch") + + iPx = SHARED.theme.baseIconSize + mPx = CONFIG.pxInt(2) + + self._time = time() + self._search = DocSearch() + self._blocked = False + + # Header + self.viewLabel = QLabel(self.tr("Project Search")) + self.viewLabel.setFont(SHARED.theme.guiFontB) + self.viewLabel.setContentsMargins(mPx, mPx, 0, mPx) + + # Options + self.searchOpt = QToolBar(self) + self.searchOpt.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + self.searchOpt.setIconSize(QSize(iPx, iPx)) + self.searchOpt.setContentsMargins(0, 0, 0, 0) + + self.toggleCase = self.searchOpt.addAction(self.tr("Case Sensitive")) + self.toggleCase.setCheckable(True) + self.toggleCase.setChecked(CONFIG.searchProjCase) + self.toggleCase.toggled.connect(self._toggleCase) + + self.toggleWord = self.searchOpt.addAction(self.tr("Whole Words Only")) + self.toggleWord.setCheckable(True) + self.toggleWord.setChecked(CONFIG.searchProjWord) + self.toggleWord.toggled.connect(self._toggleWord) + + self.toggleRegEx = self.searchOpt.addAction(self.tr("RegEx Mode")) + self.toggleRegEx.setCheckable(True) + self.toggleRegEx.setChecked(CONFIG.searchProjRegEx) + self.toggleRegEx.toggled.connect(self._toggleRegEx) + + # Search Box + self.searchText = QLineEdit(self) + self.searchText.setPlaceholderText(self.tr("Search text ...")) + self.searchText.setClearButtonEnabled(True) + + self.searchAction = self.searchText.addAction( + SHARED.theme.getIcon("search"), QLineEdit.ActionPosition.TrailingPosition + ) + self.searchAction.triggered.connect(self._processSearch) + + # Search Result + self.searchResult = QTreeWidget(self) + self.searchResult.setHeaderHidden(True) + self.searchResult.setColumnCount(2) + self.searchResult.setIconSize(QSize(iPx, iPx)) + self.searchResult.setIndentation(iPx) + self.searchResult.itemDoubleClicked.connect(self._searchResultDoubleClicked) + self.searchResult.itemSelectionChanged.connect(self._searchResultSelected) + + treeHeader = self.searchResult.header() + treeHeader.setStretchLastSection(False) + treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch) + treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeMode.ResizeToContents) + + # Assemble + self.headerBox = QHBoxLayout() + self.headerBox.addWidget(self.viewLabel, 1) + self.headerBox.addWidget(self.searchOpt, 0) + self.headerBox.setContentsMargins(0, 0, 0, 0) + + self.outerBox = QVBoxLayout() + self.outerBox.addLayout(self.headerBox, 0) + self.outerBox.addWidget(self.searchText, 0) + self.outerBox.addWidget(self.searchResult, 1) + self.outerBox.setContentsMargins(0, 0, 0, 0) + self.outerBox.setSpacing(mPx) + + self.setLayout(self.outerBox) + self.updateTheme() + + logger.debug("Ready: GuiProjectSearch") + + return + + ## + # Methods + ## + + def updateTheme(self) -> None: + """Update theme elements.""" + qPalette = self.palette() + qPalette.setBrush(QPalette.ColorRole.Window, qPalette.base()) + self.setPalette(qPalette) + + self.searchAction.setIcon(SHARED.theme.getIcon("search")) + self.toggleCase.setIcon(SHARED.theme.getIcon("search_case")) + self.toggleWord.setIcon(SHARED.theme.getIcon("search_word")) + self.toggleRegEx.setIcon(SHARED.theme.getIcon("search_regex")) + + return + + def processReturn(self) -> None: + """Process a return keypress forwarded from the main GUI.""" + if self.searchText.hasFocus(): + self._processSearch() + elif ( + self.searchResult.hasFocus() + and (items := self.searchResult.selectedItems()) + and (data := items[0].data(0, self.D_RESULT)) + and len(data) == 3 + ): + self.openDocumentSelectRequest.emit( + str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1), False + ) + return + + def beginSearch(self) -> None: + """Focus the search box and select its text, if any.""" + self.searchText.setFocus() + self.searchText.selectAll() + return + + def closeProjectTasks(self) -> None: + """Run close project tasks.""" + self.searchText.clear() + self.searchResult.clear() + return + + ## + # Events + ## + + def keyPressEvent(self, event: QKeyEvent) -> None: + """Process key press events. This handles up and down arrow key + presses to jump between search text box and result tree. + """ + if ( + event.key() == Qt.Key.Key_Down + and self.searchText.hasFocus() + and (first := self.searchResult.topLevelItem(0)) + ): + first.setSelected(True) + self.searchResult.setFocus() + elif ( + event.key() == Qt.Key.Key_Up + and self.searchResult.hasFocus() + and (first := self.searchResult.topLevelItem(0)) + and first.isSelected() + ): + first.setSelected(False) + self.searchText.setFocus() + else: + super().keyPressEvent(event) + return + + ## + # Private Slots + ## + + @pyqtSlot() + def _processSearch(self) -> None: + """Perform a search.""" + if not self._blocked: + qApp.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) + start = time() + self._blocked = True + self.searchResult.clear() + if text := self.searchText.text(): + self._search.setUserRegEx(self.toggleRegEx.isChecked()) + self._search.setCaseSensitive(self.toggleCase.isChecked()) + self._search.setWholeWords(self.toggleWord.isChecked()) + for item, results, capped in self._search.iterSearch(SHARED.project, text): + self._appendResultSet(item, results, capped) + logger.debug("Search took %.3f ms", 1000*(time() - start)) + self._time = time() + qApp.restoreOverrideCursor() + self._blocked = False + return + + @pyqtSlot() + def _searchResultSelected(self) -> None: + """Process search result selection.""" + if items := self.searchResult.selectedItems(): + if (data := items[0].data(0, self.D_RESULT)) and len(data) == 3: + self.selectedItemChanged.emit(str(data[0])) + elif data := items[0].data(0, self.D_HANDLE): + self.selectedItemChanged.emit(str(data)) + return + + @pyqtSlot("QTreeWidgetItem*", int) + def _searchResultDoubleClicked(self, item: QTreeWidgetItem, column: int) -> None: + """Process search result double click.""" + if (data := item.data(0, self.D_RESULT)) and len(data) == 3: + self.openDocumentSelectRequest.emit( + str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1), True + ) + return + + @pyqtSlot(bool) + def _toggleCase(self, state: bool) -> None: + """Enable/disable case sensitive mode.""" + CONFIG.searchProjCase = state + return + + @pyqtSlot(bool) + def _toggleWord(self, state: bool) -> None: + """Enable/disable whole word search mode.""" + CONFIG.searchProjWord = state + return + + @pyqtSlot(bool) + def _toggleRegEx(self, state: bool) -> None: + """Enable/disable regular expression search mode.""" + CONFIG.searchProjRegEx = state + return + + ## + # Internal Functions + ## + + def _appendResultSet( + self, nwItem: NWItem, results: list[tuple[int, int, str]], capped: bool + ) -> None: + """Populate the result tree.""" + if results: + tHandle = nwItem.itemHandle + docIcon = SHARED.theme.getItemIcon( + nwItem.itemType, nwItem.itemClass, + nwItem.itemLayout, nwItem.mainHeading + ) + ext = "+" if capped else "" + + tItem = QTreeWidgetItem() + tItem.setText(self.C_NAME, nwItem.itemName) + tItem.setIcon(self.C_NAME, docIcon) + tItem.setData(self.C_NAME, self.D_HANDLE, tHandle) + tItem.setText(self.C_COUNT, f"({len(results):n}{ext})") + tItem.setTextAlignment(self.C_COUNT, Qt.AlignmentFlag.AlignRight) + tItem.setForeground(self.C_COUNT, self.palette().highlight()) + self.searchResult.addTopLevelItem(tItem) + + rItems = [] + for start, length, context in results: + rItem = QTreeWidgetItem() + rItem.setText(0, context) + rItem.setData(0, self.D_RESULT, (tHandle, start, length)) + rItems.append(rItem) + + tItem.addChildren(rItems) + tItem.setExpanded(True) + + parent = self.searchResult.indexFromItem(tItem) + for i in range(tItem.childCount()): + self.searchResult.setFirstColumnSpanned(i, parent, True) + + qApp.processEvents() + + return + +# END Class GuiProjectSearch diff --git a/novelwriter/gui/sidebar.py b/novelwriter/gui/sidebar.py index 9497444d..dfc936c5 100644 --- a/novelwriter/gui/sidebar.py +++ b/novelwriter/gui/sidebar.py @@ -45,7 +45,7 @@ logger = logging.getLogger(__name__) class GuiSideBar(QWidget): - viewChangeRequested = pyqtSignal(nwView) + requestViewChange = pyqtSignal(nwView) def __init__(self, mainGui: GuiMain) -> None: super().__init__(parent=mainGui) @@ -61,15 +61,19 @@ class GuiSideBar(QWidget): # Buttons self.tbProject = NIconToolButton(self, iPx) self.tbProject.setToolTip("{0} [Ctrl+T]".format(self.tr("Project Tree View"))) - self.tbProject.clicked.connect(lambda: self.viewChangeRequested.emit(nwView.PROJECT)) + self.tbProject.clicked.connect(lambda: self.requestViewChange.emit(nwView.PROJECT)) self.tbNovel = NIconToolButton(self, iPx) self.tbNovel.setToolTip("{0} [Ctrl+T]".format(self.tr("Novel Tree View"))) - self.tbNovel.clicked.connect(lambda: self.viewChangeRequested.emit(nwView.NOVEL)) + self.tbNovel.clicked.connect(lambda: self.requestViewChange.emit(nwView.NOVEL)) + + self.tbSearch = NIconToolButton(self, iPx) + self.tbSearch.setToolTip("{0} [Ctrl+Shift+F]".format(self.tr("Search Project"))) + self.tbSearch.clicked.connect(lambda: self.requestViewChange.emit(nwView.SEARCH)) self.tbOutline = NIconToolButton(self, iPx) self.tbOutline.setToolTip("{0} [Ctrl+Shift+T]".format(self.tr("Novel Outline View"))) - self.tbOutline.clicked.connect(lambda: self.viewChangeRequested.emit(nwView.OUTLINE)) + self.tbOutline.clicked.connect(lambda: self.requestViewChange.emit(nwView.OUTLINE)) self.tbBuild = NIconToolButton(self, iPx) self.tbBuild.setToolTip("{0} [F5]".format(self.tr("Build Manuscript"))) @@ -99,6 +103,7 @@ class GuiSideBar(QWidget): self.outerBox = QVBoxLayout() self.outerBox.addWidget(self.tbProject) self.outerBox.addWidget(self.tbNovel) + self.outerBox.addWidget(self.tbSearch) self.outerBox.addWidget(self.tbOutline) self.outerBox.addWidget(self.tbBuild) self.outerBox.addStretch(1) @@ -129,6 +134,9 @@ class GuiSideBar(QWidget): self.tbNovel.setIcon(SHARED.theme.getIcon("view_novel")) self.tbNovel.setStyleSheet(buttonStyle) + self.tbSearch.setIcon(SHARED.theme.getIcon("view_search")) + self.tbSearch.setStyleSheet(buttonStyle) + self.tbOutline.setIcon(SHARED.theme.getIcon("view_outline")) self.tbOutline.setStyleSheet(buttonStyle) diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 8ffe396e..f40642c4 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -152,6 +152,8 @@ class GuiTheme: # Fonts self.guiFont = qApp.font() + self.guiFontB = qApp.font() + self.guiFontB.setBold(True) qMetric = QFontMetrics(self.guiFont) self.fontPointSize = self.guiFont.pointSizeF() @@ -486,7 +488,7 @@ class GuiIcons: "build_excluded", "build_filtered", "build_included", "proj_chapter", "proj_details", "proj_document", "proj_folder", "proj_note", "proj_nwx", "proj_section", "proj_scene", "proj_stats", "proj_title", "status_idle", "status_lang", "status_lines", "status_stats", - "status_time", "view_build", "view_editor", "view_novel", "view_outline", + "status_time", "view_build", "view_editor", "view_novel", "view_outline", "view_search", # Class Icons "cls_archive", "cls_character", "cls_custom", "cls_entity", "cls_none", "cls_novel", diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 21db48b6..28f58905 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -38,33 +38,31 @@ from PyQt5.QtWidgets import ( ) from novelwriter import CONFIG, SHARED, __hexversion__, __version__ +from novelwriter.common import formatFileFilter, formatVersion, hexToInt from novelwriter.constants import nwConst -from novelwriter.gui.theme import GuiTheme -from novelwriter.gui.sidebar import GuiSideBar -from novelwriter.gui.outline import GuiOutlineView -from novelwriter.gui.mainmenu import GuiMainMenu -from novelwriter.gui.projtree import GuiProjectView -from novelwriter.gui.doceditor import GuiDocEditor -from novelwriter.gui.docviewer import GuiDocViewer -from novelwriter.gui.noveltree import GuiNovelView -from novelwriter.gui.statusbar import GuiMainStatus -from novelwriter.gui.itemdetails import GuiItemDetails -from novelwriter.gui.docviewerpanel import GuiDocViewerPanel from novelwriter.dialogs.about import GuiAbout -from novelwriter.dialogs.wordlist import GuiWordList from novelwriter.dialogs.preferences import GuiPreferences from novelwriter.dialogs.projectsettings import GuiProjectSettings -from novelwriter.tools.welcome import GuiWelcome -from novelwriter.tools.manuscript import GuiManuscript +from novelwriter.dialogs.wordlist import GuiWordList +from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemType, nwWidget, nwView +from novelwriter.gui.doceditor import GuiDocEditor +from novelwriter.gui.docviewer import GuiDocViewer +from novelwriter.gui.docviewerpanel import GuiDocViewerPanel +from novelwriter.gui.itemdetails import GuiItemDetails +from novelwriter.gui.mainmenu import GuiMainMenu +from novelwriter.gui.noveltree import GuiNovelView +from novelwriter.gui.outline import GuiOutlineView +from novelwriter.gui.projtree import GuiProjectView +from novelwriter.gui.search import GuiProjectSearch +from novelwriter.gui.sidebar import GuiSideBar +from novelwriter.gui.statusbar import GuiMainStatus +from novelwriter.gui.theme import GuiTheme from novelwriter.tools.dictionaries import GuiDictionaries +from novelwriter.tools.manuscript import GuiManuscript from novelwriter.tools.noveldetails import GuiNovelDetails +from novelwriter.tools.welcome import GuiWelcome from novelwriter.tools.writingstats import GuiWritingStats -from novelwriter.enum import ( - nwDocAction, nwDocInsert, nwDocMode, nwItemType, nwWidget, nwView -) -from novelwriter.common import formatFileFilter, formatVersion, hexToInt - logger = logging.getLogger(__name__) @@ -123,6 +121,7 @@ class GuiMain(QMainWindow): # Main GUI Elements self.mainStatus = GuiMainStatus(self) self.projView = GuiProjectView(self) + self.projSearch = GuiProjectSearch(self) self.novelView = GuiNovelView(self) self.docEditor = GuiDocEditor(self) self.docViewer = GuiDocViewer(self) @@ -136,6 +135,7 @@ class GuiMain(QMainWindow): self.projStack = QStackedWidget(self) self.projStack.addWidget(self.projView) self.projStack.addWidget(self.novelView) + self.projStack.addWidget(self.projSearch) self.projStack.currentChanged.connect(self._projStackChanged) # Project Tree View @@ -154,6 +154,8 @@ class GuiMain(QMainWindow): self.splitView.setHandleWidth(hWd) self.splitView.setOpaqueResize(False) self.splitView.setSizes(CONFIG.viewPanePos) + self.splitView.setCollapsible(0, False) + self.splitView.setCollapsible(1, False) # Splitter : Document Editor / Document Viewer self.splitDocs = QSplitter(Qt.Horizontal, self) @@ -161,6 +163,8 @@ class GuiMain(QMainWindow): self.splitDocs.addWidget(self.splitView) self.splitDocs.setOpaqueResize(False) self.splitDocs.setHandleWidth(hWd) + self.splitDocs.setCollapsible(0, False) + self.splitDocs.setCollapsible(1, False) # Splitter : Project Tree / Document Area self.splitMain = QSplitter(Qt.Horizontal) @@ -170,6 +174,10 @@ class GuiMain(QMainWindow): self.splitMain.setOpaqueResize(False) self.splitMain.setHandleWidth(hWd) self.splitMain.setSizes(CONFIG.mainPanePos) + self.splitMain.setCollapsible(0, False) + self.splitMain.setCollapsible(0, False) + self.splitMain.setStretchFactor(1, 0) + self.splitMain.setStretchFactor(1, 1) # Main Stack : Editor / Outline self.mainStack = QStackedWidget(self) @@ -177,31 +185,6 @@ class GuiMain(QMainWindow): self.mainStack.addWidget(self.outlineView) self.mainStack.currentChanged.connect(self._mainStackChanged) - # Indices of Splitter Widgets - self.idxTree = self.splitMain.indexOf(self.treePane) - self.idxMain = self.splitMain.indexOf(self.splitDocs) - self.idxEditor = self.splitDocs.indexOf(self.docEditor) - self.idxViewer = self.splitDocs.indexOf(self.splitView) - self.idxViewDoc = self.splitView.indexOf(self.docViewer) - self.idxViewDocPanel = self.splitView.indexOf(self.docViewerPanel) - - # Indices of Stack Widgets - self.idxEditorView = self.mainStack.indexOf(self.splitMain) - self.idxOutlineView = self.mainStack.indexOf(self.outlineView) - self.idxProjView = self.projStack.indexOf(self.projView) - self.idxNovelView = self.projStack.indexOf(self.novelView) - - # Splitter Behaviour - self.splitMain.setCollapsible(self.idxTree, False) - self.splitMain.setCollapsible(self.idxMain, False) - self.splitDocs.setCollapsible(self.idxEditor, False) - self.splitDocs.setCollapsible(self.idxViewer, False) - self.splitView.setCollapsible(self.idxViewDoc, False) - self.splitView.setCollapsible(self.idxViewDocPanel, False) - - self.splitMain.setStretchFactor(self.idxTree, 0) - self.splitMain.setStretchFactor(self.idxMain, 1) - # Editor / Viewer Default State self.splitView.setVisible(False) self.docEditor.closeSearch() @@ -237,14 +220,16 @@ class GuiMain(QMainWindow): SHARED.indexScannedText.connect(self.itemDetails.updateViewBox) SHARED.indexCleared.connect(self.docViewerPanel.indexWasCleared) SHARED.indexAvailable.connect(self.docViewerPanel.indexHasAppeared) + SHARED.mainClockTick.connect(self._timeTick) self.mainMenu.requestDocAction.connect(self._passDocumentAction) self.mainMenu.requestDocInsert.connect(self._passDocumentInsert) self.mainMenu.requestDocInsertText.connect(self._passDocumentInsert) self.mainMenu.requestDocKeyWordInsert.connect(self.docEditor.insertKeyWord) self.mainMenu.requestFocusChange.connect(self.switchFocus) + self.mainMenu.requestViewChange.connect(self._changeView) - self.sideBar.viewChangeRequested.connect(self._changeView) + self.sideBar.requestViewChange.connect(self._changeView) self.projView.selectedItemChanged.connect(self.itemDetails.updateViewBox) self.projView.openDocumentRequest.connect(self._openDocument) @@ -261,6 +246,9 @@ class GuiMain(QMainWindow): self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox) self.novelView.openDocumentRequest.connect(self._openDocument) + self.projSearch.openDocumentSelectRequest.connect(self._openDocumentSelection) + self.projSearch.selectedItemChanged.connect(self.itemDetails.updateViewBox) + self.docEditor.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus) self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts) self.docEditor.docCountsChanged.connect(self.projView.updateCounts) @@ -299,12 +287,6 @@ class GuiMain(QMainWindow): self.asDocTimer = QTimer(self) self.asDocTimer.timeout.connect(self._autoSaveDocument) - # Main Clock - self.mainTimer = QTimer(self) - self.mainTimer.setInterval(1000) - self.mainTimer.timeout.connect(self._timeTick) - self.mainTimer.start() - # Shortcuts and Actions self._connectMenuActions() @@ -408,6 +390,7 @@ class GuiMain(QMainWindow): self.outlineView.closeProjectTasks() self.novelView.closeProjectTasks() self.projView.closeProjectTasks() + self.projSearch.closeProjectTasks() self.itemDetails.clearDetails() self.mainStatus.clearStatus() @@ -1031,12 +1014,15 @@ class GuiMain(QMainWindow): self.novelView.setTreeFocus() else: self.projView.setTreeFocus() - else: + elif self.projStack.currentWidget() is self.novelView: if self.novelView.treeHasFocus(): self._changeView(nwView.PROJECT) self.projView.setTreeFocus() else: self.novelView.setTreeFocus() + else: + self._changeView(nwView.PROJECT) + self.projView.setTreeFocus() elif paneNo == nwWidget.EDITOR: self._changeView(nwView.EDITOR) self.docEditor.setFocus() @@ -1077,6 +1063,7 @@ class GuiMain(QMainWindow): self.sideBar.updateTheme() self.projView.updateTheme() self.novelView.updateTheme() + self.projSearch.updateTheme() self.outlineView.updateTheme() self.itemDetails.updateTheme() self.mainStatus.updateTheme() @@ -1147,6 +1134,15 @@ class GuiMain(QMainWindow): self.viewDocument(tHandle=tHandle, sTitle=sTitle) return + @pyqtSlot(str, int, int, bool) + def _openDocumentSelection( + self, tHandle: str, selStart: int, selLength: int, changeFocus: bool + ) -> None: + """Open a document and select a section of the text.""" + if self.openDocument(tHandle, changeFocus=changeFocus): + self.docEditor.setCursorSelection(selStart, selLength) + return + @pyqtSlot() def _reloadViewer(self) -> None: """Reload the document in the viewer.""" @@ -1168,6 +1164,10 @@ class GuiMain(QMainWindow): elif view == nwView.NOVEL: self.mainStack.setCurrentWidget(self.splitMain) self.projStack.setCurrentWidget(self.novelView) + elif view == nwView.SEARCH: + self.mainStack.setCurrentWidget(self.splitMain) + self.projStack.setCurrentWidget(self.projSearch) + self.projSearch.beginSearch() elif view == nwView.OUTLINE: self.mainStack.setCurrentWidget(self.outlineView) return @@ -1206,16 +1206,15 @@ class GuiMain(QMainWindow): @pyqtSlot() def _timeTick(self) -> None: """Process time tick of the main timer.""" - if not SHARED.hasProject: - return - currTime = time() - editIdle = currTime - self.docEditor.lastActive > CONFIG.userIdleTime - userIdle = qApp.applicationState() != Qt.ApplicationActive - self.mainStatus.setUserIdle(editIdle or userIdle) - SHARED.updateIdleTime(currTime, editIdle or userIdle) - self.mainStatus.updateTime(idleTime=SHARED.projectIdleTime) - if CONFIG.memInfo and int(currTime) % 5 == 0: # pragma: no cover - self.mainStatus.memInfo() + if SHARED.hasProject: + currTime = time() + editIdle = currTime - self.docEditor.lastActive > CONFIG.userIdleTime + userIdle = qApp.applicationState() != Qt.ApplicationActive + self.mainStatus.setUserIdle(editIdle or userIdle) + SHARED.updateIdleTime(currTime, editIdle or userIdle) + self.mainStatus.updateTime(idleTime=SHARED.projectIdleTime) + if CONFIG.memInfo and int(currTime) % 5 == 0: # pragma: no cover + self.mainStatus.memInfo() return @pyqtSlot() @@ -1257,15 +1256,16 @@ class GuiMain(QMainWindow): @pyqtSlot() def _keyPressReturn(self) -> None: - """Forward the return/enter keypress to the function that opens - the currently selected item. - """ - self.openSelectedItem() + """Process a return or enter keypress in the main window.""" + if self.projStack.currentWidget() == self.projSearch: + self.projSearch.processReturn() + else: + self.openSelectedItem() return @pyqtSlot() def _keyPressEscape(self) -> None: - """Process escape keypress in the main window.""" + """Process an escape keypress in the main window.""" if self.docEditor.docSearch.isVisible(): self.docEditor.closeSearch() elif SHARED.focusMode: @@ -1275,7 +1275,7 @@ class GuiMain(QMainWindow): @pyqtSlot(int) def _mainStackChanged(self, index: int) -> None: """Process main window tab change.""" - if index == self.idxOutlineView: + if self.mainStack.widget(index) == self.outlineView: if SHARED.hasProject: self.outlineView.refreshTree() return @@ -1284,9 +1284,10 @@ class GuiMain(QMainWindow): def _projStackChanged(self, index: int) -> None: """Process project view tab change.""" sHandle = None - if index == self.idxProjView: + widget = self.projStack.widget(index) + if widget == self.projView: sHandle = self.projView.getSelectedHandle() - elif index == self.idxNovelView: + elif widget == self.novelView: sHandle, _ = self.novelView.getSelectedHandle() self.itemDetails.updateViewBox(sHandle) return diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 31b3c88a..15b66a9e 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -30,7 +30,7 @@ from time import time from typing import TYPE_CHECKING, TypeVar from pathlib import Path -from PyQt5.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal +from PyQt5.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal from PyQt5.QtWidgets import QFileDialog, QMessageBox, QWidget from novelwriter.common import formatFileFilter @@ -62,6 +62,7 @@ class SharedData(QObject): indexChangedTags = pyqtSignal(list, list) indexCleared = pyqtSignal() indexAvailable = pyqtSignal() + mainClockTick = pyqtSignal() def __init__(self) -> None: super().__init__() @@ -79,6 +80,10 @@ class SharedData(QObject): self._idleRefTime = time() self._focusMode = False + self._clock = QTimer(self) + self._clock.setInterval(1000) + self._clock.timeout.connect(lambda: self.mainClockTick.emit()) + return ## @@ -158,6 +163,7 @@ class SharedData(QObject): soon as the Main GUI is created to ensure the SHARED singleton has the properties needed for operation. """ + self._clock.start() self._gui = gui self._theme = theme self._resetProject() diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index d9d7342e..977a10be 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -796,7 +796,7 @@ class _PreviewWidget(QTextBrowser): # Age Timer self.ageTimer = QTimer(self) - self.ageTimer.setInterval(10) + self.ageTimer.setInterval(10000) self.ageTimer.timeout.connect(self._updateBuildAge) self.ageTimer.start() diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf index 998394a0..4e218943 100644 --- a/tests/reference/baseConfig_novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -1,5 +1,5 @@ [Meta] -timestamp = 2024-02-09 12:05:00 +timestamp = 2024-03-25 11:57:35 [Main] theme = default @@ -69,7 +69,6 @@ useridletime = 300 [State] showviewerpanel = True showedittoolbar = False -useshortcodes = False viewcomments = True viewsynopsis = True searchcase = False @@ -78,4 +77,7 @@ searchregex = False searchloop = False searchnextfile = False searchmatchcap = False +searchprojcase = False +searchprojword = False +searchprojregex = False diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py index 85ebedfd..1e205156 100644 --- a/tests/test_core/test_core_coretools.py +++ b/tests/test_core/test_core_coretools.py @@ -32,9 +32,11 @@ from tools import C, NWD_IGNORE, buildTestProject, cmpFiles, XML_IGNORE from mocked import causeOSError from novelwriter import CONFIG -from novelwriter.constants import nwFiles, nwItemClass +from novelwriter.constants import nwConst, nwFiles, nwItemClass +from novelwriter.core.coretools import ( + DocDuplicator, DocMerger, DocSearch, DocSplitter, ProjectBuilder +) from novelwriter.core.project import NWProject -from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter, ProjectBuilder @pytest.mark.core @@ -401,6 +403,97 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd): # END Test testCoreTools_DocDuplicator +@pytest.mark.core +def testCoreTools_DocSearch(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText): + """Test the DocDuplicator utility.""" + project = NWProject() + mockRnd.reset() + buildTestProject(project, fncPath) + project.storage.getDocument(C.hSceneDoc).writeDocument( + "### New Scene\n\n" + "\n\n".join(ipsumText) + ) + + search = DocSearch() + + # Defaults + # ======== + + result = [(i.itemHandle, r, c) for i, r, c in search.iterSearch(project, "Scene")] + assert result[0] == (C.hTitlePage, [], False) + assert result[1] == (C.hChapterDoc, [], False) + assert result[2] == (C.hSceneDoc, [(8, 5, "Scene")], False) + + # Cache + assert list(search._cache.keys()) == [C.hTitlePage, C.hChapterDoc, C.hSceneDoc] + + # Patterns + # ======== + + # Escape Using QRegularExpression + with monkeypatch.context() as mp: + mp.setattr(CONFIG, "verQtValue", 0x050f00) + assert search._buildPattern("[A-Za-z0-9_]+") == r"\[A\-Za\-z0\-9_\]\+" + + # Escape Using Custom Implementation + with monkeypatch.context() as mp: + mp.setattr(CONFIG, "verQtValue", 0x050d00) + assert search._buildPattern("[A-Za-z0-9_]+") == r"\[A\-Za\-z0\-9_\]\+" + + # Whole Words + search.setWholeWords(True) + search.setUserRegEx(True) + assert search._buildPattern("Hi") == r"\bHi\b" + assert search._buildPattern(r"\bHi") == r"\bHi\b" + assert search._buildPattern(r"Hi\b") == r"\bHi\b" + assert search._buildPattern(r"\bHi\b") == r"\bHi\b" + search.setWholeWords(False) + search.setUserRegEx(False) + + # Test Settings + # ============= + + def pruneResult(result, index): + temp = [(i.itemHandle, r, c) for i, r, c in result][index][1] + return [(s, n, c.split()[0]) for s, n, c in temp] + + # Defaults + assert pruneResult(search.iterSearch(project, "Lorem"), 2) == [ + (15, 5, "Lorem"), (754, 5, "lorem"), (2056, 5, "lorem,"), (2209, 5, "lorem"), + (2425, 5, "lorem"), (2840, 5, "lorem."), (3399, 5, "lorem"), + ] + + # Whole Words + search.setWholeWords(True) + assert pruneResult(search.iterSearch(project, "Lor"), 2) == [] + search.setWholeWords(False) + assert pruneResult(search.iterSearch(project, "Lor"), 2) == [ + (15, 3, "Lorem"), (29, 3, "lor"), (754, 3, "lorem"), (2056, 3, "lorem,"), + (2209, 3, "lorem"), (2425, 3, "lorem"), (2840, 3, "lorem."), (3328, 3, "lor."), + (3399, 3, "lorem"), + ] + + # As RegEx + search.setWholeWords(False) + search.setUserRegEx(True) + assert pruneResult(search.iterSearch(project, r"Lor\b"), 2) == [ + (29, 3, "lor"), (3328, 3, "lor."), + ] + + # Max Results + with monkeypatch.context() as mp: + mp.setattr(nwConst, "MAX_SEARCH_RESULT", 3) + assert pruneResult(search.iterSearch(project, "Lorem"), 2) == [ + (15, 5, "Lorem"), (754, 5, "lorem"), (2056, 5, "lorem,"), + ] + + # Case Sensitive + search.setCaseSensitive(True) + assert pruneResult(search.iterSearch(project, "Lorem"), 2) == [(15, 5, "Lorem")] + search.setCaseSensitive(False) + +# END Test testCoreTools_DocSearch + + @pytest.mark.core def testCoreTools_ProjectBuilderWrapper(monkeypatch, caplog, fncPath, mockGUI): """Test the wrapper function of the project builder.""" diff --git a/tests/test_gui/test_gui_noveltree.py b/tests/test_gui/test_gui_noveltree.py index 25dfbf61..120b66be 100644 --- a/tests/test_gui/test_gui_noveltree.py +++ b/tests/test_gui/test_gui_noveltree.py @@ -85,7 +85,7 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd): novelView.setTreeFocus() - nwGUI.projStack.setCurrentIndex(nwGUI.idxNovelView) + nwGUI.projStack.setCurrentWidget(nwGUI.novelView) nwGUI.rebuildIndex() novelTree._populateTree(rootHandle=None) assert novelTree.topLevelItemCount() == 3 diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py index 48e4a06e..1aa0565f 100644 --- a/tests/test_gui/test_gui_outline.py +++ b/tests/test_gui/test_gui_outline.py @@ -237,7 +237,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat selItem = outlineTree.topLevelItem(0) outlineTree.setCurrentItem(selItem) - assert outlineData.titleLabel.text() == "Title" + assert outlineData.titleLabel.text() == "Title" assert outlineData.titleValue.text() == "Lorem Ipsum" assert outlineData.fileValue.text() == "Lorem Ipsum" assert outlineData.itemValue.text() == "Finished" @@ -256,7 +256,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat assert tHandle == "88243afbe5ed8" assert sTitle == "T0001" - assert outlineData.titleLabel.text() == "Scene" + assert outlineData.titleLabel.text() == "Scene" assert outlineData.titleValue.text() == "Scene One" assert outlineData.fileValue.text() == "Scene One" assert outlineData.itemValue.text() == "Finished" @@ -274,7 +274,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat assert tHandle == "88243afbe5ed8" assert sTitle == "T0002" - assert outlineData.titleLabel.text() == "Section" + assert outlineData.titleLabel.text() == "Section" assert outlineData.titleValue.text() == "Scene One, Section Two" assert outlineData.fileValue.text() == "Scene One" assert outlineData.itemValue.text() == "Finished" diff --git a/tests/test_gui/test_gui_search.py b/tests/test_gui/test_gui_search.py new file mode 100644 index 00000000..20a18b9c --- /dev/null +++ b/tests/test_gui/test_gui_search.py @@ -0,0 +1,125 @@ +""" +novelWriter – Main GUI Project Search Tester +============================================ + +This file is a part of novelWriter +Copyright 2018–2024, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import pytest + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QAction + +from novelwriter.enum import nwView +from novelwriter.gui.search import GuiProjectSearch + + +@pytest.mark.gui +def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum): + """Test navigating the novel tree.""" + nwGUI.openProject(prjLipsum) + nwGUI._changeView(nwView.SEARCH) + search = nwGUI.projSearch + + def totalCount(): + nonlocal search + res = search.searchResult + return sum( + int(res.topLevelItem(i).text(GuiProjectSearch.C_COUNT).strip("()")) + for i in range(res.topLevelItemCount()) + ) + + # Plain search + search.searchText.setText("Lorem") + search.searchAction.activate(QAction.ActionEvent.Trigger) + assert search.searchResult.topLevelItemCount() == 14 + assert totalCount() == 42 + + firstDoc = search.searchResult.topLevelItem(0) + firstResult = firstDoc.child(0) + assert firstDoc is not None + handle = firstDoc.data(GuiProjectSearch.C_RESULT, GuiProjectSearch.D_HANDLE) + result = firstResult.data(GuiProjectSearch.C_RESULT, GuiProjectSearch.D_RESULT) + assert result == (handle, 3, 5) + + # Move down + qtbot.keyClick(search, Qt.Key.Key_Down) + assert firstDoc.isSelected() is True + + # Move up + qtbot.keyClick(search, Qt.Key.Key_Up) + assert firstDoc.isSelected() is False + + # Move right does nothing + qtbot.keyClick(search, Qt.Key.Key_Right) + assert firstDoc.isSelected() is False + + # Selecting updates details + firstDoc.setSelected(True) + assert nwGUI.itemDetails._handle == handle + + # Press return + search.searchResult.setFocus() + search.searchResult.clearSelection() + firstResult.setSelected(True) + with monkeypatch.context() as mp: + mp.setattr(search.searchResult, "hasFocus", lambda *a: True) + with qtbot.waitSignal(search.openDocumentSelectRequest, timeout=1000) as signal: + qtbot.keyClick(search, Qt.Key.Key_Return) + assert signal.args == [handle, 3, 5, False] + + assert nwGUI.docEditor.docHandle == handle + assert nwGUI.docEditor.textCursor().selectedText() == "Lorem" + + # Double-click + with qtbot.waitSignal(search.openDocumentSelectRequest, timeout=1000) as signal: + search._searchResultDoubleClicked(firstResult, 0) + assert signal.args == [handle, 3, 5, True] + + # Case Sensitive + search.toggleCase.setChecked(True) + search.searchAction.activate(QAction.ActionEvent.Trigger) + assert search.searchResult.topLevelItemCount() == 7 + assert totalCount() == 17 + search.toggleCase.setChecked(False) + + # Whole Words + search.searchText.setText("dolor") + with monkeypatch.context() as mp: + mp.setattr(search.searchText, "hasFocus", lambda *a: True) + qtbot.keyClick(search, Qt.Key.Key_Return) + + assert search.searchResult.topLevelItemCount() == 10 + assert totalCount() == 34 + + search.toggleWord.setChecked(True) + search.searchAction.activate(QAction.ActionEvent.Trigger) + assert search.searchResult.topLevelItemCount() == 10 + assert totalCount() == 33 + + # RegEx + search.toggleRegEx.setChecked(True) + search.searchText.setText("(dolor|dolorem)") + search.searchAction.activate(QAction.ActionEvent.Trigger) + assert search.searchResult.topLevelItemCount() == 10 + assert totalCount() == 34 + + # qtbot.stop() + nwGUI.closeProject() + +# END Test testGuiDocSearch_Main