Add handling of user icon themes and account for missing theme
This commit is contained in:
+18
-12
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -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())]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user