diff --git a/novelwriter/assets/themes/cyberpunk_night.conf b/novelwriter/assets/themes/cyberpunk_night.conf index bdb2ee43..125cab45 100644 --- a/novelwriter/assets/themes/cyberpunk_night.conf +++ b/novelwriter/assets/themes/cyberpunk_night.conf @@ -30,11 +30,11 @@ note = 255, 255, 0 window = 0, 0, 0 windowtext = 150, 150, 150 base = 0, 0, 0 -alternatebase = 30, 20, 45 +alternatebase = 40, 40, 40 text = 150, 150, 150 tooltipbase = 40, 20, 70 tooltiptext = 255, 255, 255 -button = 5, 0, 10 +button = 5, 0, 10 buttontext = 150, 150, 150 brighttext = 255, 255, 255 highlight = 50, 30, 80 @@ -45,7 +45,4 @@ linkvisited = 50, 0, 80 [GUI] helptext = 97, 97, 97 fadedtext = 97, 97, 97 -errortext = 255, 77, 77 -statusnone = 50, 50, 50 -statussaved = 77, 255, 77 -statusunsaved = 255, 77, 77 +errortext = 242, 72, 23 diff --git a/novelwriter/assets/themes/default_dark.conf b/novelwriter/assets/themes/default_dark.conf index a7cbb57f..76916604 100644 --- a/novelwriter/assets/themes/default_dark.conf +++ b/novelwriter/assets/themes/default_dark.conf @@ -47,6 +47,3 @@ linkvisited = 102, 153, 204 helptext = 164, 164, 164 fadedtext = 148, 148, 148 errortext = 255, 164, 164 -statusnone = 150, 152, 150 -statussaved = 39, 135, 78 -statusunsaved = 138, 32, 32 diff --git a/novelwriter/assets/themes/default_light.conf b/novelwriter/assets/themes/default_light.conf index b6f7e8d5..29b4b98a 100644 --- a/novelwriter/assets/themes/default_light.conf +++ b/novelwriter/assets/themes/default_light.conf @@ -31,7 +31,7 @@ note = 234, 183, 0 window = 239, 239, 239 windowtext = 0, 0, 0 base = 255, 255, 255 -alternatebase = 239, 239, 239 +alternatebase = 224, 224, 224 text = 0, 0, 0 tooltipbase = 255, 255, 220 tooltiptext = 0, 0, 0 @@ -47,6 +47,3 @@ linkvisited = 66, 113, 174 helptext = 92, 92, 92 fadedtext = 108, 108, 108 errortext = 255, 92, 92 -statusnone = 120, 120, 120 -statussaved = 2, 133, 37 -statusunsaved = 200, 15, 39 diff --git a/novelwriter/assets/themes/dracula.conf b/novelwriter/assets/themes/dracula.conf index 827d4eab..5805b84b 100644 --- a/novelwriter/assets/themes/dracula.conf +++ b/novelwriter/assets/themes/dracula.conf @@ -62,6 +62,3 @@ linkvisited = 139, 233, 253 helptext = 204, 172, 249 fadedtext = 98, 114, 164 errortext = 255, 85, 85 -statusnone = 98, 114, 164 -statussaved = 80, 250, 123 -statusunsaved = 255, 85, 85 diff --git a/novelwriter/assets/themes/solarized_dark.conf b/novelwriter/assets/themes/solarized_dark.conf index 6d835dfb..0f401eb9 100644 --- a/novelwriter/assets/themes/solarized_dark.conf +++ b/novelwriter/assets/themes/solarized_dark.conf @@ -15,7 +15,7 @@ yellow = 181, 137, 0 green = 133, 153, 0 aqua = 42, 161, 152 blue = 38, 139, 210 -purple = 108, 113, 196 +purple = 211, 54, 130 [Project] root = 42, 161, 152 @@ -30,7 +30,7 @@ note = 181, 137, 0 window = 0, 43, 54 windowtext = 253, 246, 227 base = 7, 54, 66 -alternatebase = 0, 43, 54 +alternatebase = 88, 110, 117 text = 253, 246, 227 tooltipbase = 133, 153, 0 tooltiptext = 0, 43, 54 @@ -43,9 +43,6 @@ link = 38, 139, 210 linkvisited = 38, 139, 210 [GUI] -helptext = 166, 161, 149 -fadedtext = 166, 161, 149 -errortext = 255, 161, 149 -statusnone = 88, 110, 117 -statussaved = 42, 161, 152 -statusunsaved = 203, 75, 22 +helptext = 101, 123, 131 +fadedtext = 101, 123, 131 +errortext = 220, 50, 47 diff --git a/novelwriter/assets/themes/solarized_light.conf b/novelwriter/assets/themes/solarized_light.conf index b9e3162c..06f4cb20 100644 --- a/novelwriter/assets/themes/solarized_light.conf +++ b/novelwriter/assets/themes/solarized_light.conf @@ -15,7 +15,7 @@ yellow = 181, 137, 0 green = 133, 153, 0 aqua = 42, 161, 152 blue = 38, 139, 210 -purple = 108, 113, 196 +purple = 211, 54, 130 [Project] root = 42, 161, 152 @@ -30,7 +30,7 @@ note = 181, 137, 0 window = 238, 232, 213 windowtext = 0, 43, 54 base = 253, 246, 227 -alternatebase = 238, 232, 213 +alternatebase = 147, 161, 161 text = 0, 43, 54 tooltipbase = 133, 153, 0 tooltiptext = 0, 43, 54 @@ -45,7 +45,4 @@ linkvisited = 38, 139, 210 [GUI] helptext = 78, 91, 95 fadedtext = 78, 91, 95 -errortext = 255, 91, 95 -statusnone = 88, 110, 117 -statussaved = 42, 161, 152 -statusunsaved = 203, 75, 22 +errortext = 220, 50, 47 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/about.py b/novelwriter/dialogs/about.py index 0d5ca304..cb132ce1 100644 --- a/novelwriter/dialogs/about.py +++ b/novelwriter/dialogs/about.py @@ -52,7 +52,7 @@ class GuiAbout(NDialog): self.resize(700, 500) # Logo and Banner - self.nwImage = SHARED.theme.loadDecoration("nw-text", h=36) + self.nwImage = SHARED.theme.getDecoration("nw-text", h=36) self.nwLogo = QLabel(self) self.nwLogo.setPixmap(SHARED.theme.getPixmap("novelwriter", (128, 128))) 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/extensions/switch.py b/novelwriter/extensions/switch.py index ef936553..b6accdae 100644 --- a/novelwriter/extensions/switch.py +++ b/novelwriter/extensions/switch.py @@ -88,31 +88,20 @@ class NSwitch(QAbstractButton): def paintEvent(self, event: QPaintEvent) -> None: """Drawing the switch itself.""" + palette = self.palette() + painter = QPainter(self) painter.setRenderHint(QtPaintAntiAlias, True) - painter.setPen(QtNoPen) + painter.setOpacity(1.0 if self.isEnabled() else 0.5) - palette = self.palette() - if self.isChecked(): - trackBrush = palette.highlight() - thumbBrush = palette.highlightedText() - else: - trackBrush = palette.midlight() - thumbBrush = palette.light() - - if self.isEnabled(): - trackOpacity = 1.0 - else: - trackOpacity = 0.6 - trackBrush = palette.mid() - thumbBrush = palette.mid() - - painter.setBrush(trackBrush) - painter.setOpacity(trackOpacity) + painter.setPen(palette.mid().color()) + painter.setBrush(palette.highlight() if self.isChecked() else palette.alternateBase()) painter.drawRoundedRect(0, 0, self._xW, self._xH, self._xR, self._xR) - painter.setBrush(thumbBrush) + painter.setPen(QtNoPen) + painter.setBrush(palette.highlightedText()) painter.drawEllipse(self._offset - self._rR, 2, self._rH, self._rH) + painter.end() return diff --git a/novelwriter/gui/statusbar.py b/novelwriter/gui/statusbar.py index 6dd75830..258c0a67 100644 --- a/novelwriter/gui/statusbar.py +++ b/novelwriter/gui/statusbar.py @@ -133,9 +133,9 @@ class GuiMainStatus(QStatusBar): self.idlePixmap = SHARED.theme.getPixmap("timer_off", (iPx, iPx)) self.timeIcon.setPixmap(self.timePixmap) - colNone = SHARED.theme.statNone - colSaved = SHARED.theme.statSaved - colUnsaved = SHARED.theme.statUnsaved + colNone = SHARED.theme.getIconColor("default").darker(150) + colSaved = SHARED.theme.getIconColor("green").darker(150) + colUnsaved = SHARED.theme.getIconColor("red").darker(150) self.docIcon.setColors(colNone, colSaved, colUnsaved) self.projIcon.setColors(colNone, colSaved, colUnsaved) diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 0348d70b..70156dc7 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -38,10 +38,11 @@ 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 -from novelwriter.types import QtPaintAntiAlias, QtTransparent +from novelwriter.types import QtBlack, QtPaintAntiAlias, QtTransparent logger = logging.getLogger(__name__) @@ -93,20 +94,17 @@ class GuiTheme: __slots__ = ( # Attributes - "iconCache", "themeMeta", "isDarkTheme", "statNone", "statUnsaved", - "statSaved", "helpText", "fadedText", "errorText", "syntaxMeta", - "syntaxTheme", "guiFont", "guiFontB", "guiFontBU", "guiFontSmall", - "fontPointSize", "fontPixelSize", "baseIconHeight", "baseButtonHeight", - "textNHeight", "textNWidth", "baseIconSize", "buttonIconSize", - "guiFontFixed", + "iconCache", "themeMeta", "isDarkTheme", "helpText", "fadedText", "errorText", + "syntaxMeta", "syntaxTheme", "guiFont", "guiFontB", "guiFontBU", "guiFontSmall", + "fontPointSize", "fontPixelSize", "baseIconHeight", "baseButtonHeight", "textNHeight", + "textNWidth", "baseIconSize", "buttonIconSize", "guiFontFixed", # Functions - "getIcon", "getPixmap", "getItemIcon", "getToggleIcon", - "loadDecoration", "getHeaderDecoration", "getHeaderDecorationNarrow", + "getIcon", "getPixmap", "getItemIcon", "getIconColor", "getToggleIcon", "getDecoration", + "getHeaderDecoration", "getHeaderDecorationNarrow", # Internal - "_guiPalette", "_themeList", "_syntaxList", "_availThemes", - "_availSyntax", "_styleSheets", + "_guiPalette", "_themeList", "_syntaxList", "_availThemes", "_availSyntax", "_styleSheets", ) def __init__(self) -> None: @@ -117,12 +115,10 @@ class GuiTheme: self.themeMeta = ThemeMeta() self.isDarkTheme = False - self.statNone = QColor(0, 0, 0) - self.statUnsaved = QColor(0, 0, 0) - self.statSaved = QColor(0, 0, 0) - self.helpText = QColor(0, 0, 0) - self.fadedText = QColor(0, 0, 0) - self.errorText = QColor(255, 0, 0) + # Special Text Colours + self.helpText = QColor(0, 0, 0) + self.fadedText = QColor(0, 0, 0) + self.errorText = QColor(255, 0, 0) # Syntax Theme self.syntaxMeta = ThemeMeta() @@ -136,10 +132,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() @@ -148,8 +144,9 @@ class GuiTheme: self.getIcon = self.iconCache.getIcon self.getPixmap = self.iconCache.getPixmap self.getItemIcon = self.iconCache.getItemIcon + self.getIconColor = self.iconCache.getIconColor self.getToggleIcon = self.iconCache.getToggleIcon - self.loadDecoration = self.iconCache.loadDecoration + self.getDecoration = self.iconCache.getDecoration self.getHeaderDecoration = self.iconCache.getHeaderDecoration self.getHeaderDecorationNarrow = self.iconCache.getHeaderDecorationNarrow @@ -213,25 +210,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 @@ -297,12 +292,9 @@ class GuiTheme: # GUI sec = "GUI" if parser.has_section(sec): - self.helpText = self._parseColour(parser, sec, "helptext") - self.fadedText = self._parseColour(parser, sec, "fadedtext") - self.errorText = self._parseColour(parser, sec, "errortext") - self.statNone = self._parseColour(parser, sec, "statusnone") - self.statUnsaved = self._parseColour(parser, sec, "statusunsaved") - self.statSaved = self._parseColour(parser, sec, "statussaved") + self.helpText = self._parseColour(parser, sec, "helptext") + self.fadedText = self._parseColour(parser, sec, "fadedtext") + self.errorText = self._parseColour(parser, sec, "errortext") # Update Dependant Colours # Based on: https://github.com/qt/qtbase/blob/dev/src/gui/kernel/qplatformtheme.cpp @@ -315,18 +307,24 @@ class GuiTheme: QtColInactive = QPalette.ColorGroup.Inactive QtColDisabled = QPalette.ColorGroup.Disabled - light = window.lighter(150) - mid = window.darker(130) + if window.lightnessF() < 0.15: + # If window is too dark, we need a lighter ref colour for shades + ref = QColor.fromHslF(window.hueF(), window.saturationF(), 0.15, window.alphaF()) + else: + ref = window + + light = ref.lighter(150) + mid = ref.darker(130) midLight = mid.lighter(110) - dark = window.darker(150) + dark = ref.darker(150) shadow = dark.darker(135) darkOff = dark.darker(150) - shadowOff = shadow.darker(150) + shadowOff = ref.darker(150) grey = QColor(120, 120, 120) if isDark else QColor(140, 140, 140) dimmed = QColor(130, 130, 130) if isDark else QColor(190, 190, 190) - placeholder = text + placeholder = QColor(text) placeholder.setAlpha(128) self._guiPalette.setBrush(QPalette.ColorRole.Light, light) @@ -353,7 +351,7 @@ class GuiTheme: self._guiPalette.setBrush(QtColInactive, QPalette.ColorRole.Accent, highlight) self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Accent, grey) - # Load icons after theme is parsed + # Load icons after the theme is parsed self.iconCache.loadTheme(CONFIG.iconTheme) # Finalise @@ -365,25 +363,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 @@ -434,13 +430,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 @@ -449,13 +446,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 @@ -478,7 +476,6 @@ class GuiTheme: # Reset GUI Palette faded = QColor(128, 128, 128) dimmed = QColor(130, 130, 130) if isDark else QColor(190, 190, 190) - grey = QColor(120, 120, 120) if isDark else QColor(140, 140, 140) red = QColor(242, 119, 122) if isDark else QColor(240, 40, 41) orange = QColor(249, 145, 57) if isDark else QColor(245, 135, 31) yellow = QColor(255, 204, 102) if isDark else QColor(234, 183, 0) @@ -487,12 +484,10 @@ class GuiTheme: blue = QColor(102, 153, 204) if isDark else QColor(66, 113, 174) purple = QColor(204, 153, 204) if isDark else QColor(137, 89, 168) - self.statNone = grey - self.statUnsaved = red - self.statSaved = green - self.helpText = dimmed - self.fadedText = faded - self.errorText = red + # Text Colours + self.helpText = dimmed + self.fadedText = faded + self.errorText = red self._guiPalette = palette @@ -518,17 +513,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])) @@ -581,8 +565,9 @@ class GuiIcons: """ __slots__ = ( - "mainTheme", "themeMeta", "_svgData", "_svgColours", "_qIcons", - "_headerDec", "_headerDecNarrow", "_themeList", "_iconPath", "_noIcon", + "mainTheme", "themeMeta", "_svgData", "_svgColors", "_qColors", + "_qIcons", "_headerDec", "_headerDecNarrow", "_availThemes", + "_themeList", "_noIcon", ) TOGGLE_ICON_KEYS: dict[str, tuple[str, str]] = { @@ -601,24 +586,29 @@ class GuiIcons: # Storage self._svgData: dict[str, bytes] = {} - self._svgColours: dict[str, bytes] = {} + self._svgColors: dict[str, bytes] = {} + self._qColors: dict[str, QColor] = {} self._qIcons: dict[str, QIcon] = {} self._headerDec: list[QPixmap] = [] 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 def clear(self) -> None: """Clear the icon cache.""" self._svgData = {} - self._svgColours = {} + self._svgColors = {} + self._qColors = {} self._qIcons = {} self._headerDec = [] self._headerDecNarrow = [] @@ -629,16 +619,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() @@ -654,58 +652,37 @@ 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 # Set colour overrides for project item icons if (override := CONFIG.iconColTree) != "theme": - color = self._svgColours.get(override, b"#000000") - self._svgColours["root"] = color - self._svgColours["folder"] = color + color = self._svgColors.get(override, b"#000000") + self._svgColors["root"] = color + self._svgColors["folder"] = color if not CONFIG.iconColDocs: - self._svgColours["file"] = color - self._svgColours["title"] = color - self._svgColours["chapter"] = color - self._svgColours["scene"] = color - self._svgColours["note"] = color + self._svgColors["file"] = color + self._svgColors["title"] = color + self._svgColors["chapter"] = color + self._svgColors["scene"] = color + self._svgColors["note"] = color return True def setIconColor(self, key: str, color: QColor) -> None: """Set an icon colour for a named colour.""" - self._svgColours[key] = color.name(QColor.NameFormat.HexRgb).encode("utf-8") + self._qColors[key] = QColor(color) + self._svgColors[key] = color.name(QColor.NameFormat.HexRgb).encode("utf-8") return ## # Access Functions ## - def loadDecoration(self, name: str, w: int | None = None, h: int | None = None) -> QPixmap: - """Load graphical decoration element based on the decoration - map or the icon map. This function always returns a QPixmap. - """ - if name in self.IMAGE_MAP: - idx = int(self.mainTheme.isDarkTheme) - imgPath = CONFIG.assetPath("images") / self.IMAGE_MAP[name][idx] - else: - logger.error("Decoration with name '%s' does not exist", name) - return QPixmap() - - if not imgPath.is_file(): - logger.error("Asset not found: %s", imgPath) - return QPixmap() - - pixmap = QPixmap(str(imgPath)) - tMode = Qt.TransformationMode.SmoothTransformation - if w is not None and h is not None: - return pixmap.scaled(w, h, Qt.AspectRatioMode.IgnoreAspectRatio, tMode) - elif w is None and h is not None: - return pixmap.scaledToHeight(h, tMode) - elif w is not None and h is None: - return pixmap.scaledToWidth(w, tMode) - - return pixmap + def getIconColor(self, name: str) -> QColor: + """Return an icon color.""" + return QColor(self._qColors.get(name) or QtBlack) def getIcon(self, name: str, color: str | None = None, w: int = 24, h: int = 24) -> QIcon: """Return an icon from the icon buffer, or load it.""" @@ -729,13 +706,6 @@ class GuiIcons: return icon return self._noIcon - def getPixmap(self, name: str, size: tuple[int, int], color: str | None = None) -> QPixmap: - """Return an icon from the icon buffer as a QPixmap. If it - doesn't exist, return an empty QPixmap. - """ - w, h = size - return self.getIcon(name, color, w, h).pixmap(w, h, QIcon.Mode.Normal) - def getItemIcon( self, tType: nwItemType, tClass: nwItemClass, tLayout: nwItemLayout, hLevel: str = "H0" ) -> QIcon: @@ -772,6 +742,39 @@ class GuiIcons: return self.getIcon(name, color) + def getPixmap(self, name: str, size: tuple[int, int], color: str | None = None) -> QPixmap: + """Return an icon from the icon buffer as a QPixmap. If it + doesn't exist, return an empty QPixmap. + """ + w, h = size + return self.getIcon(name, color, w, h).pixmap(w, h, QIcon.Mode.Normal) + + def getDecoration(self, name: str, w: int | None = None, h: int | None = None) -> QPixmap: + """Load graphical decoration element based on the decoration + map or the icon map. This function always returns a QPixmap. + """ + if name in self.IMAGE_MAP: + idx = int(self.mainTheme.isDarkTheme) + imgPath = CONFIG.assetPath("images") / self.IMAGE_MAP[name][idx] + else: + logger.error("Decoration with name '%s' does not exist", name) + return QPixmap() + + if not imgPath.is_file(): + logger.error("Asset not found: %s", imgPath) + return QPixmap() + + pixmap = QPixmap(str(imgPath)) + tMode = Qt.TransformationMode.SmoothTransformation + if w is not None and h is not None: + return pixmap.scaled(w, h, Qt.AspectRatioMode.IgnoreAspectRatio, tMode) + elif w is None and h is not None: + return pixmap.scaledToHeight(h, tMode) + elif w is not None and h is None: + return pixmap.scaledToWidth(w, tMode) + + return pixmap + def getHeaderDecoration(self, hLevel: int) -> QPixmap: """Get the decoration for a specific heading level.""" if not self._headerDec: @@ -804,12 +807,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 @@ -823,12 +827,12 @@ 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"): + if fill := self._svgColors.get(color or "default"): svg = svg.replace(b"#000000", fill) pixmap = QPixmap(w, h) pixmap.fill(QtTransparent) @@ -850,7 +854,7 @@ class GuiIcons: painter = QPainter(pixmap) painter.setRenderHint(QtPaintAntiAlias) - if fill := self._svgColours.get(color or "default"): + if fill := self._svgColors.get(color or "default"): painter.fillPath(path, QColor(fill.decode(encoding="utf-8"))) painter.end() @@ -861,6 +865,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.""" @@ -868,17 +880,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: @@ -890,7 +901,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/novelwriter/tools/welcome.py b/novelwriter/tools/welcome.py index 32791caf..354b206d 100644 --- a/novelwriter/tools/welcome.py +++ b/novelwriter/tools/welcome.py @@ -75,8 +75,8 @@ class GuiWelcome(NDialog): # Elements # ======== - self.bgImage = SHARED.theme.loadDecoration("welcome") - self.nwImage = SHARED.theme.loadDecoration("nw-text", h=36) + self.bgImage = SHARED.theme.getDecoration("welcome") + self.nwImage = SHARED.theme.getDecoration("nw-text", h=36) self.bgColor = QColor(54, 54, 54) if SHARED.theme.isDarkTheme else QColor(255, 255, 255) self.nwLogo = QLabel(self) 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..825a85ee 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 @@ -181,13 +184,17 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths): # Check a few values assert mainTheme._guiPalette.color( - QPalette.ColorRole.Window).getRgb() == (239, 239, 239, 255) + QPalette.ColorRole.Window + ).getRgb() == (239, 239, 239, 255) assert mainTheme._guiPalette.color( - QPalette.ColorRole.WindowText).getRgb() == (0, 0, 0, 255) + QPalette.ColorRole.WindowText + ).getRgb() == (0, 0, 0, 255) assert mainTheme._guiPalette.color( - QPalette.ColorRole.Base).getRgb() == (255, 255, 255, 255) + QPalette.ColorRole.Base + ).getRgb() == (255, 255, 255, 255) assert mainTheme._guiPalette.color( - QPalette.ColorRole.AlternateBase).getRgb() == (239, 239, 239, 255) + QPalette.ColorRole.AlternateBase + ).getRgb() == (224, 224, 224, 255) # Load Default Dark Theme # ======================= @@ -280,7 +287,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: @@ -292,29 +302,29 @@ def testGuiTheme_IconThemes(qtbot, caplog, monkeypatch, nwGUI, tstPaths): assert iconCache.themeMeta.name == "Material Symbols - Rounded Medium" # Load with project colour override - purple = iconCache._svgColours["purple"] - assert iconCache._svgColours["root"] != purple - assert iconCache._svgColours["folder"] != purple - assert iconCache._svgColours["file"] != purple - assert iconCache._svgColours["title"] != purple - assert iconCache._svgColours["chapter"] != purple - assert iconCache._svgColours["scene"] != purple - assert iconCache._svgColours["note"] != purple + purple = iconCache._svgColors["purple"] + assert iconCache._svgColors["root"] != purple + assert iconCache._svgColors["folder"] != purple + assert iconCache._svgColors["file"] != purple + assert iconCache._svgColors["title"] != purple + assert iconCache._svgColors["chapter"] != purple + assert iconCache._svgColors["scene"] != purple + assert iconCache._svgColors["note"] != purple CONFIG.iconColTree = "purple" assert iconCache.loadTheme("material_rounded_normal") is True - assert iconCache._svgColours["root"] == purple - assert iconCache._svgColours["folder"] == purple - assert iconCache._svgColours["file"] == purple - assert iconCache._svgColours["title"] == purple - assert iconCache._svgColours["chapter"] == purple - assert iconCache._svgColours["scene"] == purple - assert iconCache._svgColours["note"] == purple + assert iconCache._svgColors["root"] == purple + assert iconCache._svgColors["folder"] == purple + assert iconCache._svgColors["file"] == purple + assert iconCache._svgColors["title"] == purple + assert iconCache._svgColors["chapter"] == purple + assert iconCache._svgColors["scene"] == purple + assert iconCache._svgColors["note"] == purple # Change some colours iconCache.setIconColor("root", QColor(255, 255, 255)) - assert iconCache._svgColours["root"] != purple - assert iconCache._svgColours["root"] == b"#ffffff" + assert iconCache._svgColors["root"] != purple + assert iconCache._svgColors["root"] == b"#ffffff" # List Themes # =========== @@ -453,31 +463,31 @@ def testGuiTheme_LoadDecorations(qtbot, monkeypatch, nwGUI): # ================ # Invalid name should return empty pixmap - qPix = iconCache.loadDecoration("stuff") + qPix = iconCache.getDecoration("stuff") assert qPix.isNull() is True # Load an image - qPix = iconCache.loadDecoration("welcome") + qPix = iconCache.getDecoration("welcome") assert qPix.isNull() is False # Fail finding the file with monkeypatch.context() as mp: mp.setattr("pathlib.Path.is_file", lambda *a: False) - qPix = iconCache.loadDecoration("welcome") + qPix = iconCache.getDecoration("welcome") assert qPix.isNull() is True # Test image sizes - qPix = iconCache.loadDecoration("welcome", w=100, h=None) + qPix = iconCache.getDecoration("welcome", w=100, h=None) assert qPix.isNull() is False assert qPix.width() == 100 assert qPix.height() > 50 - qPix = iconCache.loadDecoration("welcome", w=None, h=100) + qPix = iconCache.getDecoration("welcome", w=None, h=100) assert qPix.isNull() is False assert qPix.width() > 100 assert qPix.height() == 100 - qPix = iconCache.loadDecoration("welcome", w=100, h=100) + qPix = iconCache.getDecoration("welcome", w=100, h=100) assert qPix.isNull() is False assert qPix.width() == 100 assert qPix.height() == 100