Updates to app launch (#2316)

This commit is contained in:
Veronica Berglyd Olsen
2025-04-25 19:00:39 +02:00
committed by GitHub
8 changed files with 171 additions and 19 deletions
+14 -1
View File
@@ -35,6 +35,7 @@ from PyQt6.QtWidgets import QApplication, QErrorMessage
from novelwriter.config import Config from novelwriter.config import Config
from novelwriter.error import exceptionHandler from novelwriter.error import exceptionHandler
from novelwriter.shared import SharedData from novelwriter.shared import SharedData
from novelwriter.splash import NSplashScreen
if TYPE_CHECKING: if TYPE_CHECKING:
from novelwriter.guimain import GuiMain 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 # Connect the exception handler before making the main GUI
sys.excepthook = exceptionHandler sys.excepthook = exceptionHandler
splash = NSplashScreen()
splash.show()
splash.showStatus("")
splash.showStatus("Starting novelWriter ...")
# Run Config steps that require the QApplication # Run Config steps that require the QApplication
CONFIG.loadConfig() CONFIG.loadConfig(splash)
CONFIG.initLocalisation(app) CONFIG.initLocalisation(app)
SHARED.initTheme(GuiTheme()) SHARED.initTheme(GuiTheme())
# Launch main GUI # Launch main GUI
nwGUI = GuiMain() nwGUI = GuiMain()
nwGUI.showNormal()
splash.finish(nwGUI)
CONFIG.finishStartup()
del splash
nwGUI.postLaunchTasks(cmdOpen) nwGUI.postLaunchTasks(cmdOpen)
sys.exit(app.exec()) sys.exit(app.exec())
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+37 -4
View File
@@ -37,20 +37,21 @@ from PyQt6.QtCore import (
PYQT_VERSION, PYQT_VERSION_STR, QT_VERSION, QT_VERSION_STR, QLibraryInfo, PYQT_VERSION, PYQT_VERSION_STR, QT_VERSION, QT_VERSION_STR, QLibraryInfo,
QLocale, QStandardPaths, QSysInfo, QTranslator QLocale, QStandardPaths, QSysInfo, QTranslator
) )
from PyQt6.QtGui import QFont, QFontDatabase from PyQt6.QtGui import QFont, QFontDatabase, QFontMetrics
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
from novelwriter.common import ( from novelwriter.common import (
NWConfigParser, checkInt, checkPath, describeFont, fontMatcher, NWConfigParser, checkInt, checkPath, describeFont, fontMatcher,
formatTimeStamp formatTimeStamp
) )
from novelwriter.constants import nwFiles, nwUnicode from novelwriter.constants import nwFiles, nwHtmlUnicode, nwUnicode
from novelwriter.error import formatException, logException from novelwriter.error import formatException, logException
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
from novelwriter.core.projectdata import NWProjectData from novelwriter.core.projectdata import NWProjectData
from novelwriter.splash import NSplashScreen
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -66,7 +67,7 @@ class Config:
"_appPath", "_appRoot", "_backPath", "_backupPath", "_confPath", "_dLocale", "_dShortDate", "_appPath", "_appRoot", "_backPath", "_backupPath", "_confPath", "_dLocale", "_dShortDate",
"_dShortDateTime", "_dataPath", "_errData", "_hasError", "_homePath", "_manuals", "_dShortDateTime", "_dataPath", "_errData", "_hasError", "_homePath", "_manuals",
"_nwLangPath", "_qLocale", "_qtLangPath", "_qtTrans", "_recentPaths", "_recentProjects", "_nwLangPath", "_qLocale", "_qtLangPath", "_qtTrans", "_recentPaths", "_recentProjects",
"allowOpenDial", "altDialogClose", "altDialogOpen", "appHandle", "appName", "_splash", "allowOpenDial", "altDialogClose", "altDialogOpen", "appHandle", "appName",
"askBeforeBackup", "askBeforeExit", "autoSaveDoc", "autoSaveProj", "autoScroll", "askBeforeBackup", "askBeforeExit", "autoSaveDoc", "autoSaveProj", "autoScroll",
"autoScrollPos", "autoSelect", "backupOnClose", "cursorWidth", "dialogLine", "dialogStyle", "autoScrollPos", "autoSelect", "backupOnClose", "cursorWidth", "dialogLine", "dialogStyle",
"doJustify", "doReplace", "doReplaceDQuote", "doReplaceDash", "doReplaceDots", "doJustify", "doReplace", "doReplaceDQuote", "doReplaceDash", "doReplaceDots",
@@ -94,6 +95,8 @@ class Config:
# Initialisation # Initialisation
# ============== # ==============
self._splash = None
# Set Application Variables # Set Application Variables
self.appName = "novelWriter" self.appName = "novelWriter"
self.appHandle = "novelwriter" self.appHandle = "novelwriter"
@@ -497,6 +500,12 @@ class Config:
return sorted(langList.items(), key=lambda x: x[0]) 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 # Config Actions
## ##
@@ -543,6 +552,8 @@ class Config:
def initLocalisation(self, nwApp: QApplication) -> None: def initLocalisation(self, nwApp: QApplication) -> None:
"""Initialise the localisation of the GUI.""" """Initialise the localisation of the GUI."""
self.splashMessage("Loading localisation ...")
self._qLocale = QLocale(self.guiLocale) self._qLocale = QLocale(self.guiLocale)
QLocale.setDefault(self._qLocale) QLocale.setDefault(self._qLocale)
self._qtTrans = {} self._qtTrans = {}
@@ -568,8 +579,11 @@ class Config:
return return
def loadConfig(self) -> bool: def loadConfig(self, splash: NSplashScreen | None = None) -> bool:
"""Load preferences from file and replace default settings.""" """Load preferences from file and replace default settings."""
self._splash = splash
self.splashMessage("Loading user configuration ...")
logger.debug("Loading config file") logger.debug("Loading config file")
conf = NWConfigParser() conf = NWConfigParser()
@@ -690,6 +704,9 @@ class Config:
# Check Values # Check Values
# ============ # ============
self._prepareFont(self.guiFont, "GUI")
self._prepareFont(self.textFont, "document")
# If we're using straight quotes, disable auto-replace # If we're using straight quotes, disable auto-replace
if self.fmtSQuoteOpen == self.fmtSQuoteClose == "'" and self.doReplaceSQuote: if self.fmtSQuoteOpen == self.fmtSQuoteClose == "'" and self.doReplaceSQuote:
logger.info("Using straight single quotes, so disabling auto-replace") logger.info("Using straight single quotes, so disabling auto-replace")
@@ -820,6 +837,11 @@ class Config:
return True return True
def finishStartup(self) -> None:
"""Call after startup is complete."""
self._splash = None
return
## ##
# Internal Functions # Internal Functions
## ##
@@ -842,6 +864,17 @@ class Config:
logger.debug("Checking package 'pyenchant': OK") logger.debug("Checking package 'pyenchant': OK")
return 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: class RecentProjects:
+25 -10
View File
@@ -130,14 +130,6 @@ class GuiTheme:
self._availSyntax: dict[str, Path] = {} self._availSyntax: dict[str, Path] = {}
self._styleSheets: dict[str, str] = {} 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 # Icon Functions
self.getIcon = self.iconCache.getIcon self.getIcon = self.iconCache.getIcon
self.getPixmap = self.iconCache.getPixmap self.getPixmap = self.iconCache.getPixmap
@@ -186,6 +178,15 @@ class GuiTheme:
logger.debug("Text 'N' Height: %d", self.textNHeight) logger.debug("Text 'N' Height: %d", self.textNHeight)
logger.debug("Text 'N' Width: %d", self.textNWidth) 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 return
## ##
@@ -218,6 +219,7 @@ class GuiTheme:
logger.error("Could not load GUI theme") logger.error("Could not load GUI theme")
return False return False
CONFIG.splashMessage("Loading GUI theme ...")
logger.info("Loading GUI theme '%s'", theme) logger.info("Loading GUI theme '%s'", theme)
parser = NWConfigParser() parser = NWConfigParser()
try: try:
@@ -357,6 +359,8 @@ class GuiTheme:
QApplication.setPalette(self._guiPalette) QApplication.setPalette(self._guiPalette)
self._buildStyleSheets(self._guiPalette) self._buildStyleSheets(self._guiPalette)
CONFIG.splashMessage(f"Loaded GUI theme: {meta.name}")
return True return True
def loadSyntax(self) -> bool: def loadSyntax(self) -> bool:
@@ -371,6 +375,7 @@ class GuiTheme:
logger.error("Could not load syntax theme") logger.error("Could not load syntax theme")
return False return False
CONFIG.splashMessage("Loading syntax theme ...")
logger.info("Loading syntax theme '%s'", theme) logger.info("Loading syntax theme '%s'", theme)
parser = NWConfigParser() parser = NWConfigParser()
try: try:
@@ -418,6 +423,8 @@ class GuiTheme:
syntax.mod = self._parseColor(parser, sec, "modifier") syntax.mod = self._parseColor(parser, sec, "modifier")
syntax.mark = self._parseColor(parser, sec, "texthighlight") syntax.mark = self._parseColor(parser, sec, "texthighlight")
CONFIG.splashMessage(f"Loaded syntax theme: {meta.name}")
self.syntaxMeta = meta self.syntaxMeta = meta
self.syntaxTheme = syntax self.syntaxTheme = syntax
@@ -633,6 +640,7 @@ class GuiIcons:
logger.error("Could not load icon theme") logger.error("Could not load icon theme")
return False return False
CONFIG.splashMessage("Loading icon theme ...")
logger.info("Loading icon theme '%s'", theme) logger.info("Loading icon theme '%s'", theme)
try: try:
meta = ThemeMeta() meta = ThemeMeta()
@@ -656,6 +664,9 @@ class GuiIcons:
logException() logException()
return False return False
CONFIG.splashMessage(f"Loaded icon theme: {meta.name}")
CONFIG.splashMessage("Generating additional icons ...")
# Set colour overrides for project item icons # Set colour overrides for project item icons
if (override := CONFIG.iconColTree) != "theme": if (override := CONFIG.iconColTree) != "theme":
color = self._svgColors.get(override, b"#000000") color = self._svgColors.get(override, b"#000000")
@@ -668,6 +679,10 @@ class GuiIcons:
self._svgColors["scene"] = color self._svgColors["scene"] = color
self._svgColors["note"] = color self._svgColors["note"] = color
# Populate generated icons cache
self.getHeaderDecoration(0)
self.getHeaderDecorationNarrow(0)
return True return True
def setIconColor(self, key: str, color: QColor) -> None: 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: 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 """Load an icon from the assets themes folder. This function is
return a QIcon. guaranteed to return a QIcon.
""" """
# If we just want the app icons, return right away # If we just want the app icons, return right away
if name == "novelwriter": if name == "novelwriter":
+10 -1
View File
@@ -310,11 +310,11 @@ class GuiMain(QMainWindow):
self.asProjTimer.start() self.asProjTimer.start()
self.asDocTimer.start() self.asDocTimer.start()
self.mainStatus.clearStatus() self.mainStatus.clearStatus()
self.showNormal()
logger.debug("Ready: GUI") logger.debug("Ready: GUI")
logger.info("novelWriter is ready ...") logger.info("novelWriter is ready ...")
self.mainStatus.setStatusMessage(self.tr("novelWriter is ready ...")) self.mainStatus.setStatusMessage(self.tr("novelWriter is ready ..."))
CONFIG.splashMessage("novelWriter is ready ...")
return return
@@ -340,6 +340,15 @@ class GuiMain(QMainWindow):
logger.info("Command line path: %s", cmdOpen) logger.info("Command line path: %s", cmdOpen)
self.openProject(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: if not SHARED.hasProject:
self.showWelcomeDialog() self.showWelcomeDialog()
+74
View File
@@ -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 <https://www.gnu.org/licenses/>.
"""
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
+6
View File
@@ -33,6 +33,7 @@ from novelwriter import (
BLUE, CONFIG, END, FILE, LINE, LVLC, LVLP, TEXT, TIME, WHITE, _createApp, BLUE, CONFIG, END, FILE, LINE, LVLC, LVLP, TEXT, TIME, WHITE, _createApp,
logger, main logger, main
) )
from novelwriter.splash import NSplashScreen
from tests.tools import clearLogHandlers from tests.tools import clearLogHandlers
@@ -40,6 +41,8 @@ from tests.tools import clearLogHandlers
@pytest.mark.base @pytest.mark.base
def testBaseInit_Launch(caplog, monkeypatch, fncPath): def testBaseInit_Launch(caplog, monkeypatch, fncPath):
"""Check launching the main GUI. This test """ """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._createApp", lambda *a: Mock())
monkeypatch.setattr("novelwriter.guimain.GuiMain", Mock()) monkeypatch.setattr("novelwriter.guimain.GuiMain", Mock())
monkeypatch.setattr(sys, "exit", Mock()) monkeypatch.setattr(sys, "exit", Mock())
@@ -77,6 +80,9 @@ def testBaseInit_CreateApp(caplog, monkeypatch, fncPath):
@pytest.mark.base @pytest.mark.base
def testBaseInit_Options(monkeypatch, fncPath): def testBaseInit_Options(monkeypatch, fncPath):
"""Test command line options for logging level.""" """Test command line options for logging level."""
monkeypatch.setattr(NSplashScreen, "finish", lambda *a: None)
monkeypatch.setattr("novelwriter.splash.sleep", lambda *a: None)
gui = Mock() gui = Mock()
app = Mock() app = Mock()
app.exec = Mock(return_value=0) app.exec = Mock(return_value=0)
+5 -3
View File
@@ -170,14 +170,16 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths):
"\n" "\n"
"[Palette]\n" "[Palette]\n"
"window = 0, 0, 0\n" "window = 0, 0, 0\n"
"windowtext = 255, 255, 255\n" "text = 255, 255, 255\n"
) )
mainTheme._availThemes["test"] = mockTheme mainTheme._availThemes["test"] = mockTheme
CONFIG.guiTheme = "test" CONFIG.guiTheme = "test"
assert mainTheme.loadTheme() is True assert mainTheme.loadTheme() is True
assert mainTheme.isDarkTheme is False assert mainTheme._guiPalette.window().color().getRgb() == (0, 0, 0, 255)
assert mainTheme.helpText.getRgb() == (190, 190, 190, 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 # Load Default Light Theme
# ======================== # ========================