From 785322e577a9d06760ef687159b846d8c8b3ffa3 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 15 Jan 2025 20:24:01 +0100 Subject: [PATCH] Add handling of user icon themes and account for missing theme --- novelwriter/config.py | 30 +++-- novelwriter/dialogs/preferences.py | 9 +- novelwriter/gui/theme.py | 145 +++++++++++---------- tests/test_dialogs/test_dlg_preferences.py | 3 +- tests/test_gui/test_gui_theme.py | 16 ++- 5 files changed, 112 insertions(+), 91 deletions(-) diff --git a/novelwriter/config.py b/novelwriter/config.py index bf32577e..4a9fe6a5 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -49,6 +49,11 @@ from novelwriter.error import formatException, logException logger = logging.getLogger(__name__) +DEF_GUI = "default" +DEF_SYNTAX = "default_light" +DEF_ICONS = "material_rounded_normal" +DEF_TREECOL = "theme" + class Config: @@ -137,18 +142,18 @@ class Config: # General GUI Settings self.guiLocale = self._qLocale.name() - self.guiTheme = "default" # GUI theme - self.guiSyntax = "default_light" # Syntax theme - self.guiFont = QFont() # Main GUI font - self.hideVScroll = False # Hide vertical scroll bars on main widgets - self.hideHScroll = False # Hide horizontal scroll bars on main widgets - self.lastNotes = "0x0" # The latest release notes that have been shown - self.nativeFont = True # Use native font dialog + self.guiTheme = DEF_GUI # GUI theme + self.guiSyntax = DEF_SYNTAX # Syntax theme + self.guiFont = QFont() # Main GUI font + self.hideVScroll = False # Hide vertical scroll bars on main widgets + self.hideHScroll = False # Hide horizontal scroll bars on main widgets + self.lastNotes = "0x0" # The latest release notes that have been shown + self.nativeFont = True # Use native font dialog # Icons - self.iconTheme = "material_rounded_normal" # Icons theme - self.iconColTree = "theme" # Project tree icon colours - self.iconColDocs = False # Keep theme colours on documents + self.iconTheme = DEF_ICONS # Icons theme + self.iconColTree = DEF_TREECOL # Project tree icon colours + self.iconColDocs = False # Keep theme colours on documents # Size Settings self.mainWinSize = [1200, 650] # Last size of the main GUI window @@ -477,8 +482,9 @@ class Config: # Config Actions ## - def initConfig(self, confPath: str | Path | None = None, - dataPath: str | Path | None = None) -> None: + def initConfig( + self, confPath: str | Path | None = None, dataPath: str | Path | None = None + ) -> None: """Initialise the config class. The manual setting of confPath and dataPath is mainly intended for the test suite. """ diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index f746fb2b..bd14c589 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -35,6 +35,7 @@ from PyQt6.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.common import compact, describeFont, uniqueCompact +from novelwriter.config import DEF_GUI, DEF_ICONS, DEF_SYNTAX, DEF_TREECOL from novelwriter.constants import nwLabels, nwUnicode, trConst from novelwriter.dialogs.quotes import GuiQuoteSelect from novelwriter.extensions.configlayout import NColourLabel, NScrollableForm @@ -167,7 +168,7 @@ class GuiPreferences(NDialog): self.guiTheme.setMinimumWidth(200) for theme, name in SHARED.theme.listThemes(): self.guiTheme.addItem(name, theme) - self.guiTheme.setCurrentData(CONFIG.guiTheme, "default") + self.guiTheme.setCurrentData(CONFIG.guiTheme, DEF_GUI) self.mainForm.addRow( self.tr("Colour theme"), self.guiTheme, @@ -179,7 +180,7 @@ class GuiPreferences(NDialog): self.iconTheme.setMinimumWidth(200) for theme, name in SHARED.theme.iconCache.listThemes(): self.iconTheme.addItem(name, theme) - self.iconTheme.setCurrentData(CONFIG.iconTheme, "material_rounded_bold") + self.iconTheme.setCurrentData(CONFIG.iconTheme, DEF_ICONS) self.mainForm.addRow( self.tr("Icon theme"), self.iconTheme, @@ -191,7 +192,7 @@ class GuiPreferences(NDialog): self.iconColTree.setMinimumWidth(200) for key, label in nwLabels.THEME_COLORS.items(): self.iconColTree.addItem(trConst(label), key) - self.iconColTree.setCurrentData(CONFIG.iconColTree, "theme") + self.iconColTree.setCurrentData(CONFIG.iconColTree, DEF_TREECOL) self.mainForm.addRow( self.tr("Project tree icon colours"), self.iconColTree, @@ -257,7 +258,7 @@ class GuiPreferences(NDialog): self.guiSyntax.setMinimumWidth(200) for syntax, name in SHARED.theme.listSyntax(): self.guiSyntax.addItem(name, syntax) - self.guiSyntax.setCurrentData(CONFIG.guiSyntax, "default_light") + self.guiSyntax.setCurrentData(CONFIG.guiSyntax, DEF_SYNTAX) self.mainForm.addRow( self.tr("Document colour theme"), self.guiSyntax, diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 7282b26a..ae00c12a 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -38,6 +38,7 @@ from PyQt6.QtWidgets import QApplication from novelwriter import CONFIG from novelwriter.common import NWConfigParser, cssCol, minmax +from novelwriter.config import DEF_GUI, DEF_ICONS, DEF_SYNTAX from novelwriter.constants import nwLabels from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.error import logException @@ -136,10 +137,10 @@ class GuiTheme: self._availSyntax: dict[str, Path] = {} self._styleSheets: dict[str, str] = {} - self._listConf(self._availSyntax, CONFIG.assetPath("syntax")) - self._listConf(self._availThemes, CONFIG.assetPath("themes")) - self._listConf(self._availSyntax, CONFIG.dataPath("syntax")) - self._listConf(self._availThemes, CONFIG.dataPath("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() @@ -213,25 +214,23 @@ class GuiTheme: def loadTheme(self) -> bool: """Load the currently specified GUI theme.""" - guiTheme = CONFIG.guiTheme - if guiTheme not in self._availThemes: - logger.error("Could not find GUI theme '%s'", guiTheme) - guiTheme = "default" - CONFIG.guiTheme = guiTheme + theme = CONFIG.guiTheme + if theme not in self._availThemes: + logger.error("Could not find GUI theme '%s'", theme) + theme = DEF_GUI + CONFIG.guiTheme = theme - themeFile = self._availThemes.get(guiTheme, None) - if themeFile is None: + if not (file := self._availThemes.get(theme)): logger.error("Could not load GUI theme") return False - # Config File - logger.info("Loading GUI theme '%s'", guiTheme) + logger.info("Loading GUI theme '%s'", theme) parser = NWConfigParser() try: - with open(themeFile, mode="r", encoding="utf-8") as inFile: - parser.read_file(inFile) + with open(file, mode="r", encoding="utf-8") as fo: + parser.read_file(fo) except Exception: - logger.error("Could not load theme settings from: %s", themeFile) + logger.error("Could not read file: %s", file) logException() return False @@ -371,25 +370,23 @@ class GuiTheme: def loadSyntax(self) -> bool: """Load the currently specified syntax highlighter theme.""" - guiSyntax = CONFIG.guiSyntax - if guiSyntax not in self._availSyntax: - logger.error("Could not find syntax theme '%s'", guiSyntax) - guiSyntax = "default_light" - CONFIG.guiSyntax = guiSyntax + theme = CONFIG.guiSyntax + if theme not in self._availSyntax: + logger.error("Could not find syntax theme '%s'", theme) + theme = DEF_SYNTAX + CONFIG.guiSyntax = theme - syntaxFile = self._availSyntax.get(guiSyntax, None) - if syntaxFile is None: + if not (file := self._availSyntax.get(theme)): logger.error("Could not load syntax theme") return False - logger.info("Loading syntax theme '%s'", guiSyntax) - + logger.info("Loading syntax theme '%s'", theme) parser = NWConfigParser() try: - with open(syntaxFile, mode="r", encoding="utf-8") as inFile: - parser.read_file(inFile) + with open(file, mode="r", encoding="utf-8") as fo: + parser.read_file(fo) except Exception: - logger.error("Could not load syntax colours from: %s", syntaxFile) + logger.error("Could not read file: %s", file) logException() return False @@ -440,13 +437,14 @@ class GuiTheme: if self._themeList: return self._themeList + themes = [] parser = NWConfigParser() for key, path in self._availThemes.items(): - logger.debug("Checking theme config for '%s'", key) + logger.debug("Checking theme config '%s'", key) if name := _loadInternalName(parser, path): - self._themeList.append((key, name)) + themes.append((key, name)) - self._themeList = sorted(self._themeList, key=_sortTheme) + self._themeList = sorted(themes, key=_sortTheme) return self._themeList @@ -455,13 +453,14 @@ class GuiTheme: if self._syntaxList: return self._syntaxList + themes = [] parser = NWConfigParser() for key, path in self._availSyntax.items(): - logger.debug("Checking theme syntax for '%s'", key) + logger.debug("Checking theme syntax '%s'", key) if name := _loadInternalName(parser, path): - self._syntaxList.append((key, name)) + themes.append((key, name)) - self._syntaxList = sorted(self._syntaxList, key=_sortTheme) + self._syntaxList = sorted(themes, key=_sortTheme) return self._syntaxList @@ -524,17 +523,6 @@ class GuiTheme: return - def _listConf(self, targetDict: dict, checkDir: Path) -> bool: - """Scan for theme config files and populate the dictionary.""" - if not checkDir.is_dir(): - return False - - for checkFile in checkDir.iterdir(): - if checkFile.is_file() and checkFile.name.endswith(".conf"): - targetDict[checkFile.name[:-5]] = checkFile - - return True - def _parseColour(self, parser: NWConfigParser, section: str, name: str) -> QColor: """Parse a colour value from a config string.""" return QColor(*parser.rdIntList(section, name, [0, 0, 0, 255])) @@ -588,7 +576,8 @@ class GuiIcons: __slots__ = ( "mainTheme", "themeMeta", "_svgData", "_svgColours", "_qIcons", - "_headerDec", "_headerDecNarrow", "_themeList", "_iconPath", "_noIcon", + "_headerDec", "_headerDecNarrow", "_availThemes", "_themeList", + "_noIcon", ) TOGGLE_ICON_KEYS: dict[str, tuple[str, str]] = { @@ -613,11 +602,14 @@ class GuiIcons: self._headerDecNarrow: list[QPixmap] = [] # Icon Theme Path + self._availThemes: dict[str, Path] = {} self._themeList: list[tuple[str, str]] = [] - self._iconPath = CONFIG.assetPath("icons") # None Icon - self._noIcon = QIcon(str(self._iconPath / "none.svg")) + self._noIcon = QIcon(str(CONFIG.assetPath("icons") / "none.svg")) + + _listConf(self._availThemes, CONFIG.assetPath("icons"), ".icons") + _listConf(self._availThemes, CONFIG.dataPath("icons"), ".icons") return @@ -635,16 +627,24 @@ class GuiIcons: # Actions ## - def loadTheme(self, iconTheme: str) -> bool: + 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. """ - logger.info("Loading icon theme '%s'", iconTheme) - themePath = self._iconPath / f"{iconTheme}.icons" + if theme not in self._availThemes: + logger.error("Could not find icon theme '%s'", theme) + theme = DEF_ICONS + CONFIG.iconTheme = theme + + if not (file := self._availThemes.get(theme)): + logger.error("Could not load icon theme") + return False + + logger.info("Loading icon theme '%s'", theme) try: meta = ThemeMeta() - with open(themePath, mode="r", encoding="utf-8") as icons: + with open(file, mode="r", encoding="utf-8") as icons: for icon in icons: bits = icon.partition("=") key = bits[0].strip() @@ -660,7 +660,7 @@ class GuiIcons: meta.license = value self.themeMeta = meta except Exception: - logger.error("Could not load icon theme from: %s", themePath) + logger.error("Could not read file: %s", file) logException() return False @@ -810,12 +810,13 @@ class GuiIcons: if self._themeList: return self._themeList - for item in self._iconPath.iterdir(): - if item.is_file() and item.suffix == ".icons": - if name := _loadIconName(item): - self._themeList.append((item.stem, name)) + 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(self._themeList, key=_sortTheme) + self._themeList = sorted(themes, key=_sortTheme) return self._themeList @@ -829,9 +830,9 @@ class GuiIcons: """ # If we just want the app icons, return right away if name == "novelwriter": - return QIcon(str(self._iconPath / "novelwriter.svg")) + return QIcon(str(CONFIG.assetPath("icons") / "novelwriter.svg")) elif name == "proj_nwx": - return QIcon(str(self._iconPath / "x-novelwriter-project.svg")) + return QIcon(str(CONFIG.assetPath("icons") / "x-novelwriter-project.svg")) if svg := self._svgData.get(name, b""): if fill := self._svgColours.get(color or "default"): @@ -867,6 +868,14 @@ class GuiIcons: # Module Functions # ================ +def _listConf(target: dict, path: Path, extension: str) -> None: + """Scan for theme files and populate the dictionary.""" + if path.is_dir(): + for item in path.iterdir(): + if item.is_file() and item.name.endswith(extension): + target[item.stem] = item + return + def _sortTheme(data: tuple[str, str]) -> str: """Key function for theme sorting.""" @@ -874,17 +883,16 @@ def _sortTheme(data: tuple[str, str]) -> str: return f"*{name}" if key.startswith("default_") else name -def _loadInternalName(confParser: NWConfigParser, confFile: str | Path) -> str: +def _loadInternalName(parser: NWConfigParser, path: str | Path) -> str: """Open a conf file and read the 'name' setting.""" try: - with open(confFile, mode="r", encoding="utf-8") as inFile: - confParser.read_file(inFile) + with open(path, mode="r", encoding="utf-8") as inFile: + parser.read_file(inFile) + return parser.rdStr("Main", "name", "") except Exception: - logger.error("Could not load file: %s", confFile) + logger.error("Could not read file: %s", path) logException() - return "" - - return confParser.rdStr("Main", "name", "") + return "" def _loadIconName(path: Path) -> str: @@ -896,7 +904,6 @@ def _loadIconName(path: Path) -> str: if key.strip() == "meta:name": return value.strip() except Exception: - logger.error("Could not load file: %s", path) + logger.error("Could not read file: %s", path) logException() - return "" diff --git a/tests/test_dialogs/test_dlg_preferences.py b/tests/test_dialogs/test_dlg_preferences.py index 63dbc91a..9d94a729 100644 --- a/tests/test_dialogs/test_dlg_preferences.py +++ b/tests/test_dialogs/test_dlg_preferences.py @@ -27,6 +27,7 @@ from PyQt6.QtGui import QAction, QFont, QFontDatabase, QKeyEvent from PyQt6.QtWidgets import QFileDialog, QFontDialog from novelwriter import CONFIG, SHARED +from novelwriter.config import DEF_GUI from novelwriter.constants import nwUnicode from novelwriter.dialogs.preferences import GuiPreferences from novelwriter.dialogs.quotes import GuiQuoteSelect @@ -56,7 +57,7 @@ def testDlgPreferences_Main(qtbot, monkeypatch, nwGUI, tstPaths): # Check GUI Themes themes = [prefs.guiTheme.itemData(i) for i in range(prefs.guiTheme.count())] assert len(themes) >= 5 - assert "default" in themes + assert DEF_GUI in themes # Check GUI Syntax syntax = [prefs.guiSyntax.itemData(i) for i in range(prefs.guiSyntax.count())] diff --git a/tests/test_gui/test_gui_theme.py b/tests/test_gui/test_gui_theme.py index 9c368f11..d45b500e 100644 --- a/tests/test_gui/test_gui_theme.py +++ b/tests/test_gui/test_gui_theme.py @@ -28,8 +28,10 @@ from PyQt6.QtGui import QColor, QIcon, QPalette, QPixmap from novelwriter import CONFIG, SHARED from novelwriter.common import NWConfigParser +from novelwriter.config import DEF_GUI from novelwriter.constants import nwLabels from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType +from novelwriter.gui.theme import _listConf from tests.mocked import causeOSError from tests.tools import writeFile @@ -50,15 +52,16 @@ def testGuiTheme_Main(qtbot, nwGUI, tstPaths): # Scan for Themes # =============== - assert mainTheme._listConf({}, Path("not_a_path")) is False + result = {} + _listConf({}, 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") - result = {} - assert mainTheme._listConf(result, tstPaths.cnfDir / "themes") is True + _listConf(result, tstPaths.cnfDir / "themes", ".conf") assert result["themeone"] == themeOne assert result["themetwo"] == themeTwo @@ -135,7 +138,7 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths): mainTheme._availThemes = availThemes # Check handling of unreadable file - CONFIG.guiTheme = "default" + CONFIG.guiTheme = DEF_GUI with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) assert mainTheme.loadTheme() is False @@ -147,7 +150,7 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths): mainTheme._guiPalette.color(QPalette.ColorRole.Window).setRgb(0, 0, 0, 0) # Load the default theme - CONFIG.guiTheme = "default" + CONFIG.guiTheme = DEF_GUI assert mainTheme.loadTheme() is True # This should load a standard palette @@ -280,7 +283,10 @@ def testGuiTheme_IconThemes(qtbot, caplog, monkeypatch, nwGUI, tstPaths): # ========== # Invalid theme name + availThemes = iconCache._availThemes + iconCache._availThemes = {} assert iconCache.loadTheme("not_a_theme") is False + iconCache._availThemes = availThemes # Check handling of unreadable file with monkeypatch.context() as mp: