diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index 1114c47f..d83415f7 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -30,7 +30,7 @@ import logging from PyQt5.QtWidgets import QApplication, QErrorMessage from novelwriter.error import exceptionHandler, logException -from novelwriter.config import Config, Global +from novelwriter.config import Config, NWApp ## # Version Scheme @@ -74,7 +74,7 @@ logger = logging.getLogger(__name__) # Create the global singleton instances CONFIG = Config() -GLOBAL = Global() +APP = NWApp() def main(sysArgs: list | None = None): @@ -228,7 +228,7 @@ def main(sysArgs: list | None = None): from novelwriter.guimain import GuiMain if testMode: nwGUI = GuiMain() - GLOBAL.setGUI(nwGUI) + APP.setGUI(nwGUI) return nwGUI else: @@ -248,7 +248,7 @@ def main(sysArgs: list | None = None): # Launch main GUI nwGUI = GuiMain() - GLOBAL.setGUI(nwGUI) + APP.setGUI(nwGUI) nwGUI.postLaunchTasks(cmdOpen) sys.exit(nwApp.exec_()) diff --git a/novelwriter/alert.py b/novelwriter/alert.py deleted file mode 100644 index eb84107d..00000000 --- a/novelwriter/alert.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -novelWriter – Alert Functions -============================= - -File History: -Created: 2023-08-07 [2.1b2] - -This file is a part of novelWriter -Copyright 2018–2023, 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 PyQt5.QtWidgets import QMessageBox - -from novelwriter import GLOBAL -from novelwriter.enum import nwAlert -from novelwriter.constants import nwLabels - -logger = logging.getLogger(__name__) - - -def makeAlert(message: list[str] | str, level: nwAlert, exc: Exception | None = None) -> None: - """Alert both the user and the logger at the same time. The - message can be either a string or a list of strings. - """ - if isinstance(message, list): - message = list(filter(None, message)) # Strip empty strings - popMsg = "
".join(message) - logMsg = " ".join(message) - else: - popMsg = str(message) - logMsg = str(message) - - kw = {} - if exc is not None: - kw["exc_info"] = exc - popMsg = f"{popMsg}
{type(exc).__name__}: {str(exc)}" - - # Write to Log - if level == nwAlert.INFO: - logger.info(logMsg, **kw) - elif level == nwAlert.WARN: - logger.warning(logMsg, **kw) - elif level == nwAlert.ERROR: - logger.error(logMsg, **kw) - - # Popup - msgBox = QMessageBox() - if level == nwAlert.INFO: - msgBox.information(GLOBAL.gui, nwLabels.ALERT_NAME[level], popMsg) - elif level == nwAlert.WARN: - msgBox.warning(GLOBAL.gui, nwLabels.ALERT_NAME[level], popMsg) - elif level == nwAlert.ERROR: - msgBox.critical(GLOBAL.gui, nwLabels.ALERT_NAME[level], popMsg) - - return - - -def askQuestion(title: str, question: str) -> bool: - """Ask the user a Yes/No question, and return the answer.""" - msgBox = QMessageBox() - msgRes = msgBox.question(GLOBAL.gui, title, question, QMessageBox.Yes | QMessageBox.No) - return msgRes == QMessageBox.Yes diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index d209978a..32a523f7 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -17,8 +17,8 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ [Map] add = typ_plus.svg -alert_error = typ_info-large-full.svg -alert_info = typ_delete-full.svg +alert_error = typ_delete-full.svg +alert_info = typ_info-large-full.svg alert_question = typ_lightbulb-full.svg alert_warn = typ_warning-full.svg backward = typ_chevron-left.svg diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 37946169..4a2ce078 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -17,8 +17,8 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ [Map] add = typ_plus.svg -alert_error = typ_info-large-full.svg -alert_info = typ_delete-full.svg +alert_error = typ_delete-full.svg +alert_info = typ_info-large-full.svg alert_question = typ_lightbulb-full.svg alert_warn = typ_warning-full.svg backward = typ_chevron-left.svg diff --git a/novelwriter/config.py b/novelwriter/config.py index d1fb2c88..7bbf125e 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -798,7 +798,7 @@ class Config: # END Class Config -class Global: +class NWApp: """Singleton: Global Pointers This class holds pointers to the core singletons: @@ -852,7 +852,7 @@ class Global: self._project = project return -# END Class Global +# END Class NWApp class RecentProjects: diff --git a/novelwriter/constants.py b/novelwriter/constants.py index cb77570f..c3abf581 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -167,6 +167,7 @@ class nwLabels: nwAlert.INFO: QT_TRANSLATE_NOOP("Constant", "Information"), nwAlert.WARN: QT_TRANSLATE_NOOP("Constant", "Warning"), nwAlert.ERROR: QT_TRANSLATE_NOOP("Constant", "Error"), + nwAlert.ASK: QT_TRANSLATE_NOOP("Constant", "Question"), } ITEM_DESCRIPTION = { "none": QT_TRANSLATE_NOOP("Constant", "None"), diff --git a/novelwriter/enum.py b/novelwriter/enum.py index ab91b67e..aeab3f5e 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -125,6 +125,7 @@ class nwAlert(Enum): INFO = 0 WARN = 1 ERROR = 2 + ASK = 3 # END Enum nwAlert diff --git a/novelwriter/error.py b/novelwriter/error.py index 73c08290..8b4eeba6 100644 --- a/novelwriter/error.py +++ b/novelwriter/error.py @@ -1,7 +1,6 @@ """ novelWriter – Exception Handling ================================ -Error handling function and error dialog File History: Created: 2020-08-02 [0.10.2] @@ -22,18 +21,24 @@ 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 sys import random import logging +from typing import TYPE_CHECKING + from PyQt5.QtGui import QFont, QFontDatabase -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtWidgets import ( - qApp, QDialog, QGridLayout, QStyle, QPlainTextEdit, QLabel, + QWidget, qApp, QDialog, QGridLayout, QStyle, QPlainTextEdit, QLabel, QDialogButtonBox ) +if TYPE_CHECKING: # pragma: no cover + from types import TracebackType + logger = logging.getLogger(__name__) @@ -41,15 +46,15 @@ logger = logging.getLogger(__name__) # Utility Functions # =============================================================================================== # -def logException(): - """Log the content of an exception message. - """ +def logException() -> None: + """Log the content of an exception message.""" exType, exValue, _ = sys.exc_info() if exType is not None: logger.error("%s: %s", exType.__name__, str(exValue)) + return -def formatException(exc): +def formatException(exc) -> str: """Format an exception as a string the same way the default exception handler does. """ @@ -62,7 +67,7 @@ def formatException(exc): class NWErrorMessage(QDialog): - def __init__(self, parent): + def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) self.setObjectName("NWErrorMessage") @@ -113,7 +118,7 @@ class NWErrorMessage(QDialog): return - def setMessage(self, exType, exValue, exTrace): + def setMessage(self, exType: type, exValue: BaseException, exTrace: TracebackType) -> None: """Generate a message and append session data, error info and error traceback. """ @@ -142,7 +147,7 @@ class NWErrorMessage(QDialog): enchantVersion = "Unknown" try: - exTrace = "\n".join(format_tb(exTrace)) + txtTrace = "\n".join(format_tb(exTrace)) self.msgBody.setPlainText(( "Environment:\n" f"novelWriter Version: {__version__}\n" @@ -151,7 +156,7 @@ class NWErrorMessage(QDialog): f"Qt: {QT_VERSION_STR}, PyQt: {PYQT_VERSION_STR}\n" f"enchant: {enchantVersion}\n\n" f"{exType.__name__}:\n{str(exValue)}\n\n" - f"Traceback:\n{exTrace}\n" + f"Traceback:\n{txtTrace}\n" )) except Exception: self.msgBody.setPlainText("Failed to generate error report ...") @@ -162,18 +167,17 @@ class NWErrorMessage(QDialog): # Slots ## - def _doClose(self): - """Close the dialog. - """ + @pyqtSlot() + def _doClose(self) -> None: + """Close the dialog.""" self.close() return # END Class NWErrorMessage -def exceptionHandler(exType, exValue, exTrace): - """Function to catch unhandled global exceptions. - """ +def exceptionHandler(exType: type, exValue: BaseException, exTrace: TracebackType) -> None: + """Function to catch unhandled global exceptions.""" from traceback import print_tb from PyQt5.QtWidgets import qApp diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 82814d2f..67e674e1 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -31,14 +31,13 @@ from pathlib import Path from datetime import datetime from PyQt5.QtCore import Qt, QTimer, QThreadPool, pyqtSlot -from PyQt5.QtGui import QCloseEvent, QCursor, QIcon, QKeySequence +from PyQt5.QtGui import QCloseEvent, QCursor, QIcon, QKeySequence, QPixmap from PyQt5.QtWidgets import ( qApp, QDialog, QFileDialog, QMainWindow, QMessageBox, QShortcut, QSplitter, QStackedWidget, QVBoxLayout, QWidget ) -from novelwriter import CONFIG, GLOBAL, __hexversion__ -from novelwriter.alert import askQuestion, makeAlert +from novelwriter import CONFIG, APP, __hexversion__ from novelwriter.gui.theme import GuiTheme from novelwriter.gui.sidebar import GuiSideBar from novelwriter.gui.outline import GuiOutlineView @@ -67,7 +66,7 @@ from novelwriter.enum import ( nwDocAction, nwDocMode, nwItemType, nwItemClass, nwAlert, nwWidget, nwView ) from novelwriter.common import getGuiItem, hexToInt -from novelwriter.constants import nwFiles +from novelwriter.constants import nwFiles, nwLabels, trConst logger = logging.getLogger(__name__) @@ -116,8 +115,8 @@ class GuiMain(QMainWindow): # Core Classes self.mainTheme = GuiTheme() self.theProject = NWProject(self) - GLOBAL.setTheme(self.mainTheme) - GLOBAL.setProject(self.theProject) + APP.setTheme(self.mainTheme) + APP.setProject(self.theProject) # Core Settings self.hasProject = False @@ -318,6 +317,16 @@ class GuiMain(QMainWindow): # Handle Windows Mode self.showNormal() + # Cache Icons + + aPx = CONFIG.pxInt(48) + self.alertPix: dict[nwAlert, QPixmap] = { + nwAlert.INFO: self.mainTheme.getPixmap("alert_info", (aPx, aPx)), + nwAlert.WARN: self.mainTheme.getPixmap("alert_warn", (aPx, aPx)), + nwAlert.ERROR: self.mainTheme.getPixmap("alert_error", (aPx, aPx)), + nwAlert.ASK: self.mainTheme.getPixmap("alert_question", (aPx, aPx)), + } + logger.debug("Ready: GUI") if __hexversion__[-2] == "a" and logger.getEffectiveLevel() > logging.DEBUG: @@ -325,7 +334,7 @@ class GuiMain(QMainWindow): "You are running an untested development version of novelWriter. " "Please be careful when working on a live project " "and make sure you take regular backups." - ), nwAlert.WARN) + ), level=nwAlert.WARN) logger.info("novelWriter is ready ...") self.setStatus(self.tr("novelWriter is ready ...")) @@ -385,7 +394,7 @@ class GuiMain(QMainWindow): if not self.closeProject(): self.makeAlert(self.tr( "Cannot create a new project when another project is open." - ), nwAlert.ERROR) + ), level=nwAlert.ERROR) return False if projData is None: @@ -403,7 +412,7 @@ class GuiMain(QMainWindow): self.makeAlert(self.tr( "A project already exists in that location. " "Please choose another folder." - ), nwAlert.ERROR) + ), level=nwAlert.ERROR) return False logger.info("Creating new project") @@ -425,13 +434,10 @@ class GuiMain(QMainWindow): return True if not isYes: - msgYes = self.askQuestion( - self.tr("Close Project"), - "%s
%s" % ( - self.tr("Close the current project?"), - self.tr("Changes are saved automatically.") - ) - ) + msgYes = self.askQuestion("%s
%s" % ( + self.tr("Close the current project?"), + self.tr("Changes are saved automatically.") + )) if not msgYes: return False @@ -443,10 +449,7 @@ class GuiMain(QMainWindow): if self.theProject.data.doBackup and CONFIG.backupOnClose: doBackup = True if CONFIG.askBeforeBackup: - msgYes = self.askQuestion( - self.tr("Backup Project"), - self.tr("Backup the current project?") - ) + msgYes = self.askQuestion(self.tr("Backup the current project?")) if not msgYes: doBackup = False @@ -485,19 +488,28 @@ class GuiMain(QMainWindow): # Try to open the project if not self.theProject.openProject(projFile): # The project open failed. - lockStatus = self.theProject.getLockStatus() if lockStatus is None: # The project is not locked, so failed for some other # reason handled by the project class. return False + lockText = self.tr( + "The project is already open by another instance of " + "novelWriter, and is therefore locked. Override lock " + "and continue anyway?" + ) + lockInfo = self.tr( + "Note: If the program or the computer previously " + "crashed, the lock can safely be overridden. However, " + "overriding it is not recommended if the project is " + "open in another instance of novelWriter. Doing so " + "may corrupt the project." + ) try: - lockDetails = ( - "
%s" % self.tr( - "The project was locked by the computer " - "'{0}' ({1} {2}), last active on {3}." - ) + lockDetails = self.tr( + "The project was locked by the computer " + "'{0}' ({1} {2}), last active on {3}." ).format( lockStatus[0], lockStatus[1], lockStatus[2], datetime.fromtimestamp(int(lockStatus[3])).strftime("%x %X") @@ -505,27 +517,7 @@ class GuiMain(QMainWindow): except Exception: lockDetails = "" - msgBox = QMessageBox() - msgRes = msgBox.warning( - self, self.tr("Project Locked"), - "%s

%s
%s" % ( - self.tr( - "The project is already open by another instance of " - "novelWriter, and is therefore locked. Override lock " - "and continue anyway?" - ), - self.tr( - "Note: If the program or the computer previously " - "crashed, the lock can safely be overridden. However, " - "overriding it is not recommended if the project is " - "open in another instance of novelWriter. Doing so " - "may corrupt the project." - ), - lockDetails - ), - QMessageBox.Yes | QMessageBox.No, QMessageBox.No - ) - if msgRes == QMessageBox.Yes: + if self.askQuestion(lockText, info=lockInfo, details=lockDetails, level=nwAlert.WARN): if not self.theProject.openProject(projFile, overrideLock=True): return False else: @@ -565,9 +557,7 @@ class GuiMain(QMainWindow): # Check if we need to rebuild the index if self.theProject.index.indexBroken: - self.makeAlert(self.tr( - "The project index is outdated or broken. Rebuilding index." - ), nwAlert.INFO) + self.makeAlert(self.tr("The project index is outdated or broken. Rebuilding index.")) self.rebuildIndex() # Make sure the changed status is set to false on things opened @@ -756,23 +746,20 @@ class GuiMain(QMainWindow): except Exception as exc: self.makeAlert(self.tr( "Could not read file. The file must be an existing text file." - ), nwAlert.ERROR, exception=exc) + ), level=nwAlert.ERROR, exception=exc) return False if self.docEditor.docHandle() is None: self.makeAlert(self.tr( "Please open a document to import the text file into." - ), nwAlert.ERROR) + ), level=nwAlert.ERROR) return False if not self.docEditor.isEmpty(): - msgYes = self.askQuestion( - self.tr("Import Document"), - self.tr( - "Importing the file will overwrite the current content of " - "the document. Do you want to proceed?" - ) - ) + msgYes = self.askQuestion(self.tr( + "Importing the file will overwrite the current content of " + "the document. Do you want to proceed?" + )) if not msgYes: return False @@ -871,9 +858,7 @@ class GuiMain(QMainWindow): qApp.restoreOverrideCursor() if not beQuiet: - self.makeAlert(self.tr( - "The project index has been successfully rebuilt." - ), nwAlert.INFO) + self.makeAlert(self.tr("The project index has been successfully rebuilt.")) return True @@ -921,7 +906,7 @@ class GuiMain(QMainWindow): if dlgConf.needsRestart: self.makeAlert(self.tr( "Some changes will not be applied until novelWriter has been restarted." - ), nwAlert.INFO) + )) if dlgConf.refreshTree: self.projView.populateTree() @@ -1102,17 +1087,48 @@ class GuiMain(QMainWindow): return - def makeAlert(self, message: list[str] | str, level: nwAlert = nwAlert.INFO, - exception: Exception | None = None) -> None: + def makeAlert(self, text: str, info: str = "", details: str = "", + level: nwAlert = nwAlert.INFO, exception: Exception | None = None) -> None: """Alert both the user and the logger at the same time. The message can be either a string or a list of strings. """ - makeAlert(message, level, exception) + logText = " ".join(filter(None, [text, info, details])) + if level == nwAlert.INFO: + logger.info(logText, stacklevel=2) + elif level == nwAlert.WARN: + logger.warning(logText, stacklevel=2) + elif level == nwAlert.ERROR: + logger.error(logText, stacklevel=2, exc_info=exception) + + if exception is not None: + excText = f"{type(exception).__name__}: {str(exception)}" + info = f"{info}
{excText}" if info else excText + + msgBox = QMessageBox(self) + msgBox.setWindowTitle(trConst(nwLabels.ALERT_NAME[level])) + msgBox.setText(text) + msgBox.setInformativeText(info) + msgBox.setDetailedText(details) + msgBox.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + msgBox.setIconPixmap(self.alertPix[level]) + msgBox.adjustSize() + msgBox.exec_() + return - def askQuestion(self, title: str, question: str) -> bool: + def askQuestion(self, text: str, info: str = "", details: str = "", + level: nwAlert = nwAlert.ASK) -> bool: """Ask the user a Yes/No question, and return the answer.""" - return askQuestion(title, question) + msgBox = QMessageBox(self) + msgBox.setWindowTitle(trConst(nwLabels.ALERT_NAME[level])) + msgBox.setText(text) + msgBox.setInformativeText(info) + msgBox.setDetailedText(details) + msgBox.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + msgBox.setIconPixmap(self.alertPix[level]) + msgBox.adjustSize() + msgBox.exec_() + return msgBox.result() == QMessageBox.Yes def reportConfErr(self) -> bool: """Checks if the Config module has any errors to report, and let @@ -1120,7 +1136,7 @@ class GuiMain(QMainWindow): errors since it is initialised before the GUI itself. """ if CONFIG.hasError: - self.makeAlert(CONFIG.errorText(), nwAlert.ERROR) + self.makeAlert(CONFIG.errorText(), level=nwAlert.ERROR) return True return False @@ -1131,13 +1147,10 @@ class GuiMain(QMainWindow): def closeMain(self) -> bool: """Save everything, and close novelWriter.""" if self.hasProject: - msgYes = self.askQuestion( - self.tr("Exit"), - "%s
%s" % ( - self.tr("Do you want to exit novelWriter?"), - self.tr("Changes are saved automatically.") - ) - ) + msgYes = self.askQuestion("%s
%s" % ( + self.tr("Do you want to exit novelWriter?"), + self.tr("Changes are saved automatically.") + )) if not msgYes: return False @@ -1388,7 +1401,7 @@ class GuiMain(QMainWindow): "from the Tools menu, or by pressing {1}." ).format( tag, "F9" - ), nwAlert.ERROR) + ), level=nwAlert.ERROR) return None, None return tHandle, sTitle