Add a curated theme system for "modern" style widgets, inspired by Tesserae
Build and push server image / test (push) Successful in 44s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m27s

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.
This commit is contained in:
2026-07-31 10:34:28 +00:00
parent e331f5e5a1
commit 5f4f8f2ea7
32 changed files with 808 additions and 195 deletions
+23 -66
View File
@@ -20,12 +20,11 @@ block of authored text."""
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageDraw
from sqlalchemy.orm import Session
from .. import theme_tokens
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
from ..models import Frame, TextWidgetConfig, Widget
from ..text_content import has_text
@@ -40,66 +39,20 @@ LINE_HEIGHT_FACTOR = 1.35
DEFAULT_FG = (0, 0, 0)
DEFAULT_BG = (255, 255, 255)
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts"
# A small curated set, not an open-ended picker -- each entry needs a
# real vendored Regular/Bold/Italic/BoldItalic file, so families that
# only ship as a variable font (Playfair Display, Lora, Merriweather,
# stock "Inter"/"Source Sans 3" from Google Fonts) were skipped in favor
# of static builds from their own upstream repos where one exists (see
# app/fonts/OFL-*.txt for each non-Noto family's own license/copyright --
# they're all OFL, same as the Noto fonts already vendored here, but
# each has a different copyright holder so gets its own license file
# rather than sharing app/fonts/OFL.txt).
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",
},
}
# The font-family table (a small curated set, not an open-ended picker --
# each entry needs a real vendored Regular/Bold/Italic/BoldItalic file)
# lives in app/theme_tokens.py now, shared with every modern-style
# widget's own font resolution -- re-exported here under their original
# names since this was the text widget's own table before the theme
# system needed it too (see app/fonts/OFL-*.txt for each non-Noto
# family's own license/copyright).
DEFAULT_FONT_FAMILY = theme_tokens.DEFAULT_FONT_FAMILY
FONT_FAMILIES = theme_tokens.FONT_FAMILIES
_FONT_FILES = theme_tokens._FONT_FILES
_font = theme_tokens.font
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
@lru_cache(maxsize=256)
def _font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
return ImageFont.truetype(str(_FONT_DIR / files[(bold, italic)]), size)
def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
"""One paragraph's styled runs -> word groups: each group is a list
of same-word sub-tokens that must stay glued together on one line
@@ -225,7 +178,7 @@ def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.I
def _render_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int,
palette_rgb: list | None) -> Image.Image:
palette_rgb: list | None, theme_name: str | None = None) -> Image.Image:
"""classic vs modern (app/html_render.py) -- shared by render() and
render_preview_png() so both honor render_style identically (weather
once shipped with its preview endpoint bypassing render_style
@@ -233,14 +186,17 @@ def _render_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int,
dispatch point exists specifically so that bug can't happen here).
palette_rgb is unused by the classic path (it never quantizes itself
-- see module docstring), only threaded through for modern's own
ordered_dither."""
ordered_dither. theme_name is threaded through uniformly (every
modern-style widget's dispatch takes one) but build_text ignores it
-- see its own docstring for why (the text widget's font is a
per-widget, user-authored choice, not theme-driven)."""
if cfg.render_style == "modern":
# Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's
# own classic path, should never pay for it.
from .. import html_render
return html_render.build_text(cfg, target_w, target_h, palette_rgb)
return html_render.build_text(cfg, target_w, target_h, palette_rgb, theme_name)
return _render_text(cfg, target_w, target_h)
@@ -252,10 +208,11 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
cfg = db.get(TextWidgetConfig, widget.id)
if cfg is None or not has_text(cfg.content):
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"])
return _render_dispatch(cfg, target_w, target_h, frame.palette_rgb)
return _render_dispatch(cfg, target_w, target_h, frame.palette_rgb, frame.theme)
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None) -> bytes:
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None,
theme_name: str | None = None) -> bytes:
"""A normal browser-viewable PNG at full logical panel size --
mirrors calendar_render.render_tasks_preview_png's relationship to
render_tasks (the dialog's own preview endpoint always renders at
@@ -264,7 +221,7 @@ def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: lis
import io
target_w, target_h = logical_render_size(orientation)
img = _render_dispatch(cfg, target_w, target_h, palette_rgb)
img = _render_dispatch(cfg, target_w, target_h, palette_rgb, theme_name)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")