"""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"], }