Add a curated theme system for "modern" style widgets, inspired by Tesserae
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:
+23
-66
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user