Scan themes on startup instead
This commit is contained in:
@@ -49,7 +49,7 @@ __license__ = "GPLv3"
|
|||||||
__author__ = "Veronica Berglyd Olsen"
|
__author__ = "Veronica Berglyd Olsen"
|
||||||
__maintainer__ = "Veronica Berglyd Olsen"
|
__maintainer__ = "Veronica Berglyd Olsen"
|
||||||
__email__ = "code@vkbo.net"
|
__email__ = "code@vkbo.net"
|
||||||
__version__ = "2.8 Alpha 0"
|
__version__ = "2.8a0"
|
||||||
__hexversion__ = "0x020800a0"
|
__hexversion__ = "0x020800a0"
|
||||||
__date__ = "2025-06-01"
|
__date__ = "2025-06-01"
|
||||||
__status__ = "Stable"
|
__status__ = "Stable"
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ class Config:
|
|||||||
else:
|
else:
|
||||||
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont)
|
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont)
|
||||||
self.guiFont = fontMatcher(font)
|
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)
|
QApplication.setFont(self.guiFont)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -723,7 +723,7 @@ class Config:
|
|||||||
# Check Values
|
# Check Values
|
||||||
# ============
|
# ============
|
||||||
|
|
||||||
self._prepareFont(self.guiFont, "GUI")
|
self._prepareFont(self.guiFont, "main")
|
||||||
self._prepareFont(self.textFont, "document")
|
self._prepareFont(self.textFont, "document")
|
||||||
|
|
||||||
# If we're using straight quotes, disable auto-replace
|
# If we're using straight quotes, disable auto-replace
|
||||||
|
|||||||
@@ -169,11 +169,12 @@ class GuiPreferences(NDialog):
|
|||||||
self.lightTheme.setMinimumWidth(200)
|
self.lightTheme.setMinimumWidth(200)
|
||||||
self.darkTheme = NComboBox(self)
|
self.darkTheme = NComboBox(self)
|
||||||
self.darkTheme.setMinimumWidth(200)
|
self.darkTheme.setMinimumWidth(200)
|
||||||
for theme, name, dark in SHARED.theme.listThemes():
|
for key, theme in SHARED.theme.colourThemes.items():
|
||||||
if dark:
|
if theme.dark:
|
||||||
self.darkTheme.addItem(name, theme)
|
self.darkTheme.addItem(theme.name, key)
|
||||||
else:
|
else:
|
||||||
self.lightTheme.addItem(name, theme)
|
self.lightTheme.addItem(theme.name, key)
|
||||||
|
|
||||||
self.lightTheme.setCurrentData(CONFIG.lightTheme, DEF_GUI_LIGHT)
|
self.lightTheme.setCurrentData(CONFIG.lightTheme, DEF_GUI_LIGHT)
|
||||||
self.darkTheme.setCurrentData(CONFIG.darkTheme, DEF_GUI_DARK)
|
self.darkTheme.setCurrentData(CONFIG.darkTheme, DEF_GUI_DARK)
|
||||||
|
|
||||||
@@ -189,8 +190,9 @@ class GuiPreferences(NDialog):
|
|||||||
# Icon Theme
|
# Icon Theme
|
||||||
self.iconTheme = NComboBox(self)
|
self.iconTheme = NComboBox(self)
|
||||||
self.iconTheme.setMinimumWidth(200)
|
self.iconTheme.setMinimumWidth(200)
|
||||||
for theme, name in SHARED.theme.iconCache.listThemes():
|
for key, theme in SHARED.theme.iconCache.iconThemes.items():
|
||||||
self.iconTheme.addItem(name, theme)
|
self.iconTheme.addItem(theme.name, key)
|
||||||
|
|
||||||
self.iconTheme.setCurrentData(CONFIG.iconTheme, DEF_ICONS)
|
self.iconTheme.setCurrentData(CONFIG.iconTheme, DEF_ICONS)
|
||||||
|
|
||||||
self.mainForm.addRow(
|
self.mainForm.addRow(
|
||||||
|
|||||||
+142
-125
@@ -27,6 +27,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
|
from dataclasses import dataclass
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from typing import TYPE_CHECKING, Final
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
@@ -50,13 +51,19 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
T_ThemeEntry = tuple[str, str, bool]
|
|
||||||
|
|
||||||
STYLES_FLAT_TABS = "flatTabWidget"
|
STYLES_FLAT_TABS = "flatTabWidget"
|
||||||
STYLES_MIN_TOOLBUTTON = "minimalToolButton"
|
STYLES_MIN_TOOLBUTTON = "minimalToolButton"
|
||||||
STYLES_BIG_TOOLBUTTON = "bigToolButton"
|
STYLES_BIG_TOOLBUTTON = "bigToolButton"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ThemeEntry:
|
||||||
|
|
||||||
|
name: str
|
||||||
|
dark: bool
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
|
||||||
class ThemeMeta:
|
class ThemeMeta:
|
||||||
|
|
||||||
name: str = ""
|
name: str = ""
|
||||||
@@ -100,20 +107,18 @@ class GuiTheme:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
"_availSyntax", "_availThemes", "_currentTheme", "_darkThemes", "_guiPalette",
|
"_allThemes", "_currentTheme", "_darkThemes", "_guiPalette", "_lightThemes", "_meta",
|
||||||
"_lightThemes", "_qColors", "_styleSheets", "_svgColors", "_syntaxList", "_themeList",
|
"_qColors", "_styleSheets", "_svgColors", "_syntaxList", "baseButtonHeight",
|
||||||
"baseButtonHeight", "baseIconHeight", "baseIconSize", "buttonIconSize", "errorText",
|
"baseIconHeight", "baseIconSize", "buttonIconSize", "errorText", "fadedText",
|
||||||
"fadedText", "fontPixelSize", "fontPointSize", "getDecoration", "getHeaderDecoration",
|
"fontPixelSize", "fontPointSize", "getDecoration", "getHeaderDecoration",
|
||||||
"getHeaderDecorationNarrow", "getIcon", "getItemIcon", "getPixmap", "getToggleIcon",
|
"getHeaderDecorationNarrow", "getIcon", "getItemIcon", "getPixmap", "getToggleIcon",
|
||||||
"guiFont", "guiFontB", "guiFontBU", "guiFontFixed", "guiFontSmall", "helpText",
|
"guiFont", "guiFontB", "guiFontBU", "guiFontFixed", "guiFontSmall", "helpText",
|
||||||
"iconCache", "isDarkTheme", "syntaxTheme", "textNHeight", "textNWidth",
|
"iconCache", "isDarkTheme", "syntaxTheme", "textNHeight", "textNWidth",
|
||||||
"themeMeta",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
|
||||||
# Theme Objects
|
# Theme Objects
|
||||||
self.themeMeta = ThemeMeta()
|
|
||||||
self.iconCache = GuiIcons(self)
|
self.iconCache = GuiIcons(self)
|
||||||
self.syntaxTheme = SyntaxColors()
|
self.syntaxTheme = SyntaxColors()
|
||||||
self.isDarkTheme = False
|
self.isDarkTheme = False
|
||||||
@@ -123,12 +128,11 @@ class GuiTheme:
|
|||||||
self.fadedText = QColor(0, 0, 0)
|
self.fadedText = QColor(0, 0, 0)
|
||||||
self.errorText = QColor(255, 0, 0)
|
self.errorText = QColor(255, 0, 0)
|
||||||
|
|
||||||
# Load Themes
|
# Theme Data
|
||||||
|
self._meta = ThemeMeta()
|
||||||
self._currentTheme = ""
|
self._currentTheme = ""
|
||||||
self._guiPalette = QPalette()
|
self._guiPalette = QPalette()
|
||||||
self._themeList: list[T_ThemeEntry] = []
|
self._allThemes: dict[str, ThemeEntry] = {}
|
||||||
self._availThemes: dict[str, Path] = {}
|
|
||||||
self._availSyntax: dict[str, Path] = {}
|
|
||||||
self._styleSheets: dict[str, str] = {}
|
self._styleSheets: dict[str, str] = {}
|
||||||
self._svgColors: dict[str, bytes] = {}
|
self._svgColors: dict[str, bytes] = {}
|
||||||
self._qColors: dict[str, QColor] = {}
|
self._qColors: dict[str, QColor] = {}
|
||||||
@@ -180,16 +184,19 @@ class GuiTheme:
|
|||||||
logger.debug("Text 'N' Height: %d", self.textNHeight)
|
logger.debug("Text 'N' Height: %d", self.textNHeight)
|
||||||
logger.debug("Text 'N' Width: %d", self.textNWidth)
|
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
|
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:
|
def getTextWidth(self, text: str, font: QFont | None = None) -> int:
|
||||||
@@ -214,6 +221,19 @@ class GuiTheme:
|
|||||||
# Theme Methods
|
# 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:
|
def isDesktopDarkMode(self) -> bool:
|
||||||
"""Check if the desktop is in dark mode."""
|
"""Check if the desktop is in dark mode."""
|
||||||
if CONFIG.verQtValue >= 0x060500 and (hint := QGuiApplication.styleHints()):
|
if CONFIG.verQtValue >= 0x060500 and (hint := QGuiApplication.styleHints()):
|
||||||
@@ -261,8 +281,8 @@ class GuiTheme:
|
|||||||
darkMode = self.isDesktopDarkMode()
|
darkMode = self.isDesktopDarkMode()
|
||||||
|
|
||||||
theme = CONFIG.darkTheme if darkMode else CONFIG.lightTheme
|
theme = CONFIG.darkTheme if darkMode else CONFIG.lightTheme
|
||||||
if theme not in self._availThemes:
|
if theme not in self._allThemes:
|
||||||
logger.error("Could not find GUI theme '%s'", theme)
|
logger.error("Could not find theme for key '%s'", theme)
|
||||||
if darkMode:
|
if darkMode:
|
||||||
theme = DEF_GUI_DARK
|
theme = DEF_GUI_DARK
|
||||||
CONFIG.darkTheme = DEF_GUI_DARK
|
CONFIG.darkTheme = DEF_GUI_DARK
|
||||||
@@ -274,18 +294,19 @@ class GuiTheme:
|
|||||||
logger.info("Theme '%s' is already loaded", theme)
|
logger.info("Theme '%s' is already loaded", theme)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not (file := self._availThemes.get(theme)):
|
entry = self._allThemes.get(theme)
|
||||||
|
if not entry:
|
||||||
logger.error("Could not load GUI theme")
|
logger.error("Could not load GUI theme")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
CONFIG.splashMessage("Loading GUI theme ...")
|
CONFIG.splashMessage(f"Loading colour theme: {entry.name}")
|
||||||
logger.info("Loading GUI theme '%s'", theme)
|
logger.info("Loading GUI theme '%s'", theme)
|
||||||
parser = ConfigParser()
|
parser = ConfigParser()
|
||||||
try:
|
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)
|
parser.read_file(fo)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Could not read file: %s", file)
|
logger.error("Could not read file: %s", entry.path)
|
||||||
logException()
|
logException()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -305,9 +326,9 @@ class GuiTheme:
|
|||||||
meta.license = parser.get(sec, "license", fallback="N/A")
|
meta.license = parser.get(sec, "license", fallback="N/A")
|
||||||
meta.licenseUrl = parser.get(sec, "licenseurl", fallback="")
|
meta.licenseUrl = parser.get(sec, "licenseurl", fallback="")
|
||||||
|
|
||||||
self.themeMeta = meta
|
self._meta = meta
|
||||||
|
|
||||||
# Icons
|
# Base
|
||||||
sec = "Base"
|
sec = "Base"
|
||||||
if parser.has_section(sec):
|
if parser.has_section(sec):
|
||||||
self._setBaseColor("default", self._readColor(parser, sec, "default"))
|
self._setBaseColor("default", self._readColor(parser, sec, "default"))
|
||||||
@@ -447,35 +468,18 @@ class GuiTheme:
|
|||||||
self._svgColors["scene"] = color
|
self._svgColors["scene"] = color
|
||||||
self._svgColors["note"] = color
|
self._svgColors["note"] = color
|
||||||
|
|
||||||
|
self.isDarkTheme = darkMode
|
||||||
|
self._currentTheme = theme
|
||||||
|
|
||||||
# Load icons after the theme is parsed
|
# Load icons after the theme is parsed
|
||||||
self.iconCache.loadTheme(CONFIG.iconTheme)
|
self.iconCache.loadTheme(CONFIG.iconTheme)
|
||||||
|
|
||||||
# Finalise
|
# Finalise
|
||||||
self.isDarkTheme = darkMode
|
|
||||||
QApplication.setPalette(self._guiPalette)
|
QApplication.setPalette(self._guiPalette)
|
||||||
self._buildStyleSheets(self._guiPalette)
|
self._buildStyleSheets(self._guiPalette)
|
||||||
|
|
||||||
self._currentTheme = theme
|
|
||||||
CONFIG.splashMessage(f"Loaded GUI theme: {meta.name}")
|
|
||||||
|
|
||||||
return True
|
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:
|
def getStyleSheet(self, name: str) -> str:
|
||||||
"""Load a standard style sheet."""
|
"""Load a standard style sheet."""
|
||||||
return self._styleSheets.get(name, "")
|
return self._styleSheets.get(name, "")
|
||||||
@@ -493,12 +497,10 @@ class GuiTheme:
|
|||||||
def _resetTheme(self) -> None:
|
def _resetTheme(self) -> None:
|
||||||
"""Reset GUI colours to default values."""
|
"""Reset GUI colours to default values."""
|
||||||
palette = QPalette()
|
palette = QPalette()
|
||||||
|
isDark = self.isDesktopDarkMode()
|
||||||
text = palette.color(QPalette.ColorRole.Text)
|
|
||||||
window = palette.color(QPalette.ColorRole.Window)
|
|
||||||
isDark = text.lightnessF() > window.lightnessF()
|
|
||||||
|
|
||||||
# Reset GUI Palette
|
# Reset GUI Palette
|
||||||
|
default = palette.color(QPalette.ColorRole.Text)
|
||||||
faded = QColor(128, 128, 128)
|
faded = QColor(128, 128, 128)
|
||||||
dimmed = QColor(130, 130, 130) if isDark else QColor(190, 190, 190)
|
dimmed = QColor(130, 130, 130) if isDark else QColor(190, 190, 190)
|
||||||
red = QColor(242, 119, 122) if isDark else QColor(240, 40, 41)
|
red = QColor(242, 119, 122) if isDark else QColor(240, 40, 41)
|
||||||
@@ -520,7 +522,7 @@ class GuiTheme:
|
|||||||
self.iconCache.clear()
|
self.iconCache.clear()
|
||||||
self._svgColors = {}
|
self._svgColors = {}
|
||||||
self._qColors = {}
|
self._qColors = {}
|
||||||
self._setBaseColor("default", text)
|
self._setBaseColor("default", default)
|
||||||
self._setBaseColor("faded", faded)
|
self._setBaseColor("faded", faded)
|
||||||
self._setBaseColor("red", red)
|
self._setBaseColor("red", red)
|
||||||
self._setBaseColor("orange", orange)
|
self._setBaseColor("orange", orange)
|
||||||
@@ -531,7 +533,7 @@ class GuiTheme:
|
|||||||
self._setBaseColor("purple", purple)
|
self._setBaseColor("purple", purple)
|
||||||
self._setBaseColor("root", blue)
|
self._setBaseColor("root", blue)
|
||||||
self._setBaseColor("folder", yellow)
|
self._setBaseColor("folder", yellow)
|
||||||
self._setBaseColor("file", text)
|
self._setBaseColor("file", default)
|
||||||
self._setBaseColor("title", green)
|
self._setBaseColor("title", green)
|
||||||
self._setBaseColor("chapter", red)
|
self._setBaseColor("chapter", red)
|
||||||
self._setBaseColor("scene", blue)
|
self._setBaseColor("scene", blue)
|
||||||
@@ -582,6 +584,35 @@ class GuiTheme:
|
|||||||
|
|
||||||
return
|
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:
|
class GuiIcons:
|
||||||
"""The icon class manages the content of the assets/icons folder,
|
"""The icon class manages the content of the assets/icons folder,
|
||||||
@@ -593,8 +624,8 @@ class GuiIcons:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
"_availThemes", "_headerDec", "_headerDecNarrow", "_meta", "_noIcon",
|
"_allThemes", "_headerDec", "_headerDecNarrow", "_meta",
|
||||||
"_qIcons", "_svgData", "_theme", "_themeList",
|
"_noIcon", "_qIcons", "_svgData", "_theme",
|
||||||
)
|
)
|
||||||
|
|
||||||
TOGGLE_ICON_KEYS: Final[dict[str, tuple[str, str]]] = {
|
TOGGLE_ICON_KEYS: Final[dict[str, tuple[str, str]]] = {
|
||||||
@@ -612,21 +643,15 @@ class GuiIcons:
|
|||||||
self._meta = ThemeMeta()
|
self._meta = ThemeMeta()
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
|
self._allThemes: dict[str, ThemeEntry] = {}
|
||||||
self._svgData: dict[str, bytes] = {}
|
self._svgData: dict[str, bytes] = {}
|
||||||
self._qIcons: dict[str, QIcon] = {}
|
self._qIcons: dict[str, QIcon] = {}
|
||||||
self._headerDec: list[QPixmap] = []
|
self._headerDec: list[QPixmap] = []
|
||||||
self._headerDecNarrow: list[QPixmap] = []
|
self._headerDecNarrow: list[QPixmap] = []
|
||||||
|
|
||||||
# Icon Theme Path
|
|
||||||
self._availThemes: dict[str, Path] = {}
|
|
||||||
self._themeList: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
# None Icon
|
# None Icon
|
||||||
self._noIcon = QIcon(str(CONFIG.assetPath("icons") / "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
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
@@ -638,29 +663,48 @@ class GuiIcons:
|
|||||||
self._meta = ThemeMeta()
|
self._meta = ThemeMeta()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
##
|
||||||
|
# Properties
|
||||||
|
##
|
||||||
|
|
||||||
|
@property
|
||||||
|
def iconThemes(self) -> dict[str, ThemeEntry]:
|
||||||
|
"""Return a dictionary of all icon themes."""
|
||||||
|
return self._allThemes
|
||||||
|
|
||||||
##
|
##
|
||||||
# Actions
|
# 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:
|
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.
|
||||||
"""
|
"""
|
||||||
if theme not in self._availThemes:
|
if theme not in self._allThemes:
|
||||||
logger.error("Could not find icon theme '%s'", theme)
|
logger.error("Could not find icon theme '%s'", theme)
|
||||||
theme = DEF_ICONS
|
theme = DEF_ICONS
|
||||||
CONFIG.iconTheme = theme
|
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")
|
logger.error("Could not load icon theme")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
CONFIG.splashMessage("Loading icon theme ...")
|
CONFIG.splashMessage(f"Loading icon theme: {entry.name}")
|
||||||
logger.info("Loading icon theme '%s'", theme)
|
logger.info("Loading icon theme '%s'", theme)
|
||||||
try:
|
try:
|
||||||
meta = ThemeMeta()
|
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:
|
for icon in icons:
|
||||||
bits = icon.partition("=")
|
bits = icon.partition("=")
|
||||||
key = bits[0].strip()
|
key = bits[0].strip()
|
||||||
@@ -676,14 +720,12 @@ class GuiIcons:
|
|||||||
meta.license = value
|
meta.license = value
|
||||||
self._meta = meta
|
self._meta = meta
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Could not read file: %s", file)
|
logger.error("Could not read file: %s", entry.path)
|
||||||
logException()
|
logException()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
CONFIG.splashMessage(f"Loaded icon theme: {meta.name}")
|
|
||||||
CONFIG.splashMessage("Generating additional icons ...")
|
|
||||||
|
|
||||||
# Populate generated icons cache
|
# Populate generated icons cache
|
||||||
|
CONFIG.splashMessage("Generating additional icons ...")
|
||||||
self.getHeaderDecoration(0)
|
self.getHeaderDecoration(0)
|
||||||
self.getHeaderDecorationNarrow(0)
|
self.getHeaderDecorationNarrow(0)
|
||||||
|
|
||||||
@@ -811,21 +853,6 @@ class GuiIcons:
|
|||||||
]
|
]
|
||||||
return self._headerDecNarrow[minmax(hLevel, 0, 5)]
|
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
|
# Internal Functions
|
||||||
##
|
##
|
||||||
@@ -870,49 +897,39 @@ class GuiIcons:
|
|||||||
tMode = Qt.TransformationMode.SmoothTransformation
|
tMode = Qt.TransformationMode.SmoothTransformation
|
||||||
return pixmap.scaledToHeight(height, tMode)
|
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
|
# Module Functions
|
||||||
# ================
|
# ================
|
||||||
|
|
||||||
def _listConf(target: dict, path: Path, extension: str) -> None:
|
def _listContent(data: list[Path], path: Path, extension: str) -> None:
|
||||||
"""Scan for theme files and populate the dictionary."""
|
"""List files of a specific type and extend the list."""
|
||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
for item in path.iterdir():
|
data.extend(n for n in path.iterdir() if n.is_file() and n.suffix == extension)
|
||||||
if item.is_file() and item.name.endswith(extension):
|
|
||||||
target[item.stem] = item
|
|
||||||
return
|
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 ""
|
|
||||||
|
|||||||
@@ -937,8 +937,7 @@ class GuiMain(QMainWindow):
|
|||||||
|
|
||||||
def changeEvent(self, event: QEvent) -> None:
|
def changeEvent(self, event: QEvent) -> None:
|
||||||
"""Capture application change events."""
|
"""Capture application change events."""
|
||||||
if int(event.type()) == 210:
|
if int(event.type()) == 210: # ThemeChange
|
||||||
# ThemeChange
|
|
||||||
self.checkThemeUpdate()
|
self.checkThemeUpdate()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ class SharedData(QObject):
|
|||||||
is created.
|
is created.
|
||||||
"""
|
"""
|
||||||
self._theme = theme
|
self._theme = theme
|
||||||
|
self._theme.initThemes()
|
||||||
return
|
return
|
||||||
|
|
||||||
def initSharedData(self, gui: GuiMain) -> None:
|
def initSharedData(self, gui: GuiMain) -> None:
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from tests.tools import C, buildTestProject
|
|||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
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)
|
buildTestProject(nwGUI, projPath)
|
||||||
cHandle = SHARED.project.newFile("A Note", C.hCharRoot)
|
cHandle = SHARED.project.newFile("A Note", C.hCharRoot)
|
||||||
newDoc = SHARED.project.storage.getDocument(cHandle)
|
newDoc = SHARED.project.storage.getDocument(cHandle)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from novelwriter import CONFIG, SHARED
|
|||||||
from novelwriter.config import DEF_GUI_LIGHT
|
from novelwriter.config import DEF_GUI_LIGHT
|
||||||
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 novelwriter.gui.theme import _listContent
|
||||||
|
|
||||||
from tests.mocked import causeOSError
|
from tests.mocked import causeOSError
|
||||||
from tests.tools import writeFile
|
from tests.tools import writeFile
|
||||||
@@ -54,18 +54,17 @@ def testGuiTheme_Main(qtbot, nwGUI, tstPaths):
|
|||||||
# Scan for Themes
|
# Scan for Themes
|
||||||
# ===============
|
# ===============
|
||||||
|
|
||||||
result = {}
|
result = []
|
||||||
_listConf({}, Path("not_a_path"), ".conf")
|
_listContent(result, Path("not_a_path"), ".conf")
|
||||||
assert result == {}
|
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")
|
||||||
|
|
||||||
_listConf(result, tstPaths.cnfDir / "themes", ".conf")
|
_listContent(result, tstPaths.cnfDir / "themes", ".conf")
|
||||||
assert result["themeone"] == themeOne
|
assert result == [themeOne, themeTwo]
|
||||||
assert result["themetwo"] == themeTwo
|
|
||||||
|
|
||||||
# Parse Colours
|
# Parse Colours
|
||||||
# =============
|
# =============
|
||||||
@@ -157,17 +156,17 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths):
|
|||||||
with monkeypatch.context() as mp:
|
with monkeypatch.context() as mp:
|
||||||
mp.setattr("builtins.open", causeOSError)
|
mp.setattr("builtins.open", causeOSError)
|
||||||
theme._themeList = []
|
theme._themeList = []
|
||||||
assert theme.listThemes() == []
|
assert theme.getColourThemes() == []
|
||||||
|
|
||||||
# Load the theme info, default themes first
|
# Load the theme info, default themes first
|
||||||
themesList = theme.listThemes()
|
themesList = theme.getColourThemes()
|
||||||
assert themesList[0] == ("default_dark", "Default Dark Theme")
|
assert themesList[0] == ("default_dark", "Default Dark Theme")
|
||||||
assert themesList[1] == ("default_light", "Default Light Theme")
|
assert themesList[1] == ("default_light", "Default Light Theme")
|
||||||
assert themesList[2] == ("cyberpunk_night", "Cyberpunk Night")
|
assert themesList[2] == ("cyberpunk_night", "Cyberpunk Night")
|
||||||
assert themesList[3] == ("dracula", "Dracula")
|
assert themesList[3] == ("dracula", "Dracula")
|
||||||
|
|
||||||
# A second call should returned the cached list
|
# A second call should returned the cached list
|
||||||
assert theme.listThemes() == theme._themeList
|
assert theme.getColourThemes() == theme._themeList
|
||||||
|
|
||||||
# Check handling of broken theme settings
|
# Check handling of broken theme settings
|
||||||
CONFIG.guiTheme = "not_a_theme"
|
CONFIG.guiTheme = "not_a_theme"
|
||||||
@@ -376,18 +375,18 @@ def testGuiTheme_IconThemes(qtbot, caplog, monkeypatch, nwGUI, tstPaths):
|
|||||||
# Load error returns empty list
|
# Load error returns empty list
|
||||||
with monkeypatch.context() as mp:
|
with monkeypatch.context() as mp:
|
||||||
mp.setattr("builtins.open", causeOSError)
|
mp.setattr("builtins.open", causeOSError)
|
||||||
themes = iconCache.listThemes()
|
themes = iconCache.getIconThemes()
|
||||||
assert themes == []
|
assert themes == []
|
||||||
|
|
||||||
# Successful read
|
# Successful read
|
||||||
themes = iconCache.listThemes()
|
themes = iconCache.getIconThemes()
|
||||||
assert len(themes) > 1
|
assert len(themes) > 1
|
||||||
assert "material_rounded_normal" in dict(themes)
|
assert "material_rounded_normal" in dict(themes)
|
||||||
|
|
||||||
# Load error doesn't matter on second read since list is cached
|
# Load error doesn't matter on second read since list is cached
|
||||||
with monkeypatch.context() as mp:
|
with monkeypatch.context() as mp:
|
||||||
mp.setattr("builtins.open", causeOSError)
|
mp.setattr("builtins.open", causeOSError)
|
||||||
assert iconCache.listThemes() == themes
|
assert iconCache.getIconThemes() == themes
|
||||||
|
|
||||||
# qtbot.stop()
|
# qtbot.stop()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user