wip: basic theming support

This commit is contained in:
2025-10-28 21:23:06 +08:00
parent 9c96714c8f
commit 4409986687
13 changed files with 1457 additions and 5 deletions

245
ui/theme/material3.py Normal file
View File

@ -0,0 +1,245 @@
from typing import TypedDict
from materialyoucolor.blend import Blend
from materialyoucolor.dynamiccolor.contrast_curve import ContrastCurve
from materialyoucolor.dynamiccolor.dynamic_color import DynamicColor, FromPaletteOptions
from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors
from materialyoucolor.hct import Hct
from materialyoucolor.palettes.tonal_palette import TonalPalette
from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot
from PySide6.QtGui import QColor, QPalette
from .shared import ThemeImpl, ThemeInfo, _TCustomPalette, _TScheme
class _M3ThemeDataExtendedColorItem(TypedDict):
name: str
color: str
description: str
harmonized: bool
_M3ThemeDataSchemes = TypedDict(
"_M3ThemeDataSchemes",
{
"light": dict[str, str],
"light-medium-contrast": dict[str, str],
"light-high-contrast": dict[str, str],
"dark": dict[str, str],
"dark-medium-contrast": dict[str, str],
"dark-high-contrast": dict[str, str],
},
)
_M3ThemeDataPalettes = TypedDict(
"_M3ThemeDataPalettes",
{
"primary": dict[str, str],
"secondary": dict[str, str],
"tertiary": dict[str, str],
"neutral": dict[str, str],
"neutral-variant": dict[str, str],
},
)
class _M3ThemeData(TypedDict):
name: str
description: str
seed: str
coreColors: dict[str, str]
extendedColors: list[_M3ThemeDataExtendedColorItem]
schemes: _M3ThemeDataSchemes
palettes: _M3ThemeDataPalettes
def _hexToHct(hexColor: str) -> Hct:
pureHexPart = hexColor[1:] if hexColor.startswith("#") else hexColor
return Hct.from_int(int(f"0xff{pureHexPart}", 16))
def _hctToQColor(hct: Hct) -> QColor:
return QColor.fromRgba(hct.to_int())
class Material3ThemeImpl(ThemeImpl):
COLOR_ROLE_MAPPING: dict[QPalette.ColorRole, str] = {
QPalette.ColorRole.Window: "surface",
QPalette.ColorRole.WindowText: "onSurface",
QPalette.ColorRole.Base: "surfaceContainer",
QPalette.ColorRole.AlternateBase: "surfaceContainerHighest",
QPalette.ColorRole.ToolTipBase: "secondaryContainer",
QPalette.ColorRole.ToolTipText: "onSecondaryContainer",
QPalette.ColorRole.PlaceholderText: "inverseSurface",
QPalette.ColorRole.Text: "onSurface",
QPalette.ColorRole.Button: "primaryContainer",
QPalette.ColorRole.ButtonText: "onPrimaryContainer",
QPalette.ColorRole.BrightText: "onSecondary",
QPalette.ColorRole.Light: "surfaceContainerLowest",
QPalette.ColorRole.Midlight: "surfaceContainerLow",
QPalette.ColorRole.Dark: "inverseSurface",
QPalette.ColorRole.Mid: "surfaceContainer",
QPalette.ColorRole.Shadow: "shadow",
QPalette.ColorRole.Highlight: "primary",
QPalette.ColorRole.Accent: "primary",
QPalette.ColorRole.HighlightedText: "onPrimary",
QPalette.ColorRole.Link: "tertiary",
QPalette.ColorRole.LinkVisited: "tertiaryContainer",
}
def __init__(self, *, themeData: _M3ThemeData, scheme: _TScheme):
self.themeData = themeData
self.scheme: _TScheme = scheme
if self.themeData["schemes"].get(scheme) is None:
raise ValueError(f"Invalid scheme: {scheme}")
def _findExtendedColor(
self, colorName: str
) -> _M3ThemeDataExtendedColorItem | None:
return next(
(it for it in self.themeData["extendedColors"] if it["name"] == colorName),
None,
)
@property
def info(self):
return ThemeInfo(
series="material3",
name=self.themeData["name"],
scheme=self.scheme,
)
@property
def qPalette(self) -> QPalette:
qPalette = QPalette()
for role, name in self.COLOR_ROLE_MAPPING.items():
color = QColor.fromString(self.themeData["schemes"][self.scheme][name])
qPalette.setColor(role, color)
return qPalette
@property
def customPalette(self) -> _TCustomPalette:
primaryHct = _hexToHct(self.themeData["schemes"][self.scheme]["primary"])
successColorItem = self._findExtendedColor("Success")
if successColorItem is None:
raise Exception("Success color not found")
successHct = _hexToHct(successColorItem["color"])
successHarmonizedHct = Hct.from_int(
Blend.harmonize(successHct.to_int(), primaryHct.to_int())
)
return {
"primary": _hctToQColor(primaryHct),
"success": _hctToQColor(successHarmonizedHct),
"error": QColor.fromString(self.themeData["schemes"][self.scheme]["error"]),
}
class Material3DynamicThemeImpl(ThemeImpl):
ACTIVE_COLOR_ROLE_MAPPING: dict[QPalette.ColorRole, DynamicColor] = {
QPalette.ColorRole.Window: MaterialDynamicColors.surface,
QPalette.ColorRole.WindowText: MaterialDynamicColors.onSurface,
QPalette.ColorRole.Base: MaterialDynamicColors.surfaceContainer,
QPalette.ColorRole.AlternateBase: MaterialDynamicColors.surfaceContainerHighest,
QPalette.ColorRole.ToolTipBase: MaterialDynamicColors.secondaryContainer,
QPalette.ColorRole.ToolTipText: MaterialDynamicColors.onSecondaryContainer,
QPalette.ColorRole.PlaceholderText: MaterialDynamicColors.inverseSurface,
QPalette.ColorRole.Text: MaterialDynamicColors.onSurface,
QPalette.ColorRole.Button: MaterialDynamicColors.primaryContainer,
QPalette.ColorRole.ButtonText: MaterialDynamicColors.onPrimaryContainer,
QPalette.ColorRole.BrightText: MaterialDynamicColors.onSecondary,
QPalette.ColorRole.Light: MaterialDynamicColors.surfaceContainerLowest,
QPalette.ColorRole.Midlight: MaterialDynamicColors.surfaceContainerLow,
QPalette.ColorRole.Dark: MaterialDynamicColors.inverseSurface,
QPalette.ColorRole.Mid: MaterialDynamicColors.surfaceContainer,
QPalette.ColorRole.Shadow: MaterialDynamicColors.shadow,
QPalette.ColorRole.Highlight: MaterialDynamicColors.primary,
QPalette.ColorRole.Accent: MaterialDynamicColors.primary,
QPalette.ColorRole.HighlightedText: MaterialDynamicColors.onPrimary,
QPalette.ColorRole.Link: MaterialDynamicColors.tertiary,
QPalette.ColorRole.LinkVisited: MaterialDynamicColors.tertiaryContainer,
}
EXTENDED_COLORS = {
"success": "#00c555",
}
def __init__(self, sourceColorHex: str, scheme: _TScheme, *, name: str):
self.material3Scheme = SchemeTonalSpot(
_hexToHct(sourceColorHex),
is_dark=scheme == "dark",
contrast_level=0.0,
)
self.name = name
@property
def info(self):
return ThemeInfo(
series="material3-dynamic",
name=self.name,
scheme="dark" if self.material3Scheme.is_dark else "light",
)
@property
def qPalette(self) -> QPalette:
qPalette = QPalette()
for role, dynamicColor in self.ACTIVE_COLOR_ROLE_MAPPING.items():
hct = dynamicColor.get_hct(self.material3Scheme)
qColor = QColor.fromRgba(hct.to_int())
qPalette.setColor(QPalette.ColorGroup.Active, role, qColor)
# TODO: disabled palette seems to work only after a theme reload, needs further investigation
if role in [QPalette.ColorRole.Button, QPalette.ColorRole.ButtonText]:
disabledHct = Hct.from_hct(hct.hue, 1.0, hct.tone)
disabledQColor = QColor.fromRgba(disabledHct.to_int())
qPalette.setColor(QPalette.ColorGroup.Disabled, role, disabledQColor)
return qPalette
@property
def customPalette(self) -> _TCustomPalette:
primaryHct = MaterialDynamicColors.primary.get_hct(self.material3Scheme)
errorHct = MaterialDynamicColors.error.get_hct(self.material3Scheme)
extendedPalettes: dict[str, DynamicColor] = {}
for colorName, colorHex in self.EXTENDED_COLORS.items():
colorHct = _hexToHct(colorHex)
colorHarmonized = Blend.harmonize(colorHct.to_int(), primaryHct.to_int())
colorTonalPalette = TonalPalette.from_int(colorHarmonized)
colorSurfacePaletteOptions = DynamicColor.from_palette(
FromPaletteOptions(
name=f"{colorName}_container",
palette=lambda s: colorTonalPalette,
tone=lambda s: 30 if s.is_dark else 90,
is_background=True,
background=lambda s: MaterialDynamicColors.highestSurface(s),
contrast_curve=ContrastCurve(1, 1, 3, 4.5),
)
)
extendedPalettes[colorName] = DynamicColor.from_palette(
FromPaletteOptions(
name=colorName, # pyright: ignore[reportArgumentType]
palette=lambda s: colorTonalPalette,
tone=lambda s: 80 if s.is_dark else 40, # pyright: ignore[reportArgumentType]
is_background=False, # pyright: ignore[reportArgumentType]
background=lambda s: MaterialDynamicColors.highestSurface(s),
contrast_curve=ContrastCurve(3, 4.5, 7, 7),
)
)
return {
"primary": _hctToQColor(primaryHct),
"success": _hctToQColor(
extendedPalettes["success"].get_hct(self.material3Scheme)
),
"error": _hctToQColor(errorHct),
}