mirror of
https://github.com/283375/arcaea-offline-pyside-ui.git
synced 2025-11-12 07:22:15 +00:00
wip: basic theming support
This commit is contained in:
3
ui/theme/__init__.py
Normal file
3
ui/theme/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .manager import ThemeManager
|
||||
|
||||
__all__ = ["ThemeManager"]
|
||||
160
ui/theme/manager.py
Normal file
160
ui/theme/manager.py
Normal file
@ -0,0 +1,160 @@
|
||||
import dataclasses
|
||||
import json
|
||||
from typing import overload
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Property, QObject, QResource, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QGuiApplication, QPalette
|
||||
|
||||
from .material3 import Material3DynamicThemeImpl, Material3ThemeImpl
|
||||
from .qml import ThemeQmlExposer
|
||||
from .shared import ThemeImpl, ThemeInfo, _TCustomPalette, _TScheme
|
||||
|
||||
QML_IMPORT_NAME = "internal.ui.theme"
|
||||
QML_IMPORT_MAJOR_VERSION = 1
|
||||
QML_IMPORT_MINOR_VERSION = 0
|
||||
|
||||
_THEME_CACHES: dict[ThemeInfo, ThemeImpl] = {}
|
||||
|
||||
logger: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
|
||||
class ThemeManager(QObject):
|
||||
_void = Signal()
|
||||
themeChanged = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._qPalette = QPalette()
|
||||
self._customPalette: _TCustomPalette = {
|
||||
"primary": QColor.fromString("#616161"),
|
||||
"success": QColor.fromString("#616161"),
|
||||
"error": QColor.fromString("#616161"),
|
||||
}
|
||||
|
||||
self._lastThemeInfo = ThemeInfo(
|
||||
series="material3",
|
||||
name="default",
|
||||
scheme=self.getCurrentScheme(),
|
||||
)
|
||||
self._qmlExposer = ThemeQmlExposer(themeImpl=ThemeImpl())
|
||||
|
||||
self._cacheMaterial3Theme(themeName="default", scheme="light")
|
||||
self._cacheMaterial3Theme(themeName="default", scheme="dark")
|
||||
self._cacheMaterial3Theme(themeName="tempest", scheme="light")
|
||||
self._cacheMaterial3Theme(themeName="tempest", scheme="dark")
|
||||
|
||||
self._cacheMaterial3DynamicTheme(themeName="default", scheme="light")
|
||||
self._cacheMaterial3DynamicTheme(themeName="default", scheme="dark")
|
||||
self._cacheMaterial3DynamicTheme(themeName="tempest", scheme="light")
|
||||
self._cacheMaterial3DynamicTheme(themeName="tempest", scheme="dark")
|
||||
|
||||
self.setTheme("material3-dynamic", "default")
|
||||
|
||||
def getCurrentScheme(self) -> _TScheme:
|
||||
qApp: QGuiApplication = QGuiApplication.instance() # pyright: ignore[reportAssignmentType]
|
||||
return (
|
||||
"dark"
|
||||
if qApp.styleHints().colorScheme() == Qt.ColorScheme.Dark
|
||||
else "light"
|
||||
)
|
||||
|
||||
def getMaterial3Theme(self, themeName: str, scheme: _TScheme) -> Material3ThemeImpl:
|
||||
themeDataResource = QResource(f":/themes/{themeName}.json")
|
||||
if not themeDataResource.isValid():
|
||||
raise ValueError(f"Material3 theme {themeName!r} not found")
|
||||
|
||||
themeData = json.loads(
|
||||
themeDataResource.uncompressedData().data().decode("utf-8")
|
||||
)
|
||||
|
||||
return Material3ThemeImpl(themeData=themeData, scheme=scheme)
|
||||
|
||||
def getMaterial3DynamicTheme(
|
||||
self, themeName: str, scheme: _TScheme
|
||||
) -> Material3DynamicThemeImpl:
|
||||
themeDataResource = QResource(f":/themes/{themeName}.json")
|
||||
if not themeDataResource.isValid():
|
||||
raise ValueError(f"Material3 theme {themeName!r} not found")
|
||||
|
||||
themeData = json.loads(
|
||||
themeDataResource.uncompressedData().data().decode("utf-8")
|
||||
)
|
||||
|
||||
return Material3DynamicThemeImpl(
|
||||
sourceColorHex=themeData["seed"],
|
||||
scheme=scheme,
|
||||
name=themeName,
|
||||
)
|
||||
|
||||
def _cacheTheme(self, *, themeImpl: ThemeImpl):
|
||||
_THEME_CACHES[themeImpl.info] = themeImpl
|
||||
logger.debug("Theme %r cached", themeImpl.info)
|
||||
|
||||
def _getCachedTheme(self, *, themeInfo: ThemeInfo):
|
||||
cachedTheme = _THEME_CACHES.get(themeInfo)
|
||||
if cachedTheme is None:
|
||||
raise KeyError(f"Theme {themeInfo!r} not cached")
|
||||
return cachedTheme
|
||||
|
||||
def _cacheMaterial3Theme(self, *, themeName: str, scheme: _TScheme):
|
||||
self._cacheTheme(
|
||||
themeImpl=self.getMaterial3Theme(themeName=themeName, scheme=scheme),
|
||||
)
|
||||
|
||||
def _cacheMaterial3DynamicTheme(self, *, themeName: str, scheme: _TScheme):
|
||||
self._cacheTheme(
|
||||
themeImpl=self.getMaterial3DynamicTheme(themeName=themeName, scheme=scheme)
|
||||
)
|
||||
|
||||
@overload
|
||||
def setTheme(self, *, themeInfo: ThemeInfo): ...
|
||||
|
||||
@overload
|
||||
def setTheme(
|
||||
self, themeSeries: str, themeName: str, scheme: _TScheme | None = None, /
|
||||
): ...
|
||||
|
||||
def setTheme(self, *args, **kwargs):
|
||||
if "themeInfo" in kwargs:
|
||||
themeInfo = kwargs["themeInfo"]
|
||||
elif 2 <= len(args) <= 3:
|
||||
themeSeries = args[0]
|
||||
themeName = args[1]
|
||||
schemeArg = args[2] if len(args) > 2 else None
|
||||
scheme = schemeArg or self.getCurrentScheme()
|
||||
|
||||
themeInfo = ThemeInfo(series=themeSeries, name=themeName, scheme=scheme)
|
||||
else:
|
||||
raise TypeError("Invalid setTheme() call")
|
||||
|
||||
logger.debug("Preparing to set theme %r", themeInfo)
|
||||
|
||||
cachedTheme = self._getCachedTheme(themeInfo=themeInfo)
|
||||
|
||||
self._qPalette = cachedTheme.qPalette
|
||||
self._customPalette = cachedTheme.customPalette
|
||||
self._lastThemeInfo = themeInfo
|
||||
self._qmlExposer.themeImpl = cachedTheme
|
||||
|
||||
self.themeChanged.emit()
|
||||
|
||||
def updateTheme(self, scheme: _TScheme | None = None):
|
||||
themeInfo = dataclasses.replace(self._lastThemeInfo) # make a copy
|
||||
scheme = scheme or self.getCurrentScheme()
|
||||
themeInfo.scheme = scheme
|
||||
|
||||
self.setTheme(themeInfo=themeInfo)
|
||||
|
||||
@Property(QPalette, notify=themeChanged)
|
||||
def qPalette(self) -> QPalette:
|
||||
return self._qPalette
|
||||
|
||||
@Property(dict, notify=themeChanged)
|
||||
def customPalette(self) -> _TCustomPalette:
|
||||
return self._customPalette
|
||||
|
||||
@Property(ThemeQmlExposer, notify=themeChanged)
|
||||
def qmlExposer(self) -> ThemeQmlExposer:
|
||||
return self._qmlExposer
|
||||
245
ui/theme/material3.py
Normal file
245
ui/theme/material3.py
Normal 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),
|
||||
}
|
||||
37
ui/theme/qml.py
Normal file
37
ui/theme/qml.py
Normal file
@ -0,0 +1,37 @@
|
||||
from PySide6.QtCore import Property, QObject, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
from .shared import ThemeImpl
|
||||
|
||||
QML_IMPORT_NAME = "internal.ui.theme"
|
||||
QML_IMPORT_MAJOR_VERSION = 1
|
||||
QML_IMPORT_MINOR_VERSION = 0
|
||||
|
||||
|
||||
class ThemeQmlExposer(QObject):
|
||||
themeChanged = Signal()
|
||||
|
||||
def __init__(self, *, themeImpl: ThemeImpl, parent: QObject | None = None):
|
||||
super().__init__(parent)
|
||||
self._themeImpl = themeImpl
|
||||
|
||||
@property
|
||||
def themeImpl(self) -> ThemeImpl:
|
||||
return self._themeImpl
|
||||
|
||||
@themeImpl.setter
|
||||
def themeImpl(self, themeImpl: ThemeImpl):
|
||||
self._themeImpl = themeImpl
|
||||
self.themeChanged.emit()
|
||||
|
||||
@Property(QColor, notify=themeChanged)
|
||||
def primary(self):
|
||||
return self._themeImpl.customPalette["primary"]
|
||||
|
||||
@Property(QColor, notify=themeChanged)
|
||||
def success(self):
|
||||
return self._themeImpl.customPalette["success"]
|
||||
|
||||
@Property(QColor, notify=themeChanged)
|
||||
def error(self):
|
||||
return self._themeImpl.customPalette["error"]
|
||||
43
ui/theme/shared.py
Normal file
43
ui/theme/shared.py
Normal file
@ -0,0 +1,43 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
|
||||
|
||||
class _TCustomPalette(TypedDict):
|
||||
primary: QColor
|
||||
success: QColor
|
||||
error: QColor
|
||||
|
||||
|
||||
_TScheme = Literal["light", "dark"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThemeInfo:
|
||||
series: str
|
||||
name: str
|
||||
scheme: _TScheme
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.series, self.name, self.scheme))
|
||||
|
||||
|
||||
class ThemeImpl:
|
||||
DEFAULT_CUSTOM_PALETTE = {
|
||||
"primary": QColor.fromString("#616161"),
|
||||
"success": QColor.fromString("#616161"),
|
||||
"error": QColor.fromString("#616161"),
|
||||
}
|
||||
|
||||
@property
|
||||
def info(self) -> ThemeInfo:
|
||||
return ThemeInfo(series="placeholder", name="placeholder", scheme="dark")
|
||||
|
||||
@property
|
||||
def qPalette(self) -> QPalette:
|
||||
return QPalette()
|
||||
|
||||
@property
|
||||
def customPalette(self) -> _TCustomPalette:
|
||||
return self.DEFAULT_CUSTOM_PALETTE # pyright: ignore[reportReturnType]
|
||||
Reference in New Issue
Block a user