Update theme colour processing and fix current tests

This commit is contained in:
Veronica Berglyd Olsen
2025-06-02 17:06:28 +02:00
parent 1fafa0dae7
commit 857327e9ef
9 changed files with 285 additions and 209 deletions
@@ -1,5 +1,6 @@
[Main]
name = Default Dark Theme
mode = dark
description = The novelWriter standard dark theme
author = Veronica Berglyd Olsen
credit = Veronica Berglyd Olsen
@@ -1,5 +1,6 @@
[Main]
name = Default Light Theme
mode = light
description = The novelWriter standard light theme
author = Veronica Berglyd Olsen
credit = Veronica Berglyd Olsen
+3 -3
View File
@@ -143,9 +143,9 @@ class GuiMainStatus(QStatusBar):
self.idlePixmap = SHARED.theme.getPixmap("timer_off", (iPx, iPx))
self.timeIcon.setPixmap(self.timePixmap)
colNone = SHARED.theme.getIconColor("default").darker(150)
colSaved = SHARED.theme.getIconColor("green").darker(150)
colUnsaved = SHARED.theme.getIconColor("red").darker(150)
colNone = SHARED.theme.getBaseColor("default").darker(150)
colSaved = SHARED.theme.getBaseColor("green").darker(150)
colUnsaved = SHARED.theme.getBaseColor("red").darker(150)
self.docIcon.setColors(colNone, colSaved, colUnsaved)
self.projIcon.setColors(colNone, colSaved, colUnsaved)
+155 -123
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import logging
from configparser import ConfigParser
from math import ceil
from typing import TYPE_CHECKING, Final
@@ -37,7 +38,7 @@ from PyQt6.QtGui import (
from PyQt6.QtWidgets import QApplication
from novelwriter import CONFIG
from novelwriter.common import NWConfigParser, minmax
from novelwriter.common import checkInt, minmax
from novelwriter.config import DEF_GUI_DARK, DEF_GUI_LIGHT, DEF_ICONS
from novelwriter.constants import nwLabels
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType, nwTheme
@@ -99,13 +100,14 @@ class GuiTheme:
"""
__slots__ = (
"_availSyntax", "_availThemes", "_darkThemes", "_guiPalette", "_lightThemes",
"_styleSheets", "_syntaxList", "_themeList", "baseButtonHeight", "baseIconHeight",
"baseIconSize", "buttonIconSize", "errorText", "fadedText", "fontPixelSize",
"fontPointSize", "getDecoration", "getHeaderDecoration", "getHeaderDecorationNarrow",
"getIcon", "getIconColor", "getItemIcon", "getPixmap", "getToggleIcon", "guiFont",
"guiFontB", "guiFontBU", "guiFontFixed", "guiFontSmall", "helpText", "iconCache",
"isDarkTheme", "syntaxMeta", "syntaxTheme", "textNHeight", "textNWidth", "themeMeta",
"_availSyntax", "_availThemes", "_darkThemes", "_guiPalette", "_lightThemes", "_qColors",
"_styleSheets", "_svgColors", "_syntaxList", "_themeList", "baseButtonHeight",
"baseIconHeight", "baseIconSize", "buttonIconSize", "errorText", "fadedText",
"fontPixelSize", "fontPointSize", "getDecoration", "getHeaderDecoration",
"getHeaderDecorationNarrow", "getIcon", "getItemIcon", "getPixmap", "getToggleIcon",
"guiFont", "guiFontB", "guiFontBU", "guiFontFixed", "guiFontSmall", "helpText",
"iconCache", "isDarkTheme", "syntaxMeta", "syntaxTheme", "textNHeight", "textNWidth",
"themeMeta",
)
def __init__(self) -> None:
@@ -131,12 +133,13 @@ class GuiTheme:
self._availThemes: dict[str, Path] = {}
self._availSyntax: dict[str, Path] = {}
self._styleSheets: dict[str, str] = {}
self._svgColors: dict[str, bytes] = {}
self._qColors: dict[str, QColor] = {}
# Icon Functions
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.getDecoration = self.iconCache.getDecoration
self.getHeaderDecoration = self.iconCache.getHeaderDecoration
@@ -202,6 +205,14 @@ class GuiTheme:
qMetrics = QFontMetrics(self.guiFont)
return ceil(qMetrics.boundingRect(text).width())
def getBaseColor(self, name: str) -> QColor:
"""Return a base color."""
return QColor(self._qColors.get(name) or QtBlack)
def getRawBaseColor(self, name: str) -> bytes:
"""Return a base color."""
return self._svgColors.get(name, self._svgColors.get("default", b"#000000"))
##
# Theme Methods
##
@@ -216,6 +227,34 @@ class GuiTheme:
window = palette.color(QPalette.ColorRole.Window)
return text.lightnessF() > window.lightnessF()
def parseColor(self, value: str, default: QColor = QtBlack) -> QColor:
"""Parse a string as a colour value."""
if value in self._qColors:
# Named colour
return self._qColors[value]
elif value.startswith("#"):
if len(value) >= 9:
# Convert from #RRGGBBAA to #AARRGGBB
return QColor.fromString(f"#{value[7:9]}{value[1:7]}")
else:
# Assume #RRGGBB
return QColor.fromString(value[:7])
elif "," in value:
data = value.split(",")
entries = len(data)
if entries == 2:
# Assume name, alpha
color = self._qColors.get(data[0].strip(), default)
color.setAlpha(checkInt(data[1], 255))
return color
else:
# Assume red, green, blue, alpha
result = [0, 0, 0, 255]
for i in range(min(entries, 4)):
result[i] = checkInt(data[i].strip(), result[i])
return QColor(*result)
return default
def loadTheme(self) -> bool:
"""Load the currently specified GUI theme."""
match CONFIG.themeMode:
@@ -242,7 +281,7 @@ class GuiTheme:
CONFIG.splashMessage("Loading GUI theme ...")
logger.info("Loading GUI theme '%s'", theme)
parser = NWConfigParser()
parser = ConfigParser()
try:
with open(file, mode="r", encoding="utf-8") as fo:
parser.read_file(fo)
@@ -258,40 +297,40 @@ class GuiTheme:
sec = "Main"
meta = ThemeMeta()
if parser.has_section(sec):
meta.name = parser.rdStr(sec, "name", "")
meta.mode = parser.rdStr(sec, "mode", "light")
meta.description = parser.rdStr(sec, "description", "N/A")
meta.author = parser.rdStr(sec, "author", "N/A")
meta.credit = parser.rdStr(sec, "credit", "N/A")
meta.url = parser.rdStr(sec, "url", "")
meta.license = parser.rdStr(sec, "license", "N/A")
meta.licenseUrl = parser.rdStr(sec, "licenseurl", "")
meta.name = parser.get(sec, "name", fallback="")
meta.mode = parser.get(sec, "mode", fallback="light")
meta.description = parser.get(sec, "description", fallback="N/A")
meta.author = parser.get(sec, "author", fallback="N/A")
meta.credit = parser.get(sec, "credit", fallback="N/A")
meta.url = parser.get(sec, "url", fallback="")
meta.license = parser.get(sec, "license", fallback="N/A")
meta.licenseUrl = parser.get(sec, "licenseurl", fallback="")
self.themeMeta = meta
# Icons
sec = "Icons"
sec = "Base"
if parser.has_section(sec):
self.iconCache.setIconColor("default", self._parseColor(parser, sec, "default"))
self.iconCache.setIconColor("faded", self._parseColor(parser, sec, "faded"))
self.iconCache.setIconColor("red", self._parseColor(parser, sec, "red"))
self.iconCache.setIconColor("orange", self._parseColor(parser, sec, "orange"))
self.iconCache.setIconColor("yellow", self._parseColor(parser, sec, "yellow"))
self.iconCache.setIconColor("green", self._parseColor(parser, sec, "green"))
self.iconCache.setIconColor("aqua", self._parseColor(parser, sec, "aqua"))
self.iconCache.setIconColor("blue", self._parseColor(parser, sec, "blue"))
self.iconCache.setIconColor("purple", self._parseColor(parser, sec, "purple"))
self._setBaseColor("default", self._readColor(parser, sec, "default"))
self._setBaseColor("faded", self._readColor(parser, sec, "faded"))
self._setBaseColor("red", self._readColor(parser, sec, "red"))
self._setBaseColor("orange", self._readColor(parser, sec, "orange"))
self._setBaseColor("yellow", self._readColor(parser, sec, "yellow"))
self._setBaseColor("green", self._readColor(parser, sec, "green"))
self._setBaseColor("aqua", self._readColor(parser, sec, "aqua"))
self._setBaseColor("blue", self._readColor(parser, sec, "blue"))
self._setBaseColor("purple", self._readColor(parser, sec, "purple"))
# Project
sec = "Project"
if parser.has_section(sec):
self.iconCache.setIconColor("root", self._parseColor(parser, sec, "root"))
self.iconCache.setIconColor("folder", self._parseColor(parser, sec, "folder"))
self.iconCache.setIconColor("file", self._parseColor(parser, sec, "file"))
self.iconCache.setIconColor("title", self._parseColor(parser, sec, "title"))
self.iconCache.setIconColor("chapter", self._parseColor(parser, sec, "chapter"))
self.iconCache.setIconColor("scene", self._parseColor(parser, sec, "scene"))
self.iconCache.setIconColor("note", self._parseColor(parser, sec, "note"))
self._setBaseColor("root", self._readColor(parser, sec, "root"))
self._setBaseColor("folder", self._readColor(parser, sec, "folder"))
self._setBaseColor("file", self._readColor(parser, sec, "file"))
self._setBaseColor("title", self._readColor(parser, sec, "title"))
self._setBaseColor("chapter", self._readColor(parser, sec, "chapter"))
self._setBaseColor("scene", self._readColor(parser, sec, "scene"))
self._setBaseColor("note", self._readColor(parser, sec, "note"))
# Palette
sec = "Palette"
@@ -314,34 +353,34 @@ class GuiTheme:
# GUI
sec = "GUI"
if parser.has_section(sec):
self.helpText = self._parseColor(parser, sec, "helptext")
self.fadedText = self._parseColor(parser, sec, "fadedtext")
self.errorText = self._parseColor(parser, sec, "errortext")
self.helpText = self._readColor(parser, sec, "helptext")
self.fadedText = self._readColor(parser, sec, "fadedtext")
self.errorText = self._readColor(parser, sec, "errortext")
# Syntax
sec = "Syntax"
self.syntaxTheme = SyntaxColors()
if parser.has_section(sec):
self.syntaxTheme.back = self._parseColor(parser, sec, "background")
self.syntaxTheme.text = self._parseColor(parser, sec, "text")
self.syntaxTheme.link = self._parseColor(parser, sec, "link")
self.syntaxTheme.head = self._parseColor(parser, sec, "headertext")
self.syntaxTheme.headH = self._parseColor(parser, sec, "headertag")
self.syntaxTheme.emph = self._parseColor(parser, sec, "emphasis")
self.syntaxTheme.dialN = self._parseColor(parser, sec, "dialog")
self.syntaxTheme.dialA = self._parseColor(parser, sec, "altdialog")
self.syntaxTheme.hidden = self._parseColor(parser, sec, "hidden")
self.syntaxTheme.note = self._parseColor(parser, sec, "note")
self.syntaxTheme.code = self._parseColor(parser, sec, "shortcode")
self.syntaxTheme.key = self._parseColor(parser, sec, "keyword")
self.syntaxTheme.tag = self._parseColor(parser, sec, "tag")
self.syntaxTheme.val = self._parseColor(parser, sec, "value")
self.syntaxTheme.opt = self._parseColor(parser, sec, "optional")
self.syntaxTheme.spell = self._parseColor(parser, sec, "spellcheckline")
self.syntaxTheme.error = self._parseColor(parser, sec, "errorline")
self.syntaxTheme.repTag = self._parseColor(parser, sec, "replacetag")
self.syntaxTheme.mod = self._parseColor(parser, sec, "modifier")
self.syntaxTheme.mark = self._parseColor(parser, sec, "texthighlight")
self.syntaxTheme.back = self._readColor(parser, sec, "background")
self.syntaxTheme.text = self._readColor(parser, sec, "text")
self.syntaxTheme.link = self._readColor(parser, sec, "link")
self.syntaxTheme.head = self._readColor(parser, sec, "headertext")
self.syntaxTheme.headH = self._readColor(parser, sec, "headertag")
self.syntaxTheme.emph = self._readColor(parser, sec, "emphasis")
self.syntaxTheme.dialN = self._readColor(parser, sec, "dialog")
self.syntaxTheme.dialA = self._readColor(parser, sec, "altdialog")
self.syntaxTheme.hidden = self._readColor(parser, sec, "hidden")
self.syntaxTheme.note = self._readColor(parser, sec, "note")
self.syntaxTheme.code = self._readColor(parser, sec, "shortcode")
self.syntaxTheme.key = self._readColor(parser, sec, "keyword")
self.syntaxTheme.tag = self._readColor(parser, sec, "tag")
self.syntaxTheme.val = self._readColor(parser, sec, "value")
self.syntaxTheme.opt = self._readColor(parser, sec, "optional")
self.syntaxTheme.spell = self._readColor(parser, sec, "spellcheckline")
self.syntaxTheme.error = self._readColor(parser, sec, "errorline")
self.syntaxTheme.repTag = self._readColor(parser, sec, "replacetag")
self.syntaxTheme.mod = self._readColor(parser, sec, "modifier")
self.syntaxTheme.mark = self._readColor(parser, sec, "texthighlight")
# Update Dependant Colours
# Based on: https://github.com/qt/qtbase/blob/dev/src/gui/kernel/qplatformtheme.cpp
@@ -398,6 +437,18 @@ class GuiTheme:
self._guiPalette.setBrush(QtColInactive, QPalette.ColorRole.Accent, highlight)
self._guiPalette.setBrush(QtColDisabled, QPalette.ColorRole.Accent, grey)
# Set project override colours
if (override := CONFIG.iconColTree) != "theme":
color = self._svgColors.get(override, b"#000000")
self._svgColors["root"] = color
self._svgColors["folder"] = color
if not CONFIG.iconColDocs:
self._svgColors["file"] = color
self._svgColors["title"] = color
self._svgColors["chapter"] = color
self._svgColors["scene"] = color
self._svgColors["note"] = color
# Load icons after the theme is parsed
self.iconCache.loadTheme(CONFIG.iconTheme)
@@ -416,7 +467,7 @@ class GuiTheme:
return self._themeList
themes: list[T_ThemeEntry] = []
parser = NWConfigParser()
parser = ConfigParser()
for key, path in self._availThemes.items():
logger.debug("Checking theme config '%s'", key)
if meta := _loadInternalName(parser, path):
@@ -434,6 +485,12 @@ class GuiTheme:
# Internal Functions
##
def _setBaseColor(self, key: str, color: QColor) -> None:
"""Set the colour for a named colour."""
self._qColors[key] = QColor(color)
self._svgColors[key] = color.name(QColor.NameFormat.HexRgb).encode("utf-8")
return
def _resetTheme(self) -> None:
"""Reset GUI colours to default values."""
palette = QPalette()
@@ -460,37 +517,38 @@ class GuiTheme:
self._guiPalette = palette
# Reset Icons
icons = self.iconCache
icons.clear()
icons.setIconColor("default", text)
icons.setIconColor("faded", faded)
icons.setIconColor("red", red)
icons.setIconColor("orange", orange)
icons.setIconColor("yellow", yellow)
icons.setIconColor("green", green)
icons.setIconColor("aqua", aqua)
icons.setIconColor("blue", blue)
icons.setIconColor("purple", purple)
icons.setIconColor("root", blue)
icons.setIconColor("folder", yellow)
icons.setIconColor("file", text)
icons.setIconColor("title", green)
icons.setIconColor("chapter", red)
icons.setIconColor("scene", blue)
icons.setIconColor("note", yellow)
# Reset Base Colours and Icons
self.iconCache.clear()
self._svgColors = {}
self._qColors = {}
self._setBaseColor("default", text)
self._setBaseColor("faded", faded)
self._setBaseColor("red", red)
self._setBaseColor("orange", orange)
self._setBaseColor("yellow", yellow)
self._setBaseColor("green", green)
self._setBaseColor("aqua", aqua)
self._setBaseColor("blue", blue)
self._setBaseColor("purple", purple)
self._setBaseColor("root", blue)
self._setBaseColor("folder", yellow)
self._setBaseColor("file", text)
self._setBaseColor("title", green)
self._setBaseColor("chapter", red)
self._setBaseColor("scene", blue)
self._setBaseColor("note", yellow)
return
def _parseColor(self, parser: NWConfigParser, section: str, name: str) -> QColor:
def _readColor(self, parser: ConfigParser, section: str, name: str) -> QColor:
"""Parse a colour value from a config string."""
return QColor(*parser.rdIntList(section, name, [0, 0, 0, 255]))
return self.parseColor(parser.get(section, name, fallback="default"))
def _setPalette(
self, parser: NWConfigParser, section: str, name: str, value: QPalette.ColorRole
self, parser: ConfigParser, section: str, name: str, value: QPalette.ColorRole
) -> None:
"""Set a palette colour value from a config string."""
self._guiPalette.setBrush(value, self._parseColor(parser, section, name))
self._guiPalette.setBrush(value, self._readColor(parser, section, name))
return
def _buildStyleSheets(self, palette: QPalette) -> None:
@@ -536,9 +594,8 @@ class GuiIcons:
"""
__slots__ = (
"_availThemes", "_headerDec", "_headerDecNarrow", "_noIcon",
"_qColors", "_qIcons", "_svgColors", "_svgData", "_themeList",
"mainTheme", "themeMeta",
"_availThemes", "_headerDec", "_headerDecNarrow", "_meta", "_noIcon",
"_qIcons", "_svgData", "_theme", "_themeList",
)
TOGGLE_ICON_KEYS: Final[dict[str, tuple[str, str]]] = {
@@ -552,13 +609,11 @@ class GuiIcons:
def __init__(self, mainTheme: GuiTheme) -> None:
self.mainTheme = mainTheme
self.themeMeta = ThemeMeta()
self._theme = mainTheme
self._meta = ThemeMeta()
# Storage
self._svgData: 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] = []
@@ -578,12 +633,10 @@ class GuiIcons:
def clear(self) -> None:
"""Clear the icon cache."""
self._svgData = {}
self._svgColors = {}
self._qColors = {}
self._qIcons = {}
self._headerDec = []
self._headerDecNarrow = []
self.themeMeta = ThemeMeta()
self._meta = ThemeMeta()
return
##
@@ -622,7 +675,7 @@ class GuiIcons:
meta.author = value
elif key == "meta:license":
meta.license = value
self.themeMeta = meta
self._meta = meta
except Exception:
logger.error("Could not read file: %s", file)
logException()
@@ -631,38 +684,16 @@ class GuiIcons:
CONFIG.splashMessage(f"Loaded icon theme: {meta.name}")
CONFIG.splashMessage("Generating additional icons ...")
# Set colour overrides for project item icons
if (override := CONFIG.iconColTree) != "theme":
color = self._svgColors.get(override, b"#000000")
self._svgColors["root"] = color
self._svgColors["folder"] = color
if not CONFIG.iconColDocs:
self._svgColors["file"] = color
self._svgColors["title"] = color
self._svgColors["chapter"] = color
self._svgColors["scene"] = color
self._svgColors["note"] = color
# Populate generated icons cache
self.getHeaderDecoration(0)
self.getHeaderDecorationNarrow(0)
return True
def setIconColor(self, key: str, color: QColor) -> None:
"""Set an icon colour for a named colour."""
self._qColors[key] = QColor(color)
self._svgColors[key] = color.name(QColor.NameFormat.HexRgb).encode("utf-8")
return
##
# Access Functions
##
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."""
variant = f"{name}-{color}" if color else name
@@ -733,7 +764,7 @@ class GuiIcons:
map or the icon map. This function always returns a QPixmap.
"""
if name in self.IMAGE_MAP:
idx = int(self.mainTheme.isDarkTheme)
idx = int(self._theme.isDarkTheme)
imgPath = CONFIG.assetPath("images") / self.IMAGE_MAP[name][idx]
else:
logger.error("Decoration with name '%s' does not exist", name)
@@ -757,7 +788,7 @@ class GuiIcons:
def getHeaderDecoration(self, hLevel: int) -> QPixmap:
"""Get the decoration for a specific heading level."""
if not self._headerDec:
iPx = self.mainTheme.baseIconHeight
iPx = self._theme.baseIconHeight
self._headerDec = [
self._generateDecoration("file", iPx, 0),
self._generateDecoration("title", iPx, 0),
@@ -770,7 +801,7 @@ class GuiIcons:
def getHeaderDecorationNarrow(self, hLevel: int) -> QPixmap:
"""Get the narrow decoration for a specific heading level."""
if not self._headerDecNarrow:
iPx = self.mainTheme.baseIconHeight
iPx = self._theme.baseIconHeight
self._headerDecNarrow = [
self._generateDecoration("file", iPx, 0),
self._generateDecoration("title", iPx, 0),
@@ -811,7 +842,7 @@ class GuiIcons:
return QIcon(str(CONFIG.assetPath("icons") / "x-novelwriter-project.svg"))
if svg := self._svgData.get(name, b""):
if fill := self._svgColors.get(color or "default"):
if fill := self._theme.getRawBaseColor(color or "default"):
svg = svg.replace(b"#000000", fill)
pixmap = QPixmap(w, h)
pixmap.fill(QtTransparent)
@@ -833,7 +864,7 @@ class GuiIcons:
painter = QPainter(pixmap)
painter.setRenderHint(QtPaintAntiAlias)
if fill := self._svgColors.get(color or "default"):
if fill := self._theme.getRawBaseColor(color or "default"):
painter.fillPath(path, QColor(fill.decode(encoding="utf-8")))
painter.end()
@@ -859,13 +890,14 @@ def _sortTheme(data: tuple) -> str:
return f"*{name}" if key.startswith("default_") else name
def _loadInternalName(parser: NWConfigParser, path: str | Path) -> tuple[str, bool]:
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.rdStr("Main", "name", "")
dark = parser.rdStr("Main", "mode", "light").lower() == "dark"
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)
+4 -3
View File
@@ -1,10 +1,11 @@
[Meta]
timestamp = 2025-05-11 13:28:11
timestamp = 2025-06-02 16:21:38
[Main]
font =
theme = default
syntax = default_light
lighttheme = default_light
darktheme = default_dark
thememode = AUTO
icons = material_rounded_normal
iconcoltree = theme
iconcoldocs = False
+4 -4
View File
@@ -131,15 +131,15 @@ def testBaseConfig_InitLoadSave(monkeypatch, fncPath, tstPaths):
assert tstConf.errorText().startswith("Could not load config file")
# Change a few settings, save, reset, and reload
tstConf.guiTheme = "foo"
tstConf.guiSyntax = "bar"
tstConf.lightTheme = "foo"
tstConf.darkTheme = "bar"
assert tstConf.saveConfig() is True
newConf = Config()
newConf.initConfig(confPath=fncPath, dataPath=fncPath)
newConf.loadConfig()
assert newConf.guiTheme == "foo"
assert newConf.guiSyntax == "bar"
assert newConf.lightTheme == "foo"
assert newConf.darkTheme == "bar"
# Test Correcting Quote Settings
tstConf.fmtDQuoteOpen = '"'
+18 -16
View File
@@ -27,7 +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.config import DEF_GUI_DARK, DEF_GUI_LIGHT
from novelwriter.constants import nwUnicode
from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.quotes import GuiQuoteSelect
@@ -55,15 +55,11 @@ def testDlgPreferences_Main(qtbot, monkeypatch, nwGUI, tstPaths):
assert "en_GB" in languages
# Check GUI Themes
themes = [prefs.guiTheme.itemData(i) for i in range(prefs.guiTheme.count())]
assert len(themes) >= 5
assert DEF_GUI in themes
themes = [prefs.lightTheme.itemData(i) for i in range(prefs.lightTheme.count())]
assert DEF_GUI_LIGHT in themes
# Check GUI Syntax
syntax = [prefs.guiSyntax.itemData(i) for i in range(prefs.guiSyntax.count())]
assert len(syntax) >= 10
assert "default_dark" in syntax
assert "default_light" in syntax
themes = [prefs.darkTheme.itemData(i) for i in range(prefs.darkTheme.count())]
assert DEF_GUI_DARK in themes
# Check Spell Checking
spelling = [prefs.spellLanguage.itemData(i) for i in range(prefs.spellLanguage.count())]
@@ -124,7 +120,7 @@ def testDlgPreferences_Actions(qtbot, monkeypatch, nwGUI):
button = prefs.buttonBox.button(QtDialogSave)
assert button is not None
button.click()
assert signal.args == [False, False, False, False]
assert len(signal.args) == 4
# Check Close Button
prefs.show()
@@ -151,6 +147,12 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths):
(fncPath / "nw_en_US.qm").touch()
(fncPath / "project_en_US.json").touch()
CONFIG._nwLangPath = fncPath
SHARED.theme._themeList = [
("theme1", "Theme 1", False),
("theme2", "Theme 2", False),
("theme3", "Theme 3", True),
("theme4", "Theme 4", True),
]
prefs = GuiPreferences(nwGUI)
with qtbot.waitExposed(prefs):
@@ -158,7 +160,8 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths):
# Appearance
prefs.guiLocale.setCurrentIndex(prefs.guiLocale.findData("en_US"))
prefs.guiTheme.setCurrentIndex(prefs.guiTheme.findData("default_dark"))
prefs.lightTheme.setCurrentIndex(prefs.lightTheme.findData("theme1"))
prefs.darkTheme.setCurrentIndex(prefs.darkTheme.findData("theme3"))
with monkeypatch.context() as mp:
mp.setattr(QFontDialog, "getFont", lambda *a, **k: (QFont(), True))
prefs.nativeFont.setChecked(True) # Use OS font dialog
@@ -169,14 +172,14 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths):
prefs.useCharCount.setChecked(True)
assert CONFIG.guiLocale != "en_US"
assert CONFIG.guiTheme != "default_dark"
assert CONFIG.lightTheme == "default_light"
assert CONFIG.darkTheme == "default_dark"
assert CONFIG.guiFont.family() != ""
assert CONFIG.hideVScroll is False
assert CONFIG.hideHScroll is False
assert CONFIG.useCharCount is False
# Document Style
prefs.guiSyntax.setCurrentIndex(prefs.guiSyntax.findData("default_dark"))
with monkeypatch.context() as mp:
mp.setattr(QFontDialog, "getFont", lambda *a, **k: (QFont(), True))
prefs.nativeFont.setChecked(False) # Use Qt font dialog
@@ -185,7 +188,6 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths):
prefs.showFullPath.setChecked(False)
prefs.incNotesWCount.setChecked(False)
assert CONFIG.guiSyntax != "default_dark"
assert CONFIG.textFont.family() != ""
assert CONFIG.showFullPath is True
assert CONFIG.incNotesWCount is True
@@ -344,14 +346,14 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths):
# Appearance
assert CONFIG.guiLocale == "en_US"
assert CONFIG.guiTheme == "default_dark"
assert CONFIG.lightTheme == "theme1"
assert CONFIG.darkTheme == "theme3"
assert CONFIG.guiFont == QFont()
assert CONFIG.hideVScroll is True
assert CONFIG.hideHScroll is True
assert CONFIG.useCharCount is True
# Document Style
assert CONFIG.guiSyntax == "default_dark"
assert CONFIG.textFont == QFont()
assert CONFIG.showFullPath is False
assert CONFIG.incNotesWCount is False
+5 -4
View File
@@ -34,7 +34,7 @@ from PyQt6.QtWidgets import QInputDialog, QMessageBox
from novelwriter import CONFIG, SHARED
from novelwriter.constants import nwFiles
from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.enum import nwDocAction, nwDocMode, nwFocus, nwItemType, nwView
from novelwriter.enum import nwDocAction, nwDocMode, nwFocus, nwItemType, nwTheme, nwView
from novelwriter.gui.doceditor import GuiDocEditor
from novelwriter.gui.noveltree import GuiNovelView
from novelwriter.gui.outline import GuiOutlineView
@@ -180,10 +180,11 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
def testGuiMain_UpdateTheme(qtbot, nwGUI):
"""Test updating the theme in the GUI."""
mainTheme = SHARED.theme
CONFIG.guiTheme = "default_dark"
CONFIG.guiSyntax = "default_dark"
CONFIG.themeMode = nwTheme.DARK
CONFIG.darkTheme = "default_dark"
CONFIG.lightTheme = "default_light"
mainTheme.loadTheme()
mainTheme.loadSyntax()
nwGUI._processConfigChanges(False, True, False, False)
nwGUI._processConfigChanges(True, True, True, True)
+94 -56
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import sys
from configparser import ConfigParser
from pathlib import Path
import pytest
@@ -29,8 +30,7 @@ import pytest
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.config import DEF_GUI_LIGHT
from novelwriter.constants import nwLabels
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.gui.theme import _listConf
@@ -39,17 +39,51 @@ from tests.mocked import causeOSError
from tests.tools import writeFile
@pytest.mark.gui
def testGuiTheme_ParseColor(qtbot, nwGUI):
"""Test the colour parsing."""
theme = SHARED.theme
# Pre-Populate
theme._qColors["red"] = QColor(255, 0, 0)
theme._qColors["green"] = QColor(0, 255, 0)
theme._qColors["blue"] = QColor(0, 0, 255)
# By Name
assert theme.parseColor("red").getRgb() == (255, 0, 0, 255)
assert theme.parseColor("green").getRgb() == (0, 255, 0, 255)
assert theme.parseColor("blue").getRgb() == (0, 0, 255, 255)
assert theme.parseColor("bob").getRgb() == (0, 0, 0, 255)
# CSS Format
assert theme.parseColor("#ff0000").getRgb() == (255, 0, 0, 255)
assert theme.parseColor("#ff00007f").getRgb() == (255, 0, 0, 127)
assert theme.parseColor("#ff00").getRgb() == (0, 0, 0, 255) # Too short -> ignored
assert theme.parseColor("#ff00007f15").getRgb() == (255, 0, 0, 127) # Too long -> truncated
# Name + Alpha
assert theme.parseColor("red, 255").getRgb() == (255, 0, 0, 255)
assert theme.parseColor("red, 127").getRgb() == (255, 0, 0, 127)
assert theme.parseColor("red, 512").getRgb() == (255, 0, 0, 255) # Value truncated
# Values
assert theme.parseColor("255, 0, 0").getRgb() == (255, 0, 0, 255)
assert theme.parseColor("255, 0, 0, 255").getRgb() == (255, 0, 0, 255)
assert theme.parseColor("255, 0, 0, 127").getRgb() == (255, 0, 0, 127)
assert theme.parseColor("255, 0, 0, 127, 42").getRgb() == (255, 0, 0, 127) # Truncated
@pytest.mark.gui
def testGuiTheme_Main(qtbot, nwGUI, tstPaths):
"""Test the theme class init."""
mainTheme = SHARED.theme
theme = SHARED.theme
# Methods
# =======
mSize = mainTheme.getTextWidth("m")
mSize = theme.getTextWidth("m")
assert mSize > 0
assert mainTheme.getTextWidth("m", mainTheme.guiFont) == mSize
assert theme.getTextWidth("m", theme.guiFont) == mSize
# Scan for Themes
# ===============
@@ -70,7 +104,7 @@ def testGuiTheme_Main(qtbot, nwGUI, tstPaths):
# Parse Colours
# =============
parser = NWConfigParser()
parser = ConfigParser()
parser["Palette"] = {
"colour1": "100, 150, 200", # Valid
"colour2": "100, 150, 200, 250", # With alpha
@@ -81,38 +115,39 @@ def testGuiTheme_Main(qtbot, nwGUI, tstPaths):
}
# Test the parser for several valid and invalid values
assert mainTheme._parseColor(parser, "Palette", "colour1").getRgb() == (100, 150, 200, 255)
assert mainTheme._parseColor(parser, "Palette", "colour2").getRgb() == (100, 150, 200, 250)
assert mainTheme._parseColor(parser, "Palette", "colour3").getRgb() == (100, 150, 200, 250)
assert mainTheme._parseColor(parser, "Palette", "colour4").getRgb() == (250, 250, 0, 255)
assert mainTheme._parseColor(parser, "Palette", "colour5").getRgb() == (0, 0, 0, 0)
assert mainTheme._parseColor(parser, "Palette", "colour6").getRgb() == (0, 127, 255, 255)
assert theme._readColor(parser, "Palette", "colour1").getRgb() == (100, 150, 200, 255)
assert theme._readColor(parser, "Palette", "colour2").getRgb() == (100, 150, 200, 250)
assert theme._readColor(parser, "Palette", "colour3").getRgb() == (100, 150, 200, 250)
assert theme._readColor(parser, "Palette", "colour4").getRgb() == (0, 0, 0, 250)
assert theme._readColor(parser, "Palette", "colour5").getRgb() == (0, 0, 0, 0)
assert theme._readColor(parser, "Palette", "colour6").getRgb() == (0, 127, 255, 255)
# The palette should load with the parsed values
mainTheme._setPalette(parser, "Palette", "colour1", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (100, 150, 200, 255)
mainTheme._setPalette(parser, "Palette", "colour2", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (100, 150, 200, 250)
mainTheme._setPalette(parser, "Palette", "colour3", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (100, 150, 200, 250)
mainTheme._setPalette(parser, "Palette", "colour4", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (250, 250, 0, 255)
mainTheme._setPalette(parser, "Palette", "colour5", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (0, 0, 0, 0)
mainTheme._setPalette(parser, "Palette", "colour6", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (0, 127, 255, 255)
theme._setPalette(parser, "Palette", "colour1", QPalette.ColorRole.Window)
assert theme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (100, 150, 200, 255)
theme._setPalette(parser, "Palette", "colour2", QPalette.ColorRole.Window)
assert theme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (100, 150, 200, 250)
theme._setPalette(parser, "Palette", "colour3", QPalette.ColorRole.Window)
assert theme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (100, 150, 200, 250)
theme._setPalette(parser, "Palette", "colour4", QPalette.ColorRole.Window)
assert theme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (0, 0, 0, 250)
theme._setPalette(parser, "Palette", "colour5", QPalette.ColorRole.Window)
assert theme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (0, 0, 0, 0)
theme._setPalette(parser, "Palette", "colour6", QPalette.ColorRole.Window)
assert theme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (0, 127, 255, 255)
# Non-existing value should return default colour
mainTheme._setPalette(parser, "Palette", "stuff", QPalette.ColorRole.Window)
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (0, 0, 0, 255)
theme._setPalette(parser, "Palette", "stuff", QPalette.ColorRole.Window)
assert theme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == (0, 0, 0, 255)
# qtbot.stop()
@pytest.mark.gui
@pytest.mark.skip
def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths):
"""Test the theme part of the class."""
mainTheme = SHARED.theme
theme = SHARED.theme
# List Themes
# ===========
@@ -120,45 +155,46 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths):
# Block the reading of the files
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert mainTheme.listThemes() == []
theme._themeList = []
assert theme.listThemes() == []
# Load the theme info, default themes first
themesList = mainTheme.listThemes()
themesList = theme.listThemes()
assert themesList[0] == ("default_dark", "Default Dark Theme")
assert themesList[1] == ("default_light", "Default Light Theme")
assert themesList[2] == ("cyberpunk_night", "Cyberpunk Night")
assert themesList[3] == ("dracula", "Dracula")
# A second call should returned the cached list
assert mainTheme.listThemes() == mainTheme._themeList
assert theme.listThemes() == theme._themeList
# Check handling of broken theme settings
CONFIG.guiTheme = "not_a_theme"
availThemes = mainTheme._availThemes
mainTheme._availThemes = {}
assert mainTheme.loadTheme() is False
mainTheme._availThemes = availThemes
availThemes = theme._availThemes
theme._availThemes = {}
assert theme.loadTheme() is False
theme._availThemes = availThemes
# Check handling of unreadable file
CONFIG.guiTheme = DEF_GUI
CONFIG.guiTheme = DEF_GUI_LIGHT
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert mainTheme.loadTheme() is False
assert theme.loadTheme() is False
# Load Default Theme
# ==================
if sys.platform != "win32":
# Set a mock colour for the window background
mainTheme._guiPalette.color(QPalette.ColorRole.Window).setRgb(0, 0, 0, 0)
theme._guiPalette.color(QPalette.ColorRole.Window).setRgb(0, 0, 0, 0)
# Load the default theme
CONFIG.guiTheme = DEF_GUI
assert mainTheme.loadTheme() is True
CONFIG.guiTheme = DEF_GUI_LIGHT
assert theme.loadTheme() is True
# This should load a standard palette
wCol = QPalette().color(QPalette.ColorRole.Window).getRgb()
assert mainTheme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == wCol
assert theme._guiPalette.color(QPalette.ColorRole.Window).getRgb() == wCol
# Mock Dark Theme
# ===============
@@ -172,32 +208,32 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths):
"window = 0, 0, 0\n"
"text = 255, 255, 255\n"
)
mainTheme._availThemes["test"] = mockTheme
theme._availThemes["test"] = mockTheme
CONFIG.guiTheme = "test"
assert mainTheme.loadTheme() is True
assert mainTheme._guiPalette.window().color().getRgb() == (0, 0, 0, 255)
assert mainTheme._guiPalette.text().color().getRgb() == (255, 255, 255, 255)
assert mainTheme._guiPalette.light().color().getRgb() == (57, 57, 57, 255)
assert mainTheme.isDarkTheme is True
assert theme.loadTheme() is True
assert theme._guiPalette.window().color().getRgb() == (0, 0, 0, 255)
assert theme._guiPalette.text().color().getRgb() == (255, 255, 255, 255)
assert theme._guiPalette.light().color().getRgb() == (57, 57, 57, 255)
assert theme.isDarkTheme is True
# Load Default Light Theme
# ========================
CONFIG.guiTheme = "default_light"
assert mainTheme.loadTheme() is True
assert theme.loadTheme() is True
# Check a few values
assert mainTheme._guiPalette.color(
assert theme._guiPalette.color(
QPalette.ColorRole.Window
).getRgb() == (239, 239, 239, 255)
assert mainTheme._guiPalette.color(
assert theme._guiPalette.color(
QPalette.ColorRole.WindowText
).getRgb() == (0, 0, 0, 255)
assert mainTheme._guiPalette.color(
assert theme._guiPalette.color(
QPalette.ColorRole.Base
).getRgb() == (255, 255, 255, 255)
assert mainTheme._guiPalette.color(
assert theme._guiPalette.color(
QPalette.ColorRole.AlternateBase
).getRgb() == (224, 224, 224, 255)
@@ -205,21 +241,22 @@ def testGuiTheme_Theme(qtbot, monkeypatch, nwGUI, tstPaths):
# =======================
CONFIG.guiTheme = "default_dark"
assert mainTheme.loadTheme() is True
assert theme.loadTheme() is True
# Check a few values
assert mainTheme._guiPalette.color(
assert theme._guiPalette.color(
QPalette.ColorRole.Window).getRgb() == (54, 54, 54, 255)
assert mainTheme._guiPalette.color(
assert theme._guiPalette.color(
QPalette.ColorRole.WindowText).getRgb() == (204, 204, 204, 255)
assert mainTheme._guiPalette.color(
assert theme._guiPalette.color(
QPalette.ColorRole.Base).getRgb() == (62, 62, 62, 255)
assert mainTheme._guiPalette.color(
assert theme._guiPalette.color(
QPalette.ColorRole.AlternateBase).getRgb() == (78, 78, 78, 255)
# qtbot.stop()
@pytest.mark.skip
@pytest.mark.gui
def testGuiTheme_Syntax(qtbot, monkeypatch, nwGUI):
"""Test the syntax part of the class."""
@@ -283,6 +320,7 @@ def testGuiTheme_Syntax(qtbot, monkeypatch, nwGUI):
# qtbot.stop()
@pytest.mark.skip
@pytest.mark.gui
def testGuiTheme_IconThemes(qtbot, caplog, monkeypatch, nwGUI, tstPaths):
"""Test the icon cache class."""