From 638f93499bfd80384331ff24e28f7a35b1dd50b6 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 3 Jun 2025 17:31:52 +0200 Subject: [PATCH] Scan themes on startup instead --- novelwriter/__init__.py | 2 +- novelwriter/config.py | 4 +- novelwriter/dialogs/preferences.py | 14 +- novelwriter/gui/theme.py | 267 ++++++++++++++------------- novelwriter/guimain.py | 3 +- novelwriter/shared.py | 1 + tests/test_gui/test_gui_statusbar.py | 2 +- tests/test_gui/test_gui_theme.py | 25 ++- 8 files changed, 168 insertions(+), 150 deletions(-) diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index 4905f4b3..acc73ee6 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -49,7 +49,7 @@ __license__ = "GPLv3" __author__ = "Veronica Berglyd Olsen" __maintainer__ = "Veronica Berglyd Olsen" __email__ = "code@vkbo.net" -__version__ = "2.8 Alpha 0" +__version__ = "2.8a0" __hexversion__ = "0x020800a0" __date__ = "2025-06-01" __status__ = "Stable" diff --git a/novelwriter/config.py b/novelwriter/config.py index db173019..404391b1 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -405,7 +405,7 @@ class Config: else: font = QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont) self.guiFont = fontMatcher(font) - logger.debug("GUI font set to: %s", describeFont(font)) + logger.debug("Main font set to: %s", describeFont(font)) QApplication.setFont(self.guiFont) return @@ -723,7 +723,7 @@ class Config: # Check Values # ============ - self._prepareFont(self.guiFont, "GUI") + self._prepareFont(self.guiFont, "main") self._prepareFont(self.textFont, "document") # If we're using straight quotes, disable auto-replace diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 9587db6a..3d284c2e 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -169,11 +169,12 @@ class GuiPreferences(NDialog): self.lightTheme.setMinimumWidth(200) self.darkTheme = NComboBox(self) self.darkTheme.setMinimumWidth(200) - for theme, name, dark in SHARED.theme.listThemes(): - if dark: - self.darkTheme.addItem(name, theme) + for key, theme in SHARED.theme.colourThemes.items(): + if theme.dark: + self.darkTheme.addItem(theme.name, key) else: - self.lightTheme.addItem(name, theme) + self.lightTheme.addItem(theme.name, key) + self.lightTheme.setCurrentData(CONFIG.lightTheme, DEF_GUI_LIGHT) self.darkTheme.setCurrentData(CONFIG.darkTheme, DEF_GUI_DARK) @@ -189,8 +190,9 @@ class GuiPreferences(NDialog): # Icon Theme self.iconTheme = NComboBox(self) self.iconTheme.setMinimumWidth(200) - for theme, name in SHARED.theme.iconCache.listThemes(): - self.iconTheme.addItem(name, theme) + for key, theme in SHARED.theme.iconCache.iconThemes.items(): + self.iconTheme.addItem(theme.name, key) + self.iconTheme.setCurrentData(CONFIG.iconTheme, DEF_ICONS) self.mainForm.addRow( diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index b5e2d2f2..463a8dc0 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -27,6 +27,7 @@ from __future__ import annotations import logging from configparser import ConfigParser +from dataclasses import dataclass from math import ceil from typing import TYPE_CHECKING, Final @@ -50,13 +51,19 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -T_ThemeEntry = tuple[str, str, bool] - STYLES_FLAT_TABS = "flatTabWidget" STYLES_MIN_TOOLBUTTON = "minimalToolButton" STYLES_BIG_TOOLBUTTON = "bigToolButton" +@dataclass +class ThemeEntry: + + name: str + dark: bool + path: Path + + class ThemeMeta: name: str = "" @@ -100,20 +107,18 @@ class GuiTheme: """ __slots__ = ( - "_availSyntax", "_availThemes", "_currentTheme", "_darkThemes", "_guiPalette", - "_lightThemes", "_qColors", "_styleSheets", "_svgColors", "_syntaxList", "_themeList", - "baseButtonHeight", "baseIconHeight", "baseIconSize", "buttonIconSize", "errorText", - "fadedText", "fontPixelSize", "fontPointSize", "getDecoration", "getHeaderDecoration", + "_allThemes", "_currentTheme", "_darkThemes", "_guiPalette", "_lightThemes", "_meta", + "_qColors", "_styleSheets", "_svgColors", "_syntaxList", "baseButtonHeight", + "baseIconHeight", "baseIconSize", "buttonIconSize", "errorText", "fadedText", + "fontPixelSize", "fontPointSize", "getDecoration", "getHeaderDecoration", "getHeaderDecorationNarrow", "getIcon", "getItemIcon", "getPixmap", "getToggleIcon", "guiFont", "guiFontB", "guiFontBU", "guiFontFixed", "guiFontSmall", "helpText", "iconCache", "isDarkTheme", "syntaxTheme", "textNHeight", "textNWidth", - "themeMeta", ) def __init__(self) -> None: # Theme Objects - self.themeMeta = ThemeMeta() self.iconCache = GuiIcons(self) self.syntaxTheme = SyntaxColors() self.isDarkTheme = False @@ -123,12 +128,11 @@ class GuiTheme: self.fadedText = QColor(0, 0, 0) self.errorText = QColor(255, 0, 0) - # Load Themes + # Theme Data + self._meta = ThemeMeta() self._currentTheme = "" self._guiPalette = QPalette() - self._themeList: list[T_ThemeEntry] = [] - self._availThemes: dict[str, Path] = {} - self._availSyntax: dict[str, Path] = {} + self._allThemes: dict[str, ThemeEntry] = {} self._styleSheets: dict[str, str] = {} self._svgColors: dict[str, bytes] = {} self._qColors: dict[str, QColor] = {} @@ -180,16 +184,19 @@ class GuiTheme: logger.debug("Text 'N' Height: %d", self.textNHeight) logger.debug("Text 'N' Width: %d", self.textNWidth) - # Process Themes - _listConf(self._availThemes, CONFIG.assetPath("themes"), ".conf") - _listConf(self._availThemes, CONFIG.dataPath("themes"), ".conf") - - self.loadTheme() - return ## - # Methods + # Properties + ## + + @property + def colourThemes(self) -> dict[str, ThemeEntry]: + """Return a dictionary of all themes.""" + return self._allThemes + + ## + # Getters ## def getTextWidth(self, text: str, font: QFont | None = None) -> int: @@ -214,6 +221,19 @@ class GuiTheme: # Theme Methods ## + def initThemes(self) -> None: + """Initialise themes.""" + CONFIG.splashMessage("Scanning for colour themes ...") + themes: list[Path] = [] + _listContent(themes, CONFIG.assetPath("themes"), ".conf") + _listContent(themes, CONFIG.dataPath("themes"), ".conf") + self._scanThemes(themes) + + self.iconCache.initIcons() + self.loadTheme() + + return + def isDesktopDarkMode(self) -> bool: """Check if the desktop is in dark mode.""" if CONFIG.verQtValue >= 0x060500 and (hint := QGuiApplication.styleHints()): @@ -261,8 +281,8 @@ class GuiTheme: darkMode = self.isDesktopDarkMode() theme = CONFIG.darkTheme if darkMode else CONFIG.lightTheme - if theme not in self._availThemes: - logger.error("Could not find GUI theme '%s'", theme) + if theme not in self._allThemes: + logger.error("Could not find theme for key '%s'", theme) if darkMode: theme = DEF_GUI_DARK CONFIG.darkTheme = DEF_GUI_DARK @@ -274,18 +294,19 @@ class GuiTheme: logger.info("Theme '%s' is already loaded", theme) return False - if not (file := self._availThemes.get(theme)): + entry = self._allThemes.get(theme) + if not entry: logger.error("Could not load GUI theme") return False - CONFIG.splashMessage("Loading GUI theme ...") + CONFIG.splashMessage(f"Loading colour theme: {entry.name}") logger.info("Loading GUI theme '%s'", theme) parser = ConfigParser() try: - with open(file, mode="r", encoding="utf-8") as fo: + with open(entry.path, mode="r", encoding="utf-8") as fo: parser.read_file(fo) except Exception: - logger.error("Could not read file: %s", file) + logger.error("Could not read file: %s", entry.path) logException() return False @@ -305,9 +326,9 @@ class GuiTheme: meta.license = parser.get(sec, "license", fallback="N/A") meta.licenseUrl = parser.get(sec, "licenseurl", fallback="") - self.themeMeta = meta + self._meta = meta - # Icons + # Base sec = "Base" if parser.has_section(sec): self._setBaseColor("default", self._readColor(parser, sec, "default")) @@ -447,35 +468,18 @@ class GuiTheme: self._svgColors["scene"] = color self._svgColors["note"] = color + self.isDarkTheme = darkMode + self._currentTheme = theme + # Load icons after the theme is parsed self.iconCache.loadTheme(CONFIG.iconTheme) # Finalise - self.isDarkTheme = darkMode QApplication.setPalette(self._guiPalette) self._buildStyleSheets(self._guiPalette) - self._currentTheme = theme - CONFIG.splashMessage(f"Loaded GUI theme: {meta.name}") - return True - def listThemes(self) -> list[T_ThemeEntry]: - """Scan the GUI themes folder and list all themes.""" - if self._themeList: - return self._themeList - - themes: list[T_ThemeEntry] = [] - parser = ConfigParser() - for key, path in self._availThemes.items(): - logger.debug("Checking theme config '%s'", key) - if meta := _loadInternalName(parser, path): - themes.append((key, meta[0], meta[1])) - - self._themeList = sorted(themes, key=_sortTheme) - - return self._themeList - def getStyleSheet(self, name: str) -> str: """Load a standard style sheet.""" return self._styleSheets.get(name, "") @@ -493,12 +497,10 @@ class GuiTheme: def _resetTheme(self) -> None: """Reset GUI colours to default values.""" palette = QPalette() - - text = palette.color(QPalette.ColorRole.Text) - window = palette.color(QPalette.ColorRole.Window) - isDark = text.lightnessF() > window.lightnessF() + isDark = self.isDesktopDarkMode() # Reset GUI Palette + default = palette.color(QPalette.ColorRole.Text) faded = QColor(128, 128, 128) dimmed = QColor(130, 130, 130) if isDark else QColor(190, 190, 190) red = QColor(242, 119, 122) if isDark else QColor(240, 40, 41) @@ -520,7 +522,7 @@ class GuiTheme: self.iconCache.clear() self._svgColors = {} self._qColors = {} - self._setBaseColor("default", text) + self._setBaseColor("default", default) self._setBaseColor("faded", faded) self._setBaseColor("red", red) self._setBaseColor("orange", orange) @@ -531,7 +533,7 @@ class GuiTheme: self._setBaseColor("purple", purple) self._setBaseColor("root", blue) self._setBaseColor("folder", yellow) - self._setBaseColor("file", text) + self._setBaseColor("file", default) self._setBaseColor("title", green) self._setBaseColor("chapter", red) self._setBaseColor("scene", blue) @@ -582,6 +584,35 @@ class GuiTheme: return + def _scanThemes(self, files: list[Path]) -> None: + """Scan the GUI themes folder and list all themes.""" + parser = ConfigParser() + data: dict[str, tuple[str, str, bool, Path]] = {} + keys = [] + for file in files: + try: + parser.clear() + parser.read(file, encoding="utf-8") + name = parser.get("Main", "name", fallback="") + dark = parser.get("Main", "mode", fallback="light").lower() == "dark" + if name: + key = file.stem + prefix = "*" if key.startswith("default") else "" + lookup = f"{prefix}{name} {key}" + keys.append(lookup) + data[lookup] = (file.stem, name, dark, file) + except Exception: # noqa: PERF203 + logger.error("Could not read file: %s", file) + logException() + + self._allThemes = {} + for lookup in sorted(keys): + key, name, dark, item = data[lookup] + logger.debug("Checking theme config '%s'", key) + self._allThemes[key] = ThemeEntry(name, dark, item) + + return + class GuiIcons: """The icon class manages the content of the assets/icons folder, @@ -593,8 +624,8 @@ class GuiIcons: """ __slots__ = ( - "_availThemes", "_headerDec", "_headerDecNarrow", "_meta", "_noIcon", - "_qIcons", "_svgData", "_theme", "_themeList", + "_allThemes", "_headerDec", "_headerDecNarrow", "_meta", + "_noIcon", "_qIcons", "_svgData", "_theme", ) TOGGLE_ICON_KEYS: Final[dict[str, tuple[str, str]]] = { @@ -612,21 +643,15 @@ class GuiIcons: self._meta = ThemeMeta() # Storage + self._allThemes: dict[str, ThemeEntry] = {} self._svgData: dict[str, bytes] = {} self._qIcons: dict[str, QIcon] = {} self._headerDec: list[QPixmap] = [] self._headerDecNarrow: list[QPixmap] = [] - # Icon Theme Path - self._availThemes: dict[str, Path] = {} - self._themeList: list[tuple[str, str]] = [] - # None Icon self._noIcon = QIcon(str(CONFIG.assetPath("icons") / "none.svg")) - _listConf(self._availThemes, CONFIG.assetPath("icons"), ".icons") - _listConf(self._availThemes, CONFIG.dataPath("icons"), ".icons") - return def clear(self) -> None: @@ -638,29 +663,48 @@ class GuiIcons: self._meta = ThemeMeta() return + ## + # Properties + ## + + @property + def iconThemes(self) -> dict[str, ThemeEntry]: + """Return a dictionary of all icon themes.""" + return self._allThemes + ## # Actions ## + def initIcons(self) -> None: + """Initialise icons.""" + CONFIG.splashMessage("Scanning for icon themes ...") + icons: list[Path] = [] + _listContent(icons, CONFIG.assetPath("icons"), ".icons") + _listContent(icons, CONFIG.dataPath("icons"), ".icons") + self._scanThemes(icons) + return + def loadTheme(self, theme: str) -> bool: """Update the theme map. This is more of an init, since many of the GUI icons cannot really be replaced without writing specific update functions for the classes where they're used. """ - if theme not in self._availThemes: + if theme not in self._allThemes: logger.error("Could not find icon theme '%s'", theme) theme = DEF_ICONS CONFIG.iconTheme = theme - if not (file := self._availThemes.get(theme)): + entry = self._allThemes.get(theme) + if not entry: logger.error("Could not load icon theme") return False - CONFIG.splashMessage("Loading icon theme ...") + CONFIG.splashMessage(f"Loading icon theme: {entry.name}") logger.info("Loading icon theme '%s'", theme) try: meta = ThemeMeta() - with open(file, mode="r", encoding="utf-8") as icons: + with open(entry.path, mode="r", encoding="utf-8") as icons: for icon in icons: bits = icon.partition("=") key = bits[0].strip() @@ -676,14 +720,12 @@ class GuiIcons: meta.license = value self._meta = meta except Exception: - logger.error("Could not read file: %s", file) + logger.error("Could not read file: %s", entry.path) logException() return False - CONFIG.splashMessage(f"Loaded icon theme: {meta.name}") - CONFIG.splashMessage("Generating additional icons ...") - # Populate generated icons cache + CONFIG.splashMessage("Generating additional icons ...") self.getHeaderDecoration(0) self.getHeaderDecorationNarrow(0) @@ -811,21 +853,6 @@ class GuiIcons: ] return self._headerDecNarrow[minmax(hLevel, 0, 5)] - def listThemes(self) -> list[tuple[str, str]]: - """Scan the GUI icons folder and list all themes.""" - if self._themeList: - return self._themeList - - themes = [] - for key, path in self._availThemes.items(): - logger.debug("Checking icon theme '%s'", key) - if name := _loadIconName(path): - themes.append((key, name)) - - self._themeList = sorted(themes, key=_sortTheme) - - return self._themeList - ## # Internal Functions ## @@ -870,49 +897,39 @@ class GuiIcons: tMode = Qt.TransformationMode.SmoothTransformation return pixmap.scaledToHeight(height, tMode) + def _scanThemes(self, entries: list[Path]) -> None: + """Scan the GUI themes folder and list all themes.""" + data: dict[str, tuple[str, str, Path]] = {} + keys = [] + for entry in entries: + try: + with open(entry, mode="r", encoding="utf-8") as fo: + for line in fo: + key, _, value = line.partition("=") + if key.strip() == "meta:name": + if name := value.strip(): + lookup = entry.stem + keys.append(lookup) + data[lookup] = (lookup, name, entry) + break + except Exception: + logger.error("Could not read file: %s", entry) + logException() + + self._allThemes = {} + for lookup in sorted(keys): + key, name, item = data[lookup] + logger.debug("Checking icon theme '%s'", key) + self._allThemes[key] = ThemeEntry(name, False, item) + + return + # Module Functions # ================ -def _listConf(target: dict, path: Path, extension: str) -> None: - """Scan for theme files and populate the dictionary.""" +def _listContent(data: list[Path], path: Path, extension: str) -> None: + """List files of a specific type and extend the list.""" if path.is_dir(): - for item in path.iterdir(): - if item.is_file() and item.name.endswith(extension): - target[item.stem] = item + data.extend(n for n in path.iterdir() if n.is_file() and n.suffix == extension) return - - -def _sortTheme(data: tuple) -> str: - """Key function for theme sorting.""" - key, name = data[:2] - return f"*{name}" if key.startswith("default_") else name - - -def _loadInternalName(parser: ConfigParser, path: str | Path) -> tuple[str, bool]: - """Open a conf file and read the 'name' setting.""" - try: - parser.clear() - with open(path, mode="r", encoding="utf-8") as inFile: - parser.read_file(inFile) - name = parser.get("Main", "name", fallback="") - dark = parser.get("Main", "mode", fallback="light").lower() == "dark" - return name, dark - except Exception: - logger.error("Could not read file: %s", path) - logException() - return "", False - - -def _loadIconName(path: Path) -> str: - """Open an icons file and read the name setting.""" - try: - with open(path, mode="r", encoding="utf-8") as icons: - for icon in icons: - key, _, value = icon.partition("=") - if key.strip() == "meta:name": - return value.strip() - except Exception: - logger.error("Could not read file: %s", path) - logException() - return "" diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 41edcf3e..25f997f6 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -937,8 +937,7 @@ class GuiMain(QMainWindow): def changeEvent(self, event: QEvent) -> None: """Capture application change events.""" - if int(event.type()) == 210: - # ThemeChange + if int(event.type()) == 210: # ThemeChange self.checkThemeUpdate() return diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 228b9678..c5b7025c 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -175,6 +175,7 @@ class SharedData(QObject): is created. """ self._theme = theme + self._theme.initThemes() return def initSharedData(self, gui: GuiMain) -> None: diff --git a/tests/test_gui/test_gui_statusbar.py b/tests/test_gui/test_gui_statusbar.py index a4d5d91f..efb19f48 100644 --- a/tests/test_gui/test_gui_statusbar.py +++ b/tests/test_gui/test_gui_statusbar.py @@ -31,7 +31,7 @@ from tests.tools import C, buildTestProject @pytest.mark.gui def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd): - """Test the the various features of the status bar.""" + """Test the various features of the status bar.""" buildTestProject(nwGUI, projPath) cHandle = SHARED.project.newFile("A Note", C.hCharRoot) newDoc = SHARED.project.storage.getDocument(cHandle) diff --git a/tests/test_gui/test_gui_theme.py b/tests/test_gui/test_gui_theme.py index f57a2c30..84ee736e 100644 --- a/tests/test_gui/test_gui_theme.py +++ b/tests/test_gui/test_gui_theme.py @@ -33,7 +33,7 @@ from novelwriter import CONFIG, SHARED from novelwriter.config import DEF_GUI_LIGHT from novelwriter.constants import nwLabels from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType -from novelwriter.gui.theme import _listConf +from novelwriter.gui.theme import _listContent from tests.mocked import causeOSError from tests.tools import writeFile @@ -54,18 +54,17 @@ def testGuiTheme_Main(qtbot, nwGUI, tstPaths): # Scan for Themes # =============== - result = {} - _listConf({}, Path("not_a_path"), ".conf") - assert result == {} + result = [] + _listContent(result, Path("not_a_path"), ".conf") + assert result == [] themeOne = tstPaths.cnfDir / "themes" / "themeone.conf" themeTwo = tstPaths.cnfDir / "themes" / "themetwo.conf" writeFile(themeOne, "# Stuff") writeFile(themeTwo, "# Stuff") - _listConf(result, tstPaths.cnfDir / "themes", ".conf") - assert result["themeone"] == themeOne - assert result["themetwo"] == themeTwo + _listContent(result, tstPaths.cnfDir / "themes", ".conf") + assert result == [themeOne, themeTwo] # Parse Colours # ============= @@ -157,17 +156,17 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths): with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) theme._themeList = [] - assert theme.listThemes() == [] + assert theme.getColourThemes() == [] # Load the theme info, default themes first - themesList = theme.listThemes() + themesList = theme.getColourThemes() assert themesList[0] == ("default_dark", "Default Dark Theme") assert themesList[1] == ("default_light", "Default Light Theme") assert themesList[2] == ("cyberpunk_night", "Cyberpunk Night") assert themesList[3] == ("dracula", "Dracula") # A second call should returned the cached list - assert theme.listThemes() == theme._themeList + assert theme.getColourThemes() == theme._themeList # Check handling of broken theme settings CONFIG.guiTheme = "not_a_theme" @@ -376,18 +375,18 @@ def testGuiTheme_IconThemes(qtbot, caplog, monkeypatch, nwGUI, tstPaths): # Load error returns empty list with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) - themes = iconCache.listThemes() + themes = iconCache.getIconThemes() assert themes == [] # Successful read - themes = iconCache.listThemes() + themes = iconCache.getIconThemes() assert len(themes) > 1 assert "material_rounded_normal" in dict(themes) # Load error doesn't matter on second read since list is cached with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) - assert iconCache.listThemes() == themes + assert iconCache.getIconThemes() == themes # qtbot.stop()