diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index b8963e8f..0e0bb87b 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -35,6 +35,7 @@ from PyQt6.QtWidgets import QApplication, QErrorMessage from novelwriter.config import Config from novelwriter.error import exceptionHandler from novelwriter.shared import SharedData +from novelwriter.splash import NSplashScreen if TYPE_CHECKING: from novelwriter.guimain import GuiMain @@ -267,13 +268,25 @@ def main(sysArgs: list | None = None) -> GuiMain | None: # Connect the exception handler before making the main GUI sys.excepthook = exceptionHandler + splash = NSplashScreen() + splash.show() + + splash.showStatus("") + splash.showStatus("Starting novelWriter ...") + # Run Config steps that require the QApplication - CONFIG.loadConfig() + CONFIG.loadConfig(splash) CONFIG.initLocalisation(app) SHARED.initTheme(GuiTheme()) # Launch main GUI nwGUI = GuiMain() + nwGUI.showNormal() + splash.finish(nwGUI) + + CONFIG.finishStartup() + del splash + nwGUI.postLaunchTasks(cmdOpen) sys.exit(app.exec()) diff --git a/novelwriter/assets/images/splash.png b/novelwriter/assets/images/splash.png new file mode 100644 index 00000000..c0389531 Binary files /dev/null and b/novelwriter/assets/images/splash.png differ diff --git a/novelwriter/config.py b/novelwriter/config.py index c6dbb47b..78a4b843 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -37,20 +37,21 @@ from PyQt6.QtCore import ( PYQT_VERSION, PYQT_VERSION_STR, QT_VERSION, QT_VERSION_STR, QLibraryInfo, QLocale, QStandardPaths, QSysInfo, QTranslator ) -from PyQt6.QtGui import QFont, QFontDatabase +from PyQt6.QtGui import QFont, QFontDatabase, QFontMetrics from PyQt6.QtWidgets import QApplication from novelwriter.common import ( NWConfigParser, checkInt, checkPath, describeFont, fontMatcher, formatTimeStamp ) -from novelwriter.constants import nwFiles, nwUnicode +from novelwriter.constants import nwFiles, nwHtmlUnicode, nwUnicode from novelwriter.error import formatException, logException if TYPE_CHECKING: from datetime import datetime from novelwriter.core.projectdata import NWProjectData + from novelwriter.splash import NSplashScreen logger = logging.getLogger(__name__) @@ -66,7 +67,7 @@ class Config: "_appPath", "_appRoot", "_backPath", "_backupPath", "_confPath", "_dLocale", "_dShortDate", "_dShortDateTime", "_dataPath", "_errData", "_hasError", "_homePath", "_manuals", "_nwLangPath", "_qLocale", "_qtLangPath", "_qtTrans", "_recentPaths", "_recentProjects", - "allowOpenDial", "altDialogClose", "altDialogOpen", "appHandle", "appName", + "_splash", "allowOpenDial", "altDialogClose", "altDialogOpen", "appHandle", "appName", "askBeforeBackup", "askBeforeExit", "autoSaveDoc", "autoSaveProj", "autoScroll", "autoScrollPos", "autoSelect", "backupOnClose", "cursorWidth", "dialogLine", "dialogStyle", "doJustify", "doReplace", "doReplaceDQuote", "doReplaceDash", "doReplaceDots", @@ -94,6 +95,8 @@ class Config: # Initialisation # ============== + self._splash = None + # Set Application Variables self.appName = "novelWriter" self.appHandle = "novelwriter" @@ -497,6 +500,12 @@ class Config: return sorted(langList.items(), key=lambda x: x[0]) + def splashMessage(self, message: str) -> None: + """Send a message to the splash screen.""" + if self._splash: + self._splash.showStatus(message) + return + ## # Config Actions ## @@ -543,6 +552,8 @@ class Config: def initLocalisation(self, nwApp: QApplication) -> None: """Initialise the localisation of the GUI.""" + self.splashMessage("Loading localisation ...") + self._qLocale = QLocale(self.guiLocale) QLocale.setDefault(self._qLocale) self._qtTrans = {} @@ -568,8 +579,11 @@ class Config: return - def loadConfig(self) -> bool: + def loadConfig(self, splash: NSplashScreen | None = None) -> bool: """Load preferences from file and replace default settings.""" + self._splash = splash + self.splashMessage("Loading user configuration ...") + logger.debug("Loading config file") conf = NWConfigParser() @@ -690,6 +704,9 @@ class Config: # Check Values # ============ + self._prepareFont(self.guiFont, "GUI") + self._prepareFont(self.textFont, "document") + # If we're using straight quotes, disable auto-replace if self.fmtSQuoteOpen == self.fmtSQuoteClose == "'" and self.doReplaceSQuote: logger.info("Using straight single quotes, so disabling auto-replace") @@ -820,6 +837,11 @@ class Config: return True + def finishStartup(self) -> None: + """Call after startup is complete.""" + self._splash = None + return + ## # Internal Functions ## @@ -842,6 +864,17 @@ class Config: logger.debug("Checking package 'pyenchant': OK") return + def _prepareFont(self, font: QFont, kind: str) -> None: + """Check Unicode availability in font. This also initialises any + alternative character used for missing glyphs. See #2315. + """ + self.splashMessage(f"Initialising {kind} font: {font.family()}") + metrics = QFontMetrics(font) + for char in nwHtmlUnicode.U_TO_H.keys(): + if not metrics.inFont(char): # type: ignore + logger.warning("No glyph U+%04x in font", ord(char)) # pragma: no cover + return + class RecentProjects: diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 207bc6fc..1eb2e487 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -130,14 +130,6 @@ class GuiTheme: self._availSyntax: dict[str, Path] = {} self._styleSheets: dict[str, str] = {} - _listConf(self._availSyntax, CONFIG.assetPath("syntax"), ".conf") - _listConf(self._availThemes, CONFIG.assetPath("themes"), ".conf") - _listConf(self._availSyntax, CONFIG.dataPath("syntax"), ".conf") - _listConf(self._availThemes, CONFIG.dataPath("themes"), ".conf") - - self.loadTheme() - self.loadSyntax() - # Icon Functions self.getIcon = self.iconCache.getIcon self.getPixmap = self.iconCache.getPixmap @@ -186,6 +178,15 @@ class GuiTheme: logger.debug("Text 'N' Height: %d", self.textNHeight) logger.debug("Text 'N' Width: %d", self.textNWidth) + # Process Themes + _listConf(self._availSyntax, CONFIG.assetPath("syntax"), ".conf") + _listConf(self._availThemes, CONFIG.assetPath("themes"), ".conf") + _listConf(self._availSyntax, CONFIG.dataPath("syntax"), ".conf") + _listConf(self._availThemes, CONFIG.dataPath("themes"), ".conf") + + self.loadTheme() + self.loadSyntax() + return ## @@ -218,6 +219,7 @@ class GuiTheme: logger.error("Could not load GUI theme") return False + CONFIG.splashMessage("Loading GUI theme ...") logger.info("Loading GUI theme '%s'", theme) parser = NWConfigParser() try: @@ -357,6 +359,8 @@ class GuiTheme: QApplication.setPalette(self._guiPalette) self._buildStyleSheets(self._guiPalette) + CONFIG.splashMessage(f"Loaded GUI theme: {meta.name}") + return True def loadSyntax(self) -> bool: @@ -371,6 +375,7 @@ class GuiTheme: logger.error("Could not load syntax theme") return False + CONFIG.splashMessage("Loading syntax theme ...") logger.info("Loading syntax theme '%s'", theme) parser = NWConfigParser() try: @@ -418,6 +423,8 @@ class GuiTheme: syntax.mod = self._parseColor(parser, sec, "modifier") syntax.mark = self._parseColor(parser, sec, "texthighlight") + CONFIG.splashMessage(f"Loaded syntax theme: {meta.name}") + self.syntaxMeta = meta self.syntaxTheme = syntax @@ -633,6 +640,7 @@ class GuiIcons: logger.error("Could not load icon theme") return False + CONFIG.splashMessage("Loading icon theme ...") logger.info("Loading icon theme '%s'", theme) try: meta = ThemeMeta() @@ -656,6 +664,9 @@ class GuiIcons: logException() return False + CONFIG.splashMessage(f"Loaded icon theme: {meta.name}") + CONFIG.splashMessage("Generating additional icons ...") + # Set colour overrides for project item icons if (override := CONFIG.iconColTree) != "theme": color = self._svgColors.get(override, b"#000000") @@ -668,6 +679,10 @@ class GuiIcons: self._svgColors["scene"] = color self._svgColors["note"] = color + # Populate generated icons cache + self.getHeaderDecoration(0) + self.getHeaderDecorationNarrow(0) + return True def setIconColor(self, key: str, color: QColor) -> None: @@ -822,8 +837,8 @@ class GuiIcons: ## def _loadIcon(self, name: str, color: str | None = None, w: int = 24, h: int = 24) -> QIcon: - """Load an icon from the assets themes folder. Is guaranteed to - return a QIcon. + """Load an icon from the assets themes folder. This function is + guaranteed to return a QIcon. """ # If we just want the app icons, return right away if name == "novelwriter": diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index fac283f3..7a03076f 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -310,11 +310,11 @@ class GuiMain(QMainWindow): self.asProjTimer.start() self.asDocTimer.start() self.mainStatus.clearStatus() - self.showNormal() logger.debug("Ready: GUI") logger.info("novelWriter is ready ...") self.mainStatus.setStatusMessage(self.tr("novelWriter is ready ...")) + CONFIG.splashMessage("novelWriter is ready ...") return @@ -340,6 +340,15 @@ class GuiMain(QMainWindow): logger.info("Command line path: %s", cmdOpen) self.openProject(cmdOpen) + # Add a small delay for the window coordinates to be ready + # before showing any dialogs + QTimer.singleShot(50, self.showPostLaunchDialogs) + + return + + @pyqtSlot() + def showPostLaunchDialogs(self) -> None: + """Show post launch dialogs.""" if not SHARED.hasProject: self.showWelcomeDialog() diff --git a/novelwriter/splash.py b/novelwriter/splash.py new file mode 100644 index 00000000..f1792a98 --- /dev/null +++ b/novelwriter/splash.py @@ -0,0 +1,74 @@ +""" +novelWriter – Splash Screen +=========================== + +File History: +Created: 2015-04-25 [2.7rc1] + +This file is a part of novelWriter +Copyright (C) 2025 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 . +""" +from __future__ import annotations + +import logging + +from pathlib import Path +from time import sleep + +from PyQt6.QtCore import QRect, Qt +from PyQt6.QtGui import QColor, QPainter, QPixmap +from PyQt6.QtWidgets import QSplashScreen + +logger = logging.getLogger(__name__) + +SPLASH_IMG = Path(__file__).parent / "assets" / "images" / "splash.png" + + +class NSplashScreen(QSplashScreen): + + __slots__ = ("_color", "_rect", "_text") + + def __init__(self) -> None: + super().__init__(pixmap=QPixmap(str(SPLASH_IMG)), flags=Qt.WindowType.WindowStaysOnTopHint) + + logger.debug("Create: NSplashScreen") + + font = self.font() + font.setPointSizeF(12.0) + self.setFont(font) + self._color = QColor(26, 52, 78) + self._rect = QRect(144, 110, 440, 30) + self._text = "" + return + + def __del__(self) -> None: # pragma: no cover + logger.debug("Delete: NSplashScreen") + return + + def drawContents(self, painter: QPainter) -> None: + """Draw the text message.""" + painter.setPen(self._color) + painter.drawText(self._rect, Qt.AlignmentFlag.AlignLeft, self._text) + return + + def showStatus(self, message: str) -> None: + """Update the status message.""" + self._text = message + self.showMessage(message) + if message: + logger.info("[Splash] %s", message) + sleep(0.025) + return diff --git a/tests/test_base/test_base_init.py b/tests/test_base/test_base_init.py index 39ba566f..cc1fbeb8 100644 --- a/tests/test_base/test_base_init.py +++ b/tests/test_base/test_base_init.py @@ -33,6 +33,7 @@ from novelwriter import ( BLUE, CONFIG, END, FILE, LINE, LVLC, LVLP, TEXT, TIME, WHITE, _createApp, logger, main ) +from novelwriter.splash import NSplashScreen from tests.tools import clearLogHandlers @@ -40,6 +41,8 @@ from tests.tools import clearLogHandlers @pytest.mark.base def testBaseInit_Launch(caplog, monkeypatch, fncPath): """Check launching the main GUI. This test """ + monkeypatch.setattr(NSplashScreen, "finish", lambda *a: None) + monkeypatch.setattr("novelwriter.splash.sleep", lambda *a: None) monkeypatch.setattr("novelwriter._createApp", lambda *a: Mock()) monkeypatch.setattr("novelwriter.guimain.GuiMain", Mock()) monkeypatch.setattr(sys, "exit", Mock()) @@ -77,6 +80,9 @@ def testBaseInit_CreateApp(caplog, monkeypatch, fncPath): @pytest.mark.base def testBaseInit_Options(monkeypatch, fncPath): """Test command line options for logging level.""" + monkeypatch.setattr(NSplashScreen, "finish", lambda *a: None) + monkeypatch.setattr("novelwriter.splash.sleep", lambda *a: None) + gui = Mock() app = Mock() app.exec = Mock(return_value=0) diff --git a/tests/test_gui/test_gui_theme.py b/tests/test_gui/test_gui_theme.py index 77755fbe..c64f1172 100644 --- a/tests/test_gui/test_gui_theme.py +++ b/tests/test_gui/test_gui_theme.py @@ -170,14 +170,16 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths): "\n" "[Palette]\n" "window = 0, 0, 0\n" - "windowtext = 255, 255, 255\n" + "text = 255, 255, 255\n" ) mainTheme._availThemes["test"] = mockTheme CONFIG.guiTheme = "test" assert mainTheme.loadTheme() is True - assert mainTheme.isDarkTheme is False - assert mainTheme.helpText.getRgb() == (190, 190, 190, 255) + assert mainTheme._guiPalette.window().color().getRgb() == (0, 0, 0, 255) + assert mainTheme._guiPalette.text().color().getRgb() == (255, 255, 255, 255) + assert mainTheme._guiPalette.light().color().getRgb() == (57, 57, 57, 255) + assert mainTheme.isDarkTheme is True # Load Default Light Theme # ========================