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__)
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.
"""
+5 -4
View File
@@ -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,
+76 -69
View File
@@ -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 ""
+2 -1
View File
@@ -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())]
+11 -5
View File
@@ -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: