Add handling of user icon themes and account for missing theme

This commit is contained in:
Veronica Berglyd Olsen
2025-01-15 20:24:01 +01:00
parent d44a5e57b2
commit 785322e577
5 changed files with 112 additions and 91 deletions
+18 -12
View File
@@ -49,6 +49,11 @@ from novelwriter.error import formatException, logException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEF_GUI = "default"
DEF_SYNTAX = "default_light"
DEF_ICONS = "material_rounded_normal"
DEF_TREECOL = "theme"
class Config: class Config:
@@ -137,18 +142,18 @@ class Config:
# General GUI Settings # General GUI Settings
self.guiLocale = self._qLocale.name() self.guiLocale = self._qLocale.name()
self.guiTheme = "default" # GUI theme self.guiTheme = DEF_GUI # GUI theme
self.guiSyntax = "default_light" # Syntax theme self.guiSyntax = DEF_SYNTAX # Syntax theme
self.guiFont = QFont() # Main GUI font self.guiFont = QFont() # Main GUI font
self.hideVScroll = False # Hide vertical scroll bars on main widgets self.hideVScroll = False # Hide vertical scroll bars on main widgets
self.hideHScroll = False # Hide horizontal 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.lastNotes = "0x0" # The latest release notes that have been shown
self.nativeFont = True # Use native font dialog self.nativeFont = True # Use native font dialog
# Icons # Icons
self.iconTheme = "material_rounded_normal" # Icons theme self.iconTheme = DEF_ICONS # Icons theme
self.iconColTree = "theme" # Project tree icon colours self.iconColTree = DEF_TREECOL # Project tree icon colours
self.iconColDocs = False # Keep theme colours on documents self.iconColDocs = False # Keep theme colours on documents
# Size Settings # Size Settings
self.mainWinSize = [1200, 650] # Last size of the main GUI window self.mainWinSize = [1200, 650] # Last size of the main GUI window
@@ -477,8 +482,9 @@ class Config:
# Config Actions # Config Actions
## ##
def initConfig(self, confPath: str | Path | None = None, def initConfig(
dataPath: str | Path | None = None) -> None: self, confPath: str | Path | None = None, dataPath: str | Path | None = None
) -> None:
"""Initialise the config class. The manual setting of confPath """Initialise the config class. The manual setting of confPath
and dataPath is mainly intended for the test suite. and dataPath is mainly intended for the test suite.
""" """
+5 -4
View File
@@ -35,6 +35,7 @@ from PyQt6.QtWidgets import (
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.common import compact, describeFont, uniqueCompact 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.constants import nwLabels, nwUnicode, trConst
from novelwriter.dialogs.quotes import GuiQuoteSelect from novelwriter.dialogs.quotes import GuiQuoteSelect
from novelwriter.extensions.configlayout import NColourLabel, NScrollableForm from novelwriter.extensions.configlayout import NColourLabel, NScrollableForm
@@ -167,7 +168,7 @@ class GuiPreferences(NDialog):
self.guiTheme.setMinimumWidth(200) self.guiTheme.setMinimumWidth(200)
for theme, name in SHARED.theme.listThemes(): for theme, name in SHARED.theme.listThemes():
self.guiTheme.addItem(name, theme) self.guiTheme.addItem(name, theme)
self.guiTheme.setCurrentData(CONFIG.guiTheme, "default") self.guiTheme.setCurrentData(CONFIG.guiTheme, DEF_GUI)
self.mainForm.addRow( self.mainForm.addRow(
self.tr("Colour theme"), self.guiTheme, self.tr("Colour theme"), self.guiTheme,
@@ -179,7 +180,7 @@ class GuiPreferences(NDialog):
self.iconTheme.setMinimumWidth(200) self.iconTheme.setMinimumWidth(200)
for theme, name in SHARED.theme.iconCache.listThemes(): for theme, name in SHARED.theme.iconCache.listThemes():
self.iconTheme.addItem(name, theme) self.iconTheme.addItem(name, theme)
self.iconTheme.setCurrentData(CONFIG.iconTheme, "material_rounded_bold") self.iconTheme.setCurrentData(CONFIG.iconTheme, DEF_ICONS)
self.mainForm.addRow( self.mainForm.addRow(
self.tr("Icon theme"), self.iconTheme, self.tr("Icon theme"), self.iconTheme,
@@ -191,7 +192,7 @@ class GuiPreferences(NDialog):
self.iconColTree.setMinimumWidth(200) self.iconColTree.setMinimumWidth(200)
for key, label in nwLabels.THEME_COLORS.items(): for key, label in nwLabels.THEME_COLORS.items():
self.iconColTree.addItem(trConst(label), key) self.iconColTree.addItem(trConst(label), key)
self.iconColTree.setCurrentData(CONFIG.iconColTree, "theme") self.iconColTree.setCurrentData(CONFIG.iconColTree, DEF_TREECOL)
self.mainForm.addRow( self.mainForm.addRow(
self.tr("Project tree icon colours"), self.iconColTree, self.tr("Project tree icon colours"), self.iconColTree,
@@ -257,7 +258,7 @@ class GuiPreferences(NDialog):
self.guiSyntax.setMinimumWidth(200) self.guiSyntax.setMinimumWidth(200)
for syntax, name in SHARED.theme.listSyntax(): for syntax, name in SHARED.theme.listSyntax():
self.guiSyntax.addItem(name, syntax) self.guiSyntax.addItem(name, syntax)
self.guiSyntax.setCurrentData(CONFIG.guiSyntax, "default_light") self.guiSyntax.setCurrentData(CONFIG.guiSyntax, DEF_SYNTAX)
self.mainForm.addRow( self.mainForm.addRow(
self.tr("Document colour theme"), self.guiSyntax, self.tr("Document colour theme"), self.guiSyntax,
+76 -69
View File
@@ -38,6 +38,7 @@ from PyQt6.QtWidgets import QApplication
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.common import NWConfigParser, cssCol, minmax from novelwriter.common import NWConfigParser, cssCol, minmax
from novelwriter.config import DEF_GUI, DEF_ICONS, DEF_SYNTAX
from novelwriter.constants import nwLabels from novelwriter.constants import nwLabels
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException from novelwriter.error import logException
@@ -136,10 +137,10 @@ class GuiTheme:
self._availSyntax: dict[str, Path] = {} self._availSyntax: dict[str, Path] = {}
self._styleSheets: dict[str, str] = {} self._styleSheets: dict[str, str] = {}
self._listConf(self._availSyntax, CONFIG.assetPath("syntax")) _listConf(self._availSyntax, CONFIG.assetPath("syntax"), ".conf")
self._listConf(self._availThemes, CONFIG.assetPath("themes")) _listConf(self._availThemes, CONFIG.assetPath("themes"), ".conf")
self._listConf(self._availSyntax, CONFIG.dataPath("syntax")) _listConf(self._availSyntax, CONFIG.dataPath("syntax"), ".conf")
self._listConf(self._availThemes, CONFIG.dataPath("themes")) _listConf(self._availThemes, CONFIG.dataPath("themes"), ".conf")
self.loadTheme() self.loadTheme()
self.loadSyntax() self.loadSyntax()
@@ -213,25 +214,23 @@ class GuiTheme:
def loadTheme(self) -> bool: def loadTheme(self) -> bool:
"""Load the currently specified GUI theme.""" """Load the currently specified GUI theme."""
guiTheme = CONFIG.guiTheme theme = CONFIG.guiTheme
if guiTheme not in self._availThemes: if theme not in self._availThemes:
logger.error("Could not find GUI theme '%s'", guiTheme) logger.error("Could not find GUI theme '%s'", theme)
guiTheme = "default" theme = DEF_GUI
CONFIG.guiTheme = guiTheme CONFIG.guiTheme = theme
themeFile = self._availThemes.get(guiTheme, None) if not (file := self._availThemes.get(theme)):
if themeFile is None:
logger.error("Could not load GUI theme") logger.error("Could not load GUI theme")
return False return False
# Config File logger.info("Loading GUI theme '%s'", theme)
logger.info("Loading GUI theme '%s'", guiTheme)
parser = NWConfigParser() parser = NWConfigParser()
try: try:
with open(themeFile, mode="r", encoding="utf-8") as inFile: with open(file, mode="r", encoding="utf-8") as fo:
parser.read_file(inFile) parser.read_file(fo)
except Exception: except Exception:
logger.error("Could not load theme settings from: %s", themeFile) logger.error("Could not read file: %s", file)
logException() logException()
return False return False
@@ -371,25 +370,23 @@ class GuiTheme:
def loadSyntax(self) -> bool: def loadSyntax(self) -> bool:
"""Load the currently specified syntax highlighter theme.""" """Load the currently specified syntax highlighter theme."""
guiSyntax = CONFIG.guiSyntax theme = CONFIG.guiSyntax
if guiSyntax not in self._availSyntax: if theme not in self._availSyntax:
logger.error("Could not find syntax theme '%s'", guiSyntax) logger.error("Could not find syntax theme '%s'", theme)
guiSyntax = "default_light" theme = DEF_SYNTAX
CONFIG.guiSyntax = guiSyntax CONFIG.guiSyntax = theme
syntaxFile = self._availSyntax.get(guiSyntax, None) if not (file := self._availSyntax.get(theme)):
if syntaxFile is None:
logger.error("Could not load syntax theme") logger.error("Could not load syntax theme")
return False return False
logger.info("Loading syntax theme '%s'", guiSyntax) logger.info("Loading syntax theme '%s'", theme)
parser = NWConfigParser() parser = NWConfigParser()
try: try:
with open(syntaxFile, mode="r", encoding="utf-8") as inFile: with open(file, mode="r", encoding="utf-8") as fo:
parser.read_file(inFile) parser.read_file(fo)
except Exception: except Exception:
logger.error("Could not load syntax colours from: %s", syntaxFile) logger.error("Could not read file: %s", file)
logException() logException()
return False return False
@@ -440,13 +437,14 @@ class GuiTheme:
if self._themeList: if self._themeList:
return self._themeList return self._themeList
themes = []
parser = NWConfigParser() parser = NWConfigParser()
for key, path in self._availThemes.items(): 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): 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 return self._themeList
@@ -455,13 +453,14 @@ class GuiTheme:
if self._syntaxList: if self._syntaxList:
return self._syntaxList return self._syntaxList
themes = []
parser = NWConfigParser() parser = NWConfigParser()
for key, path in self._availSyntax.items(): 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): 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 return self._syntaxList
@@ -524,17 +523,6 @@ class GuiTheme:
return 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: def _parseColour(self, parser: NWConfigParser, section: str, name: str) -> QColor:
"""Parse a colour value from a config string.""" """Parse a colour value from a config string."""
return QColor(*parser.rdIntList(section, name, [0, 0, 0, 255])) return QColor(*parser.rdIntList(section, name, [0, 0, 0, 255]))
@@ -588,7 +576,8 @@ class GuiIcons:
__slots__ = ( __slots__ = (
"mainTheme", "themeMeta", "_svgData", "_svgColours", "_qIcons", "mainTheme", "themeMeta", "_svgData", "_svgColours", "_qIcons",
"_headerDec", "_headerDecNarrow", "_themeList", "_iconPath", "_noIcon", "_headerDec", "_headerDecNarrow", "_availThemes", "_themeList",
"_noIcon",
) )
TOGGLE_ICON_KEYS: dict[str, tuple[str, str]] = { TOGGLE_ICON_KEYS: dict[str, tuple[str, str]] = {
@@ -613,11 +602,14 @@ class GuiIcons:
self._headerDecNarrow: list[QPixmap] = [] self._headerDecNarrow: list[QPixmap] = []
# Icon Theme Path # Icon Theme Path
self._availThemes: dict[str, Path] = {}
self._themeList: list[tuple[str, str]] = [] self._themeList: list[tuple[str, str]] = []
self._iconPath = CONFIG.assetPath("icons")
# None Icon # 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 return
@@ -635,16 +627,24 @@ class GuiIcons:
# Actions # 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 """Update the theme map. This is more of an init, since many of
the GUI icons cannot really be replaced without writing specific the GUI icons cannot really be replaced without writing specific
update functions for the classes where they're used. update functions for the classes where they're used.
""" """
logger.info("Loading icon theme '%s'", iconTheme) if theme not in self._availThemes:
themePath = self._iconPath / f"{iconTheme}.icons" 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: try:
meta = ThemeMeta() 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: for icon in icons:
bits = icon.partition("=") bits = icon.partition("=")
key = bits[0].strip() key = bits[0].strip()
@@ -660,7 +660,7 @@ class GuiIcons:
meta.license = value meta.license = value
self.themeMeta = meta self.themeMeta = meta
except Exception: except Exception:
logger.error("Could not load icon theme from: %s", themePath) logger.error("Could not read file: %s", file)
logException() logException()
return False return False
@@ -810,12 +810,13 @@ class GuiIcons:
if self._themeList: if self._themeList:
return self._themeList return self._themeList
for item in self._iconPath.iterdir(): themes = []
if item.is_file() and item.suffix == ".icons": for key, path in self._availThemes.items():
if name := _loadIconName(item): logger.debug("Checking icon theme '%s'", key)
self._themeList.append((item.stem, name)) if name := _loadIconName(path):
themes.append((key, name))
self._themeList = sorted(self._themeList, key=_sortTheme) self._themeList = sorted(themes, key=_sortTheme)
return self._themeList return self._themeList
@@ -829,9 +830,9 @@ class GuiIcons:
""" """
# 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":
return QIcon(str(self._iconPath / "novelwriter.svg")) return QIcon(str(CONFIG.assetPath("icons") / "novelwriter.svg"))
elif name == "proj_nwx": 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 svg := self._svgData.get(name, b""):
if fill := self._svgColours.get(color or "default"): if fill := self._svgColours.get(color or "default"):
@@ -867,6 +868,14 @@ class GuiIcons:
# Module Functions # 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: def _sortTheme(data: tuple[str, str]) -> str:
"""Key function for theme sorting.""" """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 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.""" """Open a conf file and read the 'name' setting."""
try: try:
with open(confFile, mode="r", encoding="utf-8") as inFile: with open(path, mode="r", encoding="utf-8") as inFile:
confParser.read_file(inFile) parser.read_file(inFile)
return parser.rdStr("Main", "name", "")
except Exception: except Exception:
logger.error("Could not load file: %s", confFile) logger.error("Could not read file: %s", path)
logException() logException()
return "" return ""
return confParser.rdStr("Main", "name", "")
def _loadIconName(path: Path) -> str: def _loadIconName(path: Path) -> str:
@@ -896,7 +904,6 @@ def _loadIconName(path: Path) -> str:
if key.strip() == "meta:name": if key.strip() == "meta:name":
return value.strip() return value.strip()
except Exception: except Exception:
logger.error("Could not load file: %s", path) logger.error("Could not read file: %s", path)
logException() logException()
return "" return ""
+2 -1
View File
@@ -27,6 +27,7 @@ from PyQt6.QtGui import QAction, QFont, QFontDatabase, QKeyEvent
from PyQt6.QtWidgets import QFileDialog, QFontDialog from PyQt6.QtWidgets import QFileDialog, QFontDialog
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.config import DEF_GUI
from novelwriter.constants import nwUnicode from novelwriter.constants import nwUnicode
from novelwriter.dialogs.preferences import GuiPreferences from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.quotes import GuiQuoteSelect from novelwriter.dialogs.quotes import GuiQuoteSelect
@@ -56,7 +57,7 @@ def testDlgPreferences_Main(qtbot, monkeypatch, nwGUI, tstPaths):
# Check GUI Themes # Check GUI Themes
themes = [prefs.guiTheme.itemData(i) for i in range(prefs.guiTheme.count())] themes = [prefs.guiTheme.itemData(i) for i in range(prefs.guiTheme.count())]
assert len(themes) >= 5 assert len(themes) >= 5
assert "default" in themes assert DEF_GUI in themes
# Check GUI Syntax # Check GUI Syntax
syntax = [prefs.guiSyntax.itemData(i) for i in range(prefs.guiSyntax.count())] syntax = [prefs.guiSyntax.itemData(i) for i in range(prefs.guiSyntax.count())]
+11 -5
View File
@@ -28,8 +28,10 @@ from PyQt6.QtGui import QColor, QIcon, QPalette, QPixmap
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.common import NWConfigParser from novelwriter.common import NWConfigParser
from novelwriter.config import DEF_GUI
from novelwriter.constants import nwLabels from novelwriter.constants import nwLabels
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.gui.theme import _listConf
from tests.mocked import causeOSError from tests.mocked import causeOSError
from tests.tools import writeFile from tests.tools import writeFile
@@ -50,15 +52,16 @@ def testGuiTheme_Main(qtbot, nwGUI, tstPaths):
# Scan for Themes # 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" themeOne = tstPaths.cnfDir / "themes" / "themeone.conf"
themeTwo = tstPaths.cnfDir / "themes" / "themetwo.conf" themeTwo = tstPaths.cnfDir / "themes" / "themetwo.conf"
writeFile(themeOne, "# Stuff") writeFile(themeOne, "# Stuff")
writeFile(themeTwo, "# Stuff") writeFile(themeTwo, "# Stuff")
result = {} _listConf(result, tstPaths.cnfDir / "themes", ".conf")
assert mainTheme._listConf(result, tstPaths.cnfDir / "themes") is True
assert result["themeone"] == themeOne assert result["themeone"] == themeOne
assert result["themetwo"] == themeTwo assert result["themetwo"] == themeTwo
@@ -135,7 +138,7 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths):
mainTheme._availThemes = availThemes mainTheme._availThemes = availThemes
# Check handling of unreadable file # Check handling of unreadable file
CONFIG.guiTheme = "default" CONFIG.guiTheme = DEF_GUI
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError) mp.setattr("builtins.open", causeOSError)
assert mainTheme.loadTheme() is False 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) mainTheme._guiPalette.color(QPalette.ColorRole.Window).setRgb(0, 0, 0, 0)
# Load the default theme # Load the default theme
CONFIG.guiTheme = "default" CONFIG.guiTheme = DEF_GUI
assert mainTheme.loadTheme() is True assert mainTheme.loadTheme() is True
# This should load a standard palette # This should load a standard palette
@@ -280,7 +283,10 @@ def testGuiTheme_IconThemes(qtbot, caplog, monkeypatch, nwGUI, tstPaths):
# ========== # ==========
# Invalid theme name # Invalid theme name
availThemes = iconCache._availThemes
iconCache._availThemes = {}
assert iconCache.loadTheme("not_a_theme") is False assert iconCache.loadTheme("not_a_theme") is False
iconCache._availThemes = availThemes
# Check handling of unreadable file # Check handling of unreadable file
with monkeypatch.context() as mp: with monkeypatch.context() as mp: