Frame.theme (7 presets in app/theme_tokens.py) drives font family, corner radius, drop shadow, and an accent hue for every modern-style widget's header/accent region. Rich accent colors (not just the 6 flat panel inks) are approximated via denser Bayer stippling confined to just that region (html_render.ordered_dither_regions), so icon/text content elsewhere stays exactly as crisp as it is today -- verified directly against real Chromium renders, both in unit tests and via run-server. "classic" is a byte-identical no-visual-change default: weather's header keeps its original fixed blue gradient, tasks/calendar keep their flat THEME_* ink. Themes are purely stylistic -- battery's charge-level color, calendar/ tasks' per-owner event chips, and text's own per-widget font choice are never touched.
203 lines
8.7 KiB
Python
203 lines
8.7 KiB
Python
"""Curated theme presets for "modern" (HTML/CSS) style widgets, plus the
|
|
font-family table every modern-style widget -- and widgets/text.py's own
|
|
classic PIL path -- resolves fonts through. The font table used to be
|
|
widgets/text.py's private property; it's hoisted here (text.py now
|
|
imports it back) since html_render.py's build_text/build_daily/etc. all
|
|
need to resolve a theme's font_family to real font files too, not just
|
|
the text widget.
|
|
|
|
Inspired by Tesserae's (github.com/dmellok/tesserae, AGPL-3.0) three-
|
|
layer CSS custom-property theme system -- primitives, semantic per-
|
|
theme tokens, component tokens -- reimplemented here as original Python/
|
|
CSS rather than copied (see docs/widgets.md and this repo's CLAUDE.md on
|
|
copyleft dependencies).
|
|
|
|
A theme is purely **stylistic**: font family, corner radius, drop
|
|
shadow, header gradient on/off, and an accent hue for the widgets that
|
|
have an actual header/accent region to dither richer (see
|
|
html_render.ordered_dither_regions). It never touches **functional**
|
|
color-coding -- battery's charge-level red/yellow/green, calendar/tasks'
|
|
per-owner event color chips, and text's user-authored inline run colors
|
|
are status/identity signals, not style choices, and no theme may
|
|
recolor them.
|
|
|
|
Rich accent_hex values are not restricted to the 6 exact panel inks --
|
|
`ordered_dither_regions` approximates them via denser Bayer stippling in
|
|
just the accent region (verified directly: terracotta/ochre/moss/teal/
|
|
slate-blue/plum swatches all resolve to a believable multi-ink
|
|
approximation at amplitude ~130, the same mechanism -- spatial
|
|
dithering, not flat quantization -- Tesserae's own calibrated-palette
|
|
Floyd-Steinberg uses, just ordered instead of diffused)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
from PIL import ImageFont
|
|
|
|
from . import panel_style
|
|
|
|
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
|
|
|
|
# --- Font family table (hoisted from widgets/text.py) -----------------
|
|
|
|
DEFAULT_FONT_FAMILY = "sans"
|
|
FONT_FAMILIES: dict[str, str] = {
|
|
"sans": "Sans-serif (Noto Sans)",
|
|
"inter": "Inter",
|
|
"source_sans": "Source Sans",
|
|
"serif": "Serif (Noto Serif)",
|
|
"elegant": "Elegant serif (Crimson Text)",
|
|
"slab": "Slab serif (Arvo)",
|
|
"mono": "Monospace (IBM Plex Mono)",
|
|
}
|
|
_FONT_FILES = {
|
|
"sans": {
|
|
(False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
|
|
(False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
|
|
},
|
|
"inter": {
|
|
(False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
|
|
(False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
|
|
},
|
|
"source_sans": {
|
|
(False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
|
|
(False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
|
|
},
|
|
"serif": {
|
|
(False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
|
|
(False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
|
|
},
|
|
"elegant": {
|
|
(False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
|
|
(False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
|
|
},
|
|
"slab": {
|
|
(False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
|
|
(False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
|
|
},
|
|
"mono": {
|
|
(False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
|
|
(False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-BoldItalic.ttf",
|
|
},
|
|
}
|
|
|
|
|
|
def font_path(family: str, bold: bool, italic: bool) -> Path:
|
|
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
|
|
return _FONT_DIR / files[(bold, italic)]
|
|
|
|
|
|
@lru_cache(maxsize=256)
|
|
def font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
|
|
return ImageFont.truetype(str(font_path(family, bold, italic)), size)
|
|
|
|
|
|
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
|
return "#%02x%02x%02x" % tuple(rgb)
|
|
|
|
|
|
def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
|
|
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
|
|
|
|
|
|
# --- Theme presets -------------------------------------------------------
|
|
|
|
DEFAULT_THEME = "classic"
|
|
|
|
# accent_hex=None means "keep each widget's own classic THEME_* ink" --
|
|
# resolve_theme() below is what actually looks that up -- so "classic" is
|
|
# deliberately a no-visual-change default, byte-identical to how modern
|
|
# style already rendered before this theme system existed.
|
|
THEMES: dict[str, dict] = {
|
|
"classic": {
|
|
"label": "Classic", "accent_hex": None,
|
|
"font_family": "inter", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
|
"gradient": True, "accent_amplitude": 48.0,
|
|
},
|
|
"terracotta": {
|
|
"label": "Terracotta", "accent_hex": "#a84b2a",
|
|
"font_family": "inter", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
|
"gradient": True, "accent_amplitude": 130.0,
|
|
},
|
|
"ochre": {
|
|
"label": "Ochre", "accent_hex": "#b8892b",
|
|
"font_family": "slab", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
|
"gradient": True, "accent_amplitude": 130.0,
|
|
},
|
|
"moss": {
|
|
"label": "Moss", "accent_hex": "#4f6f36",
|
|
"font_family": "serif", "radius": 4, "shadow": False,
|
|
"gradient": False, "accent_amplitude": 130.0,
|
|
},
|
|
"teal": {
|
|
"label": "Teal", "accent_hex": "#2b7a78",
|
|
"font_family": "source_sans", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
|
"gradient": True, "accent_amplitude": 130.0,
|
|
},
|
|
"slate": {
|
|
"label": "Slate", "accent_hex": "#3f5a88",
|
|
"font_family": "inter", "radius": 0, "shadow": False,
|
|
"gradient": False, "accent_amplitude": 130.0,
|
|
},
|
|
"plum": {
|
|
"label": "Plum", "accent_hex": "#6a3b5e",
|
|
"font_family": "elegant", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
|
"gradient": False, "accent_amplitude": 130.0,
|
|
},
|
|
}
|
|
|
|
|
|
# "classic" theme's accent_hex is None, meaning "keep this specific
|
|
# widget kind's own pre-theme-system look exactly" -- for tasks/calendar
|
|
# that's their classic THEME_TASKS/THEME_CALENDAR ink (a flat color, both
|
|
# gradient stops equal, since neither ever had a gradient header before
|
|
# this system existed); weather's modern style never went through
|
|
# panel_style.THEME at all -- its header was always this fixed blue
|
|
# gradient (see html_render.py's now-removed ACCENT_START/ACCENT_END
|
|
# constants) -- preserved here byte-for-byte so "classic" stays a
|
|
# genuinely no-visual-change default for every widget kind that shipped
|
|
# before themes existed.
|
|
_CLASSIC_WEATHER_GRADIENT = ("#1c4fd6", "#6fa8ff")
|
|
|
|
|
|
def resolve_theme(theme_name: str | None, widget_kind: str, palette_rgb: list | None) -> dict:
|
|
"""Concrete, ready-to-render values for one widget's modern-style
|
|
build_* function: accent_hex/accent_hex_dark (a gradient's two CSS
|
|
stops -- both equal when the theme has no gradient), font_family
|
|
(validated against FONT_FAMILIES) plus its resolved font_regular/
|
|
font_bold file paths, radius, shadow, gradient, and accent_amplitude
|
|
(for ordered_dither_regions' header rect -- unused by widgets that
|
|
dither their header at the base amplitude only, see module
|
|
docstring). widget_kind is one of panel_style.THEME's keys
|
|
("calendar"/"tasks"/"weather") plus "battery"/"text"/"static"/
|
|
"whiteboard" for the widgets that have no classic THEME_* ink of
|
|
their own -- those fall back to BLACK when theme_name is "classic"
|
|
or unrecognized."""
|
|
theme = THEMES.get(theme_name or DEFAULT_THEME, THEMES[DEFAULT_THEME])
|
|
accent_hex = theme["accent_hex"]
|
|
if accent_hex is None:
|
|
if widget_kind == "weather":
|
|
accent_hex, accent_hex_dark = _CLASSIC_WEATHER_GRADIENT
|
|
else:
|
|
ink_index = panel_style.THEME.get(widget_kind, panel_style.BLACK)
|
|
accent_hex = accent_hex_dark = _rgb_to_hex(panel_style.ink(palette_rgb, ink_index))
|
|
else:
|
|
accent_hex_dark = accent_hex if not theme["gradient"] else _darken_hex(
|
|
tuple(int(accent_hex[i:i + 2], 16) for i in (1, 3, 5))
|
|
)
|
|
family = theme["font_family"] if theme["font_family"] in FONT_FAMILIES else DEFAULT_FONT_FAMILY
|
|
return {
|
|
"theme_name": theme_name if theme_name in THEMES else DEFAULT_THEME,
|
|
"accent_hex": accent_hex,
|
|
"accent_hex_dark": accent_hex_dark,
|
|
"font_family": family,
|
|
"font_regular": str(font_path(family, False, False)),
|
|
"font_bold": str(font_path(family, True, False)),
|
|
"radius": theme["radius"],
|
|
"shadow": theme["shadow"],
|
|
"gradient": theme["gradient"],
|
|
"accent_amplitude": theme["accent_amplitude"],
|
|
}
|