""" novelWriter – Shared Data Class =============================== File History: Created: 2023-08-10 [2.1rc1] SharedData Created: 2023-08-14 [2.1rc1] _GuiAlert This file is a part of novelWriter Copyright (C) 2023 Veronica Berglyd Olsen and novelWriter contributors 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 . """ # noqa from __future__ import annotations import logging import re from enum import Enum from pathlib import Path from time import time from typing import TYPE_CHECKING, TypeVar from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, QUrl, pyqtSignal, pyqtSlot from PyQt6.QtGui import QDesktopServices, QFont, QScreen from PyQt6.QtWidgets import QApplication, QFileDialog, QFontDialog, QMessageBox, QWidget from novelwriter.common import formatFileFilter from novelwriter.constants import nwFiles from novelwriter.core.spellcheck import NWSpellEnchant from novelwriter.enum import nwChange, nwItemClass, nwStandardButton if TYPE_CHECKING: from collections.abc import Callable from novelwriter.core.project import NWProject from novelwriter.core.status import T_StatusKind from novelwriter.gui.theme import GuiTheme from novelwriter.guimain import GuiMain logger = logging.getLogger(__name__) NWWidget = TypeVar("NWWidget", bound=QWidget) RX_HTML = re.compile(r"<.*?>") class SharedData(QObject): """Shared Data Singleton. This is the class instantiated as the SHARED singleton. It holds various globally needed data and pointers to important objects like the main GUI, the current project, and the GUI theme. """ __slots__ = ( "_gui", "_idleRefTime", "_idleTime", "_lastAlert", "_lockedBy", "_project", "_spelling", "_theme", ) focusModeChanged = pyqtSignal(bool) indexAvailable = pyqtSignal() indexChangedTags = pyqtSignal(list, list) indexCleared = pyqtSignal() mainClockTick = pyqtSignal() novelStructureChanged = pyqtSignal(str) projectItemChanged = pyqtSignal(str, Enum) projectStatusChanged = pyqtSignal(bool) projectStatusMessage = pyqtSignal(str) rootFolderChanged = pyqtSignal(str, Enum) spellLanguageChanged = pyqtSignal(str, str) statusLabelsChanged = pyqtSignal(str) def __init__(self) -> None: super().__init__() # Objects self._gui = None self._theme = None self._project = None self._spelling = None # Settings self._lockedBy = None self._lastAlert = "" self._idleTime = 0.0 self._idleRefTime = time() self._focusMode = False self._clock = QTimer(self) self._clock.setInterval(1000) self._clock.timeout.connect(lambda: self.mainClockTick.emit()) ## # Properties ## @property def mainGui(self) -> GuiMain: """Return the Main GUI instance.""" if self._gui is None: raise RuntimeError("SharedData class not fully initialised") return self._gui @property def theme(self) -> GuiTheme: """Return the GUI Theme instance.""" if self._theme is None: raise RuntimeError("SharedData class not fully initialised") return self._theme @property def project(self) -> NWProject: """Return the active NWProject instance.""" if self._project is None: raise RuntimeError("SharedData class not fully initialised") return self._project @property def spelling(self) -> NWSpellEnchant: """Return the active NWProject instance.""" if self._spelling is None: raise RuntimeError("SharedData class not fully initialised") return self._spelling @property def focusMode(self) -> bool: """Return the Focus Mode state.""" return self._focusMode @property def hasProject(self) -> bool: """Return True if the project instance is populated.""" return self.project.isValid @property def projectLock(self) -> list | None: """Return cached lock information for the last project.""" return self._lockedBy @property def projectIdleTime(self) -> float: """Return the session idle time.""" return self._idleTime @property def lastAlert(self) -> str: """Return the last alert message.""" return self._lastAlert @property def mainScreen(self) -> QScreen | None: """Return the screen of the main window.""" return QApplication.screenAt(self.mainGui.rect().center()) ## # Setters ## def setFocusMode(self, state: bool) -> None: """Set focus mode on or off.""" if state is not self._focusMode: self._focusMode = state self.focusModeChanged.emit(state) ## # Methods ## def initTheme(self, theme: GuiTheme) -> None: """Initialise the GUI theme. This must be called before the GUI is created. """ self._theme = theme self._theme.initThemes() def initSharedData(self, gui: GuiMain) -> None: """Initialise the SharedData instance. This must be called as 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._resetProject() logger.debug("Ready: SharedData") if pool := QThreadPool.globalInstance(): logger.debug("Thread Pool Max Count: %d", pool.maxThreadCount()) def closeDocument(self, tHandle: str | None = None) -> None: """Close the document editor, optionally a specific document.""" if tHandle is None or tHandle == self.mainGui.docEditor.docHandle: self.mainGui.closeDocument() if tHandle is None or tHandle == self.mainGui.docViewer.docHandle: self.mainGui.closeViewerPanel() def saveEditor(self, tHandle: str | None = None) -> None: """Save the editor content, optionally a specific document.""" docEditor = self.mainGui.docEditor if ( self.hasProject and docEditor.docHandle and (tHandle is None or tHandle == docEditor.docHandle) ): logger.debug("Saving editor document before action") docEditor.saveText() def openProject(self, path: str | Path, clearLock: bool = False) -> bool: """Open a project.""" if self.project.isValid: logger.error("A project is already open") return False self._lockedBy = None status = self.project.openProject(path, clearLock=clearLock) if status is False: # We must cache the lock status before resetting the project self._lockedBy = self.project.lockStatus self._resetProject() self.updateSpellCheckLanguage(reload=True) self._resetIdleTimer() return status def saveProject(self, autoSave: bool = False) -> bool: """Save the current project.""" if not self.project.isValid: logger.error("There is no project open") return False return self.project.saveProject(autoSave=autoSave) def closeProject(self) -> None: """Close the current project.""" self._closeToolDialogs() self.project.closeProject(self._idleTime) self._resetProject() self._resetIdleTimer() def updateSpellCheckLanguage(self, reload: bool = False) -> None: """Update the active spell check language from settings.""" from novelwriter import CONFIG language = self.project.data.spellLang or CONFIG.spellLanguage if language != self.spelling.spellLanguage or reload: self.spelling.setLanguage(language) _, provider = self.spelling.describeDict() self.spellLanguageChanged.emit(language, provider) def updateIdleTime(self, currTime: float, userIdle: bool) -> None: """Update the idle time record. If the userIdle flag is True, the user idle counter is updated with the time difference since the last time this function was called. Otherwise, only the reference time is updated. """ if userIdle: self._idleTime += currTime - self._idleRefTime self._idleRefTime = currTime def initMainProgress(self, maximum: int, inclusive: bool = False) -> None: """Start a session for the main progress bar.""" if gui := self._gui: gui.mainProgress.setMaximum(maximum - (1 if inclusive else 0)) gui.mainProgress.setValue(0) def incMainProgress(self) -> None: """Increment the value for the main progress bar.""" if gui := self._gui: gui.mainProgress.setValue(gui.mainProgress.value() + 1) QApplication.processEvents() def clearMainProgress(self, delay: float = 1.0) -> None: """Clear the main progress bar.""" if gui := self._gui: QTimer.singleShot(int(delay*1000), gui.mainProgress.reset) def newStatusMessage(self, message: str) -> None: """Request a new status message. This is a callable function for core classes that cannot emit signals on their own. """ self.projectStatusMessage.emit(message) def setGlobalProjectState(self, state: bool) -> None: """Change the global project status. This is a callable function for core classes that cannot emit signals on their own. """ self.projectStatusChanged.emit(state) def runInThreadPool(self, runnable: QRunnable, priority: int = 0) -> None: """Queue a runnable in the application thread pool.""" if pool := QThreadPool.globalInstance(): pool.start(runnable, priority=priority) def getProjectPath( self, parent: QWidget, path: str | Path | None = None, allowZip: bool = False ) -> Path | None: """Open the file dialog and select a novelWriter project file.""" label = (self.tr("novelWriter Project File or Zip File") if allowZip else self.tr("novelWriter Project File")) ext = f"{nwFiles.PROJ_FILE} *.zip" if allowZip else nwFiles.PROJ_FILE fFilter = formatFileFilter([(label, ext), "*"]) selected, _ = QFileDialog.getOpenFileName( parent, self.tr("Open Project"), str(path or ""), filter=fFilter ) return Path(selected) if selected else None def getFont(self, current: QFont, native: bool) -> tuple[QFont, bool | None]: """Open the font dialog and select a font.""" kwargs = {} if not native: kwargs["options"] = QFontDialog.FontDialogOption.DontUseNativeDialog return QFontDialog.getFont(current, self.mainGui, self.tr("Select Font"), **kwargs) def findTopLevelWidget(self, kind: type[NWWidget]) -> NWWidget | None: """Find a top level widget.""" for widget in self.mainGui.children(): if isinstance(widget, kind): return widget return None ## # Public Slots ## @pyqtSlot(str) def openWebsite(self, url: str) -> None: """Open a URL in the system's default browser.""" QDesktopServices.openUrl(QUrl(url)) @pyqtSlot(str, nwItemClass) def createNewNote(self, tag: str, itemClass: nwItemClass) -> None: """Process new note request.""" self.project.createNewNote(tag, itemClass) ## # Signal Proxies ## def emitIndexChangedTags( self, project: NWProject, updated: list[str], deleted: list[str] ) -> None: """Emit the indexChangedTags signal.""" if self._project and self._project.data.uuid == project.data.uuid: self.indexChangedTags.emit(updated, deleted) def emitIndexCleared(self, project: NWProject) -> None: """Emit the indexCleared signal.""" if self._project and self._project.data.uuid == project.data.uuid: self.indexCleared.emit() def emitIndexAvailable(self, project: NWProject) -> None: """Emit the indexAvailable signal.""" if self._project and self._project.data.uuid == project.data.uuid: self.indexAvailable.emit() def emitStatusLabelsChanged(self, project: NWProject, kind: T_StatusKind) -> None: """Emit the statusLabelsChanged signal.""" if self._project and self._project.data.uuid == project.data.uuid: self.statusLabelsChanged.emit(kind) def emitProjectItemChanged(self, project: NWProject, handle: str, change: nwChange) -> None: """Emit the projectItemChanged signal.""" if self._project and self._project.data.uuid == project.data.uuid: self.projectItemChanged.emit(handle, change) def emitRootFolderChanged(self, project: NWProject, handle: str, change: nwChange) -> None: """Emit the rootFolderChanged signal.""" if self._project and self._project.data.uuid == project.data.uuid: self.rootFolderChanged.emit(handle, change) ## # Alert Boxes ## def info(self, text: str, info: str = "", details: str = "", log: bool = True) -> None: """Open an information alert box.""" alert = _GuiAlert(self.mainGui, self.theme) alert.setMessage(text, info, details) alert.setAlertType(_GuiAlert.INFO, False) self._lastAlert = alert.logMessage if log: self._logMessage(self._lastAlert, logger.info) alert.exec() def warn(self, text: str, info: str = "", details: str = "", log: bool = True) -> None: """Open a warning alert box.""" alert = _GuiAlert(self.mainGui, self.theme) alert.setMessage(text, info, details) alert.setAlertType(_GuiAlert.WARN, False) self._lastAlert = alert.logMessage if log: self._logMessage(self._lastAlert, logger.warning) alert.exec() def error(self, text: str, info: str = "", details: str = "", log: bool = True, exc: Exception | None = None) -> None: """Open an error alert box.""" alert = _GuiAlert(self.mainGui, self.theme) alert.setMessage(text, info, details) alert.setAlertType(_GuiAlert.ERROR, False) if exc: alert.setException(exc) self._lastAlert = alert.logMessage if log: self._logMessage(self._lastAlert, logger.error) alert.exec() def question(self, text: str, info: str = "", details: str = "", warn: bool = False) -> bool: """Open a question box.""" alert = _GuiAlert(self.mainGui, self.theme) alert.setMessage(text, info, details) alert.setAlertType(_GuiAlert.WARN if warn else _GuiAlert.ASK, True) self._lastAlert = alert.logMessage alert.exec() return alert.finalState ## # Internal Functions ## def _logMessage(self, message: str, log: Callable) -> None: """Print message to log.""" for text in message.split("
"): log(RX_HTML.sub("", text), stacklevel=3) def _resetProject(self) -> None: """Create a new project and spell checking instance.""" from novelwriter.core.project import NWProject if isinstance(self._project, NWProject): self._project.clear() del self._project del self._spelling self._project = NWProject() self._spelling = NWSpellEnchant(self._project) self.updateSpellCheckLanguage() self._focusMode = False def _resetIdleTimer(self) -> None: """Reset the timer data for the idle timer.""" self._idleRefTime = time() self._idleTime = 0.0 def _closeToolDialogs(self) -> None: """Close all open tool dialogs.""" from novelwriter.extensions.modified import NToolDialog for widget in self.mainGui.children(): if isinstance(widget, NToolDialog): widget.close() class _GuiAlert(QMessageBox): INFO = 0 WARN = 1 ERROR = 2 ASK = 3 def __init__(self, parent: QWidget, theme: GuiTheme) -> None: super().__init__(parent=parent) self._theme = theme self._message = "" self._state = False logger.debug("Ready: _GuiAlert") def __del__(self) -> None: # pragma: no cover logger.debug("Delete: _GuiAlert") @property def logMessage(self) -> str: return self._message @property def finalState(self) -> bool: return self._state def setMessage(self, text: str, info: str, details: str) -> None: """Set the alert box message.""" self._message = " ".join(filter(None, [text, info, details])) self.setText(text) self.setInformativeText(info) self.setDetailedText(details) def setException(self, exception: Exception) -> None: """Add exception details.""" info = self.informativeText() text = f"{type(exception).__name__}: {exception!s}" self.setInformativeText(f"{info}
{text}" if info else text) def setAlertType(self, level: int, isYesNo: bool) -> None: """Set the type of alert and whether the dialog should have Yes/No buttons or just an Ok button. """ if isYesNo: self._btnYes = self._theme.getStandardButton(nwStandardButton.YES, self) self._btnYes.clicked.connect(self._onAccept) self._btnNo = self._theme.getStandardButton(nwStandardButton.NO, self) self._btnNo.clicked.connect(self._onReject) self.addButton(self._btnYes, QMessageBox.ButtonRole.YesRole) self.addButton(self._btnNo, QMessageBox.ButtonRole.NoRole) else: self._btnOk = self._theme.getStandardButton(nwStandardButton.OK, self) self._btnOk.clicked.connect(self._onAccept) self.addButton(self._btnOk, QMessageBox.ButtonRole.AcceptRole) pSz = 2*self._theme.baseIconHeight if level == self.INFO: self.setIconPixmap(self._theme.getPixmap("alert_info", (pSz, pSz), "blue")) self.setWindowTitle(self.tr("Information")) elif level == self.WARN: self.setIconPixmap(self._theme.getPixmap("alert_warn", (pSz, pSz), "orange")) self.setWindowTitle(self.tr("Warning")) elif level == self.ERROR: self.setIconPixmap(self._theme.getPixmap("alert_error", (pSz, pSz), "red")) self.setWindowTitle(self.tr("Error")) elif level == self.ASK: self.setIconPixmap(self._theme.getPixmap("alert_question", (pSz, pSz), "blue")) self.setWindowTitle(self.tr("Question")) @pyqtSlot() def _onAccept(self) -> None: """Process accepted state.""" self._state = True self.close() @pyqtSlot() def _onReject(self) -> None: """Process rejected state.""" self._state = False self.close()