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
+64
View File
@@ -212,6 +212,70 @@ Per-widget-type notes:
(`framed_image.html.jinja`, shared between the two) wrapping the (`framed_image.html.jinja`, shared between the two) wrapping the
already-composed image. already-composed image.
### Themes for modern-style widgets
`Frame.theme` (String, default `"classic"`, one Advanced Configuration
`<select>`) picks a curated visual preset for every modern-style widget
on that frame -- font family, corner radius, drop shadow, and an accent
hue for widgets with a header/accent region. Presets live in
`app/theme_tokens.py`'s `THEMES` dict; `resolve_theme(theme_name,
widget_kind, palette_rgb)` turns one into concrete, ready-to-render
values (`accent_hex`/`accent_hex_dark`, resolved `font_regular`/
`font_bold` file paths, `radius`, `shadow`, `accent_amplitude`). Inspired
by [Tesserae](https://github.com/dmellok/tesserae)'s (AGPL-3.0) own
three-layer CSS custom-property theme system -- this is an original
reimplementation of that *architecture*, not a copy of its token file
(see this repo's `CLAUDE.md` on copyleft dependencies).
**A theme is purely stylistic, never 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 -- no theme may recolor them, and every
`build_*`/`resolve_theme` call site that touches those stays on its own
existing logic untouched. Text's own per-widget `font_family` setting
(a user's explicit content-level choice, same carve-out reasoning) is
similarly never overridden by a theme -- `build_text` accepts a
`theme_name` param for signature uniformity with every other modern-
style builder but deliberately ignores it.
**Rich accent hues, not just the 6 exact panel inks.** A theme's
`accent_hex` can be any arbitrary color (e.g. terracotta, moss, slate) --
`html_render.ordered_dither_regions(rendered, palette_rgb,
base_amplitude, accent_regions=[(rect, amplitude), ...])` dithers the
whole widget at the existing safe default (`ordered_dither`'s tuned 48,
unchanged, still icon/text-legible) and then *separately* re-dithers
just the accent rectangle (a header bar's already-computed pixel rect)
at a theme's higher `accent_amplitude` (~130) and pastes it back. Safe
to do per-region for the same reason `ordered_dither` itself is safe
per-widget: ordered (Bayer) dithering has no cross-pixel error term, so
a region's result depends only on its own pixels. A single higher
amplitude applied to the *whole* widget instead was tried and rejected --
it washes out pale content (a weather icon's white cloud body nearly
vanished in testing); confining the higher amplitude to just the accent
rect avoids that while still letting the rect approximate a rich hue via
denser stippling instead of flatly snapping to one nearest ink (what
happens to a rich hue at the base amplitude).
**"classic" is a deliberately no-visual-change default.** Its
`accent_hex` is `None`, meaning "keep this widget kind's own pre-theme
look exactly": weather's header was always a fixed blue gradient (now
`theme_tokens._CLASSIC_WEATHER_GRADIENT`, byte-identical to the old
module-level `ACCENT_START`/`ACCENT_END` constants this system
replaced); tasks/calendar's header was always a flat single ink resolved
through `panel_style.THEME` (still is, just via `resolve_theme` now).
Widget kinds with no ink of their own (battery/text/static/whiteboard)
fall back to black, though none of their templates currently have an
accent-colored surface for it to visibly affect.
Which widgets get the richer accent-region treatment: weather's
`build_daily` (the header bar, when `city_label` is set), tasks, and
calendar's four view builders (each already computed a `header_h` in
Python for layout, reused as the accent rect). Weather's `build_current`,
battery, and static/whiteboard's shared `build_framed_image` are
theme-aware for font/radius/shadow only -- no header/accent region to
dither richer, so they call plain `ordered_dither` exactly as before
themes existed.
## Button actions ## Button actions
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
+76 -40
View File
@@ -22,7 +22,7 @@ from zoneinfo import ZoneInfo
from PIL import Image from PIL import Image
from . import html_render, panel_style from . import html_render, panel_style, theme_tokens
from .calendar_render import ( from .calendar_render import (
MARGIN, MARGIN,
WEEKDAY_NAMES, WEEKDAY_NAMES,
@@ -71,8 +71,11 @@ def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb,
def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image: weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_agenda.""" """HTML/CSS-rendered analogue of calendar_render._build_agenda. Has a
header bar -- dithered at the theme's accent_amplitude via
ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
day = datetime.now(tz).date() + timedelta(days=browse_offset) day = datetime.now(tz).date() + timedelta(days=browse_offset)
title_size = max(14, min(target_w, target_h) // 12) title_size = max(14, min(target_w, target_h) // 12)
body_size = max(11, min(target_w, target_h) // 20) body_size = max(11, min(target_w, target_h) // 20)
@@ -91,24 +94,30 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen, data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
target_h - header_h - MARGIN, row_h) target_h - header_h - MARGIN, row_h)
accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
template = html_render._jinja_env.get_template("calendar_agenda.html.jinja") template = html_render._jinja_env.get_template("calendar_agenda.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
font_dir=str(html_render._FONT_DIR), header=data["header"], title_size=title_size, header_h=header_h, font_regular=theme["font_regular"], font_bold=theme["font_bold"],
accent_start=accent, accent_end=accent, weather_entries=data["weather_entries"], header=data["header"], title_size=title_size, header_h=header_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_entries=data["weather_entries"],
weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"], weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"],
more_count=data["more_count"], row_h=row_h, body_size=body_size, more_count=data["more_count"], row_h=row_h, body_size=body_size,
) )
rendered = html_render.render_html_to_image(html, target_w, target_h) rendered = html_render.render_html_to_image(html, target_w, target_h)
return html_render.ordered_dither(rendered, palette_rgb) gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
return html_render.ordered_dither_regions(
rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
)
def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image: weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow """HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
-- two day-sections stacked (see _day_section_data).""" -- two day-sections stacked (see _day_section_data), each with its own
header bar dithered richer via ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset) start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2 section_h = target_h // 2
title_size = max(13, section_h // 8) title_size = max(13, section_h // 8)
@@ -131,26 +140,35 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
for d in day_dates for d in day_dates
] ]
accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja") template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
font_dir=str(html_render._FONT_DIR), days=days, title_size=title_size, header_h=header_h, font_regular=theme["font_regular"], font_bold=theme["font_bold"],
accent_start=accent, accent_end=accent, weather_size=weather_size, unit_suffix=unit_suffix, days=days, title_size=title_size, header_h=header_h,
row_h=row_h, body_size=body_size, accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
) )
rendered = html_render.render_html_to_image(html, target_w, target_h) rendered = html_render.render_html_to_image(html, target_w, target_h)
return html_render.ordered_dither(rendered, palette_rgb) gutter = panel_style.GUTTER
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
theme["accent_amplitude"])
for i in range(len(days))
]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None, week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal", weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal",
start_offset: int = 0) -> Image.Image: start_offset: int = 0, theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_week -- both """HTML/CSS-rendered analogue of calendar_render._build_week -- both
the vertical (stacked day-sections, reusing build_today_tomorrow's the vertical (stacked day-sections, reusing build_today_tomorrow's
template with an arbitrary day count) and horizontal (side-by-side template with an arbitrary day count) and horizontal (side-by-side
columns) layouts.""" columns) layouts. Each header (per-section or per-column) dithers
richer via ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
gutter = panel_style.GUTTER
today = datetime.now(tz).date() today = datetime.now(tz).date()
if days == 7: if days == 7:
days_since_start = (today.weekday() - week_start) % 7 days_since_start = (today.weekday() - week_start) % 7
@@ -158,7 +176,6 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
else: else:
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset) week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
unit_suffix = "F" if weather_units == "fahrenheit" else "C" unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
owners_seen: list[str] = [] owners_seen: list[str] = []
if layout == "vertical": if layout == "vertical":
@@ -180,13 +197,19 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
] ]
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja") template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
font_dir=str(html_render._FONT_DIR), days=day_sections, title_size=title_size, header_h=header_h, font_regular=theme["font_regular"], font_bold=theme["font_bold"],
accent_start=accent, accent_end=accent, weather_size=weather_size, unit_suffix=unit_suffix, days=day_sections, title_size=title_size, header_h=header_h,
row_h=row_h, body_size=body_size, accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
) )
rendered = html_render.render_html_to_image(html, target_w, target_h) rendered = html_render.render_html_to_image(html, target_w, target_h)
return html_render.ordered_dither(rendered, palette_rgb) accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
theme["accent_amplitude"])
for i in range(len(day_sections))
]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
header_size = max(10, min(16, (target_w // days) // 6)) header_size = max(10, min(16, (target_w // days) // 6))
chip_size = max(9, header_size - 3) chip_size = max(9, header_size - 3)
@@ -219,20 +242,29 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja") template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
font_dir=str(html_render._FONT_DIR), cols=cols, header_size=header_size, chip_size=chip_size, font_regular=theme["font_regular"], font_bold=theme["font_bold"],
header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix, accent_start=accent, accent_end=accent, cols=cols, header_size=header_size, chip_size=chip_size,
header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
) )
rendered = html_render.render_html_to_image(html, target_w, target_h) rendered = html_render.render_html_to_image(html, target_w, target_h)
return html_render.ordered_dither(rendered, palette_rgb) accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None) -> Image.Image: week_start: int, palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_month -- """HTML/CSS-rendered analogue of calendar_render._build_month --
density dots per day, not literal event text, same reasoning as the density dots per day, not literal event text, same reasoning as the
classic renderer (real text at typical month-cell size is close to classic renderer (real text at typical month-cell size is close to
unreadable on a 6-color dithered e-ink panel).""" unreadable on a 6-color dithered e-ink panel). The per-owner event
dots are identity-coding (like every other calendar view's chips) and
are never touched by a theme; only the weekday-name row (a flat
accent background, no gradient in this view) dithers richer via
ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
gutter = panel_style.GUTTER
today = datetime.now(tz).date() today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset) target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks_dates = list( weeks_dates = list(
@@ -243,7 +275,6 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
header_size = max(11, min(16, target_h // 30)) header_size = max(11, min(16, target_h // 30))
day_size = max(10, min(15, target_w // 55)) day_size = max(10, min(15, target_w // 55))
dot_size = max(4, day_size // 2) dot_size = max(4, day_size // 2)
accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
owners_seen: list[str] = [] owners_seen: list[str] = []
weeks = [] weeks = []
@@ -260,18 +291,22 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
template = html_render._jinja_env.get_template("calendar_month.html.jinja") template = html_render._jinja_env.get_template("calendar_month.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
font_dir=str(html_render._FONT_DIR), day_names=day_names, weeks=weeks, font_regular=theme["font_regular"], font_bold=theme["font_bold"],
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=accent, day_names=day_names, weeks=weeks,
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"],
) )
rendered = html_render.render_html_to_image(html, target_w, target_h) rendered = html_render.render_html_to_image(html, target_w, target_h)
return html_render.ordered_dither(rendered, palette_rgb) weekday_row_h = header_size + 12
accent_rect = (gutter, gutter, target_w - gutter, gutter + weekday_row_h)
return html_render.ordered_dither_regions(rendered, palette_rgb,
accent_regions=[(accent_rect, theme["accent_amplitude"])])
def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None, week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal", weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> Image.Image: week_start_offset: int = 0, theme_name: str | None = None) -> Image.Image:
"""Dispatches to the right build_* -- mirrors calendar_render._build's """Dispatches to the right build_* -- mirrors calendar_render._build's
exact "month falls back to agenda when it doesn't fit" resolution, so exact "month falls back to agenda when it doesn't fit" resolution, so
a narrow month-mode widget set to modern style still gets a sensible a narrow month-mode widget set to modern style still gets a sensible
@@ -281,11 +316,12 @@ def build(events: list[dict], view: str, browse_offset: int, target_w: int, targ
effective_view = "agenda" effective_view = "agenda"
if effective_view == "agenda": if effective_view == "agenda":
return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, weather_units) return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name)
if effective_view == "today_tomorrow": if effective_view == "today_tomorrow":
return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units) weather_units, theme_name)
if effective_view == "week": if effective_view == "week":
return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities, return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities,
weather_units, week_days, week_layout, week_start_offset) weather_units, week_days, week_layout, week_start_offset, theme_name)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb) return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name)
+91 -46
View File
@@ -8,7 +8,7 @@ dithering) plus one `build_*` function per widget type that has a
modern-style builder -- battery/text/tasks/static image/whiteboard live modern-style builder -- battery/text/tasks/static image/whiteboard live
here directly (mirroring how those widget types are themselves "inlined" here directly (mirroring how those widget types are themselves "inlined"
in their own widget.py rather than getting a dedicated render module); in their own widget.py rather than getting a dedicated render module);
calendar's (agenda mode only, see calendar_html_render.py) is the one calendar's (all four view modes, see calendar_html_render.py) is the one
exception, kept separate the same reason calendar_render.py itself is exception, kept separate the same reason calendar_render.py itself is
its own 800+ line file rather than joining battery/text/tasks inline. its own 800+ line file rather than joining battery/text/tasks inline.
Not offered for the photos widget -- a real photograph isn't a Not offered for the photos widget -- a real photograph isn't a
@@ -66,7 +66,7 @@ import numpy as np
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from PIL import Image from PIL import Image
from . import panel_style from . import panel_style, theme_tokens
from .image_pipeline import DEFAULT_PALETTE_RGB, hex_to_rgb from .image_pipeline import DEFAULT_PALETTE_RGB, hex_to_rgb
_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html" _TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html"
@@ -87,16 +87,6 @@ CATEGORY_EMOJI = {
"thunderstorm": "⛈️", "thunderstorm": "⛈️",
} }
# ACCENT_START/END: a fixed blue gradient pair for the "modern" style's
# card header -- deliberately not routed through panel_style.theme_color
# (unlike every classic-rendered widget's chrome), since the whole point
# of this style is the gradient look ordered_dither below then commits
# to exact palette colors anyway; which literal hex this starts from
# doesn't matter to the end result the way it would for a flat PIL fill.
ACCENT_START = "#1c4fd6"
ACCENT_END = "#6fa8ff"
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str: def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
return "#%02x%02x%02x" % tuple(rgb) return "#%02x%02x%02x" % tuple(rgb)
@@ -104,7 +94,7 @@ def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str: def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
"""A darker shade of `rgb` for a CSS gradient's second stop -- purely """A darker shade of `rgb` for a CSS gradient's second stop -- purely
decorative (ordered_dither commits everything to exact palette colors decorative (ordered_dither commits everything to exact palette colors
regardless), same reasoning ACCENT_START/END's own comment gives.""" regardless of which literal hex a gradient starts from)."""
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb)) return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
@@ -247,6 +237,26 @@ def ordered_dither(img: Image.Image, palette_rgb: list | None, amplitude: float
return Image.fromarray(palette[idx].astype(np.uint8), "RGB") return Image.fromarray(palette[idx].astype(np.uint8), "RGB")
def ordered_dither_regions(rendered: Image.Image, palette_rgb: list | None, base_amplitude: float = 48.0,
accent_regions: list[tuple[tuple[int, int, int, int], float]] = ()) -> Image.Image:
"""Like `ordered_dither`, but lets specific rectangles (e.g. a themed
header bar) dither at a higher amplitude than the rest of the widget.
A single higher amplitude applied to a whole widget washes out pale
content (a weather icon's white cloud body nearly disappeared in
testing); dithering the base image at the safe default and only
re-dithering an accent rect on top -- pasted back over the base --
lets a header carry a rich, arbitrary accent hue (via denser
stippling) without touching icon/text legibility elsewhere. Safe to
do per-region for the same reason `ordered_dither` is safe per-widget
(see its docstring): no cross-pixel error-diffusion term, so each
region's result depends only on its own pixels."""
base = ordered_dither(rendered, palette_rgb, amplitude=base_amplitude)
for (x0, y0, x1, y1), amplitude in accent_regions:
crop = rendered.crop((x0, y0, x1, y1))
base.paste(ordered_dither(crop, palette_rgb, amplitude=amplitude), (x0, y0))
return base
# --- Weather "modern" style ---------------------------------------------- # --- Weather "modern" style ----------------------------------------------
def _day_label(day_date: date) -> str: def _day_label(day_date: date) -> str:
@@ -259,21 +269,25 @@ def _day_label(day_date: date) -> str:
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None, def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "") -> Image.Image: units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_current -- """HTML/CSS-rendered analogue of weather_render.build_current --
same call signature, so app/widgets/weather.py can dispatch to same call signature, so app/widgets/weather.py can dispatch to
either interchangeably. Returns an already-palette-exact RGB image either interchangeably. Returns an already-palette-exact RGB image
(see ordered_dither).""" (see ordered_dither). No header/accent region here (just a centered
icon+temp) -- theme-aware for font/radius only, plain ordered_dither
(no ordered_dither_regions call)."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255)) img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
if not entry: if not entry:
return img return img
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C" unit_suffix = "F" if units == "fahrenheit" else "C"
icon_size = max(28, min(target_w, target_h) // 3) icon_size = max(28, min(target_w, target_h) // 3)
template = _jinja_env.get_template("weather_current.html.jinja") template = _jinja_env.get_template("weather_current.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
font_dir=str(_FONT_DIR), emoji=CATEGORY_EMOJI.get(entry["category"], ""), font_regular=theme["font_regular"], font_bold=theme["font_bold"],
emoji=CATEGORY_EMOJI.get(entry["category"], ""),
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label, temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label,
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3), icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
label_size=max(12, icon_size // 3), label_size=max(12, icon_size // 3),
@@ -283,15 +297,18 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb:
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None, def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "") -> Image.Image: units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same """HTML/CSS-rendered analogue of weather_render.build_daily -- same
call signature. Returns an already-palette-exact RGB image (see call signature. Returns an already-palette-exact RGB image (see
ordered_dither).""" ordered_dither). Has a header bar -- dithered at the theme's
accent_amplitude via ordered_dither_regions, richer than the rest of
the widget (see that function's docstring for why)."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255)) img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
days = list(daily.items()) days = list(daily.items())
if not days: if not days:
return img return img
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C" unit_suffix = "F" if units == "fahrenheit" else "C"
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0 header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
col_w = max(1, target_w // len(days)) col_w = max(1, target_w // len(days))
@@ -307,21 +324,26 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
] ]
template = _jinja_env.get_template("weather_daily.html.jinja") template = _jinja_env.get_template("weather_daily.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
font_dir=str(_FONT_DIR), city_label=city_label, header_h=header_h, font_regular=theme["font_regular"], font_bold=theme["font_bold"],
title_size=max(14, header_h - 12), accent_start=ACCENT_START, accent_end=ACCENT_END, city_label=city_label, header_h=header_h,
title_size=max(14, header_h - 12), accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2), days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2),
unit_suffix=unit_suffix, unit_suffix=unit_suffix,
) )
rendered = render_html_to_image(html, target_w, target_h) rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb) if header_h <= 0:
return ordered_dither(rendered, palette_rgb)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
SUPPORTED_MODES = ("current", "daily") SUPPORTED_MODES = ("current", "daily")
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None, def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "") -> Image.Image: units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""Dispatches to build_current/build_daily -- mirrors weather_render. """Dispatches to build_current/build_daily -- mirrors weather_render.
build()'s signature (minus interval_hours, which no modern-style mode build()'s signature (minus interval_hours, which no modern-style mode
uses) so app/widgets/weather.py and the weather preview endpoint can uses) so app/widgets/weather.py and the weather preview endpoint can
@@ -329,12 +351,13 @@ def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | Non
SUPPORTED_MODES -- callers are expected to have already fallen back to SUPPORTED_MODES -- callers are expected to have already fallen back to
weather_render.build() for hourly/multi_city (see weather.py).""" weather_render.build() for hourly/multi_city (see weather.py)."""
if mode == "current": if mode == "current":
return build_current(data, target_w, target_h, palette_rgb, units, city_label) return build_current(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
return build_daily(data, target_w, target_h, palette_rgb, units, city_label) return build_daily(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None, def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", city_label: str = "") -> bytes: units: str = "fahrenheit", city_label: str = "",
theme_name: str | None = None) -> bytes:
"""Modern-style analogue of weather_render.render_weather_preview_png """Modern-style analogue of weather_render.render_weather_preview_png
-- same browser-viewable-PNG convention every other widget's preview -- same browser-viewable-PNG convention every other widget's preview
endpoint uses. build()'s output is already palette-exact (see endpoint uses. build()'s output is already palette-exact (see
@@ -343,7 +366,7 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l
from .image_pipeline import _quantize, _png_bytes, logical_render_size from .image_pipeline import _quantize, _png_bytes, logical_render_size
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation)
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label) img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, theme_name)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized) return _png_bytes(quantized)
@@ -361,13 +384,16 @@ def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int, def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
palette_rgb: list | None = None) -> Image.Image: palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of widgets/battery.py's classic PIL """HTML/CSS-rendered analogue of widgets/battery.py's classic PIL
drawing -- same icon+percent+caption-lines shape, `lines` already drawing -- same icon+percent+caption-lines shape, `lines` already
resolved by the caller (widgets/battery.py's _lines_for(), shared resolved by the caller (widgets/battery.py's _lines_for(), shared
with the classic path so the estimate/age formatting only lives in with the classic path so the estimate/age formatting only lives in
one place). Returns an already-palette-exact RGB image (see one place). Returns an already-palette-exact RGB image (see
ordered_dither). ordered_dither). Theme-aware for font/radius only -- the charge-level
fill_color below is a functional status signal (not a style choice)
and is never touched by a theme, and there's no header/accent region
to dither richer via ordered_dither_regions.
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
the whole stack actually fits the available height -- the classic the whole stack actually fits the available height -- the classic
@@ -392,6 +418,7 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
num_lines = len(lines) num_lines = len(lines)
sizes = _battery_sizes(target_w, target_h, num_lines, scale) sizes = _battery_sizes(target_w, target_h, num_lines, scale)
theme = theme_tokens.resolve_theme(theme_name, "battery", palette_rgb)
fill_color = panel_style.battery_fill_color(percent, palette_rgb) fill_color = panel_style.battery_fill_color(percent, palette_rgb)
icon_h = sizes["icon_h"] icon_h = sizes["icon_h"]
icon_w = int(icon_h * 1.8) icon_w = int(icon_h * 1.8)
@@ -399,8 +426,8 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
nub_w = max(3, icon_w // 10) nub_w = max(3, icon_w // 10)
template = _jinja_env.get_template("battery.html.jinja") template = _jinja_env.get_template("battery.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
font_dir=str(_FONT_DIR), percent=percent, lines=lines, font_regular=theme["font_regular"], font_bold=theme["font_bold"], percent=percent, lines=lines,
icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke, icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke,
fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke), fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke),
fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color), fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color),
@@ -413,7 +440,8 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
# --- Text "modern" style --------------------------------------------------- # --- Text "modern" style ---------------------------------------------------
def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None) -> Image.Image: def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None,
theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of widgets/text.py's classic PIL """HTML/CSS-rendered analogue of widgets/text.py's classic PIL
drawing. Reuses widgets/text.py's own `_fit()` for the one piece of drawing. Reuses widgets/text.py's own `_fit()` for the one piece of
logic CSS has no native equivalent for (shrink-to-fit sizing) -- logic CSS has no native equivalent for (shrink-to-fit sizing) --
@@ -425,7 +453,15 @@ def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = Non
algorithms can disagree on exact break points, an acceptable algorithms can disagree on exact break points, an acceptable
approximation since this style only needs to look good and fit approximation since this style only needs to look good and fit
reasonably, not be pixel-identical to classic. Returns an already- reasonably, not be pixel-identical to classic. Returns an already-
palette-exact RGB image (see ordered_dither).""" palette-exact RGB image (see ordered_dither).
theme_name is accepted (every modern-style build_* function takes
one, threaded uniformly from frame.theme) but deliberately unused --
the text widget's own font_family is a per-widget, user-authored
choice (see widgets/text.py's module docstring), same carve-out
reasoning as run-level colors; a frame theme overriding it would
silently undo an explicit user choice. text.html.jinja also has no
card chrome (no radius/shadow) for a theme to touch."""
from PIL import ImageDraw from PIL import ImageDraw
from . import widgets # local import: heavy-ish, and only "modern" text needs it from . import widgets # local import: heavy-ish, and only "modern" text needs it
@@ -459,15 +495,19 @@ def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = Non
# --- Tasks "modern" style --------------------------------------------------- # --- Tasks "modern" style ---------------------------------------------------
def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None, def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
title: str = "Tasks") -> Image.Image: title: str = "Tasks", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_tasks -- """HTML/CSS-rendered analogue of calendar_render._build_tasks --
same header+checklist shape. Reuses calendar_render's own same header+checklist shape. Reuses calendar_render's own
_event_colors/_fmt_task_due (the exact color-dedup/due-date-format _event_colors/_fmt_task_due (the exact color-dedup/due-date-format
logic the classic renderer uses) so a task's color chip/due string logic the classic renderer uses) so a task's color chip/due string
matches classic style exactly; only the drawing differs. Returns an matches classic style exactly; only the drawing differs -- and a
already-palette-exact RGB image (see ordered_dither).""" theme's accent never touches those per-owner chip colors (identity-
coding, not style). Has a header bar -- dithered at the theme's
accent_amplitude via ordered_dither_regions. Returns an already-
palette-exact RGB image (see ordered_dither)."""
from .calendar_render import _event_colors, _fmt_task_due from .calendar_render import _event_colors, _fmt_task_due
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
header_h = max(28, min(target_w, target_h) // 8) header_h = max(28, min(target_w, target_h) // 8)
body_size = max(11, min(target_w, target_h) // 20) body_size = max(11, min(target_w, target_h) // 20)
row_h = body_size + 14 row_h = body_size + 14
@@ -491,35 +531,40 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
template = _jinja_env.get_template("tasks.html.jinja") template = _jinja_env.get_template("tasks.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
font_dir=str(_FONT_DIR), title=title, header_h=header_h, title_size=max(14, header_h - 12), font_regular=theme["font_regular"], font_bold=theme["font_bold"],
accent_start=_rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_TASKS)), title=title, header_h=header_h, title_size=max(14, header_h - 12),
accent_end=_rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_TASKS)), accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size, rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size,
) )
rendered = render_html_to_image(html, target_w, target_h) rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb) gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
# --- Static image / whiteboard "modern" style (shared) --------------------- # --- Static image / whiteboard "modern" style (shared) ---------------------
def build_framed_image(composed: Image.Image, target_w: int, target_h: int, def build_framed_image(composed: Image.Image, target_w: int, target_h: int,
palette_rgb: list | None = None) -> Image.Image: palette_rgb: list | None = None, theme_name: str | None = None,
widget_kind: str = "static") -> Image.Image:
"""Wraps an already-composed image (static_image.py/whiteboard.py's """Wraps an already-composed image (static_image.py/whiteboard.py's
own compose_into() output, exactly target_w x target_h, already own compose_into() output, exactly target_w x target_h, already
cropped/fit per that widget's own display_mode) in a rounded-corner, cropped/fit per that widget's own display_mode) in a rounded-corner,
shadowed card -- the first visual chrome either widget type has ever shadowed card -- the first visual chrome either widget type has ever
had (both currently draw with zero chrome of their own). Returns an had (both currently draw with zero chrome of their own). Theme-aware
already-palette-exact RGB image (see ordered_dither).""" for radius/shadow only -- no text/header content to accent or font.
Returns an already-palette-exact RGB image (see ordered_dither)."""
import base64 import base64
theme = theme_tokens.resolve_theme(theme_name, widget_kind, palette_rgb)
buf = io.BytesIO() buf = io.BytesIO()
composed.convert("RGB").save(buf, format="PNG") composed.convert("RGB").save(buf, format="PNG")
image_b64 = base64.b64encode(buf.getvalue()).decode("ascii") image_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
template = _jinja_env.get_template("framed_image.html.jinja") template = _jinja_env.get_template("framed_image.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
image_b64=image_b64, image_b64=image_b64,
) )
rendered = render_html_to_image(html, target_w, target_h) rendered = render_html_to_image(html, target_w, target_h)
+10
View File
@@ -880,6 +880,15 @@ def _migration_38(conn) -> None:
conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'")) conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
def _migration_39(conn) -> None:
"""Frame-level curated theme for "modern" style widgets (models.
Frame.theme, see theme_tokens.THEMES) -- same guarded-per-column
shape as every prior migration."""
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
if "theme" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'"))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -919,6 +928,7 @@ MIGRATIONS = [
(36, _migration_36), (36, _migration_36),
(37, _migration_37), (37, _migration_37),
(38, _migration_38), (38, _migration_38),
(39, _migration_39),
] ]
+5
View File
@@ -186,6 +186,11 @@ class Frame(Base):
# for real photographs. NULL/1.0 = same defaults as the main fields. # for real photographs. NULL/1.0 = same defaults as the main fields.
photo_palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) photo_palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
photo_dither_strength: Mapped[float] = mapped_column(Float, default=1.0) photo_dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# Curated visual theme for "modern" (HTML/CSS) style widgets -- see
# theme_tokens.THEMES. Frame-level (like palette_rgb/dither_strength
# above), not per-widget, since a theme is "how this frame looks."
# Widgets rendered in classic (PIL) style ignore this entirely.
theme: Mapped[str] = mapped_column(String, default="classic")
# -- calendar mode (see calendar_feed.py, calendar_render.py, # -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) -- # routers/device.py's RENDERERS["calendar"]) --
+4 -1
View File
@@ -25,7 +25,7 @@ from fastapi.responses import Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import gitea_releases, grid, quiet_hours from .. import gitea_releases, grid, quiet_hours, theme_tokens
from ..auth import require_frame_control, require_frame_view, require_user_api from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db from ..db import frame_locked, get_db
from ..global_actions import GLOBAL_ACTIONS from ..global_actions import GLOBAL_ACTIONS
@@ -108,6 +108,7 @@ def api_config_save(
photo_palette: list[str] | None = Form(None), photo_palette: list[str] | None = Form(None),
photo_palette_reset: bool | None = Form(None), photo_palette_reset: bool | None = Form(None),
photo_dither_strength: float | None = Form(None), photo_dither_strength: float | None = Form(None),
theme: str | None = Form(None),
hold_duration_ms: int | None = Form(None), hold_duration_ms: int | None = Form(None),
next_hold_action: str | None = Form(None), next_hold_action: str | None = Form(None),
back_hold_action: str | None = Form(None), back_hold_action: str | None = Form(None),
@@ -196,6 +197,8 @@ def api_config_save(
cfg.photo_palette_rgb = [list(rgb) for rgb in parsed] cfg.photo_palette_rgb = [list(rgb) for rgb in parsed]
if photo_dither_strength is not None: if photo_dither_strength is not None:
cfg.photo_dither_strength = max(0.0, min(1.0, photo_dither_strength)) cfg.photo_dither_strength = max(0.0, min(1.0, photo_dither_strength))
if theme is not None:
cfg.theme = theme if theme in theme_tokens.THEMES else "classic"
if hold_duration_ms is not None: if hold_duration_ms is not None:
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms)) cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
if next_hold_action is not None: if next_hold_action is not None:
+8 -5
View File
@@ -876,6 +876,7 @@ def api_widget_preview_calendar(
img = calendar_html_render.build( img = calendar_html_render.build(
events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb, events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb,
weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset, weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset,
frame.theme,
) )
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0) quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized) png = _png_bytes(quantized)
@@ -911,7 +912,7 @@ def api_widget_preview_tasks(
from .. import html_render from .. import html_render
target_w, target_h = logical_render_size(frame.orientation) target_w, target_h = logical_render_size(frame.orientation)
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title) img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0) quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized) png = _png_bytes(quantized)
else: else:
@@ -1170,7 +1171,7 @@ def api_widget_preview_weather(
png = html_render.render_weather_preview_png( png = html_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units, wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", city_label=wcfg.city_label or "", theme_name=frame.theme,
) )
else: else:
png = weather_render.render_weather_preview_png( png = weather_render.render_weather_preview_png(
@@ -1230,7 +1231,7 @@ def api_widget_preview_static(
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
display_mode=scfg.display_mode) display_mode=scfg.display_mode)
fitted = _enhance(composed, frame.color_boost, frame.contrast_boost) fitted = _enhance(composed, frame.color_boost, frame.contrast_boost)
img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb) img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb, frame.theme, "static")
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0) quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized) png = _png_bytes(quantized)
else: else:
@@ -1257,7 +1258,8 @@ def api_widget_preview_text(
xcfg = db.get(TextWidgetConfig, widget.id) xcfg = db.get(TextWidgetConfig, widget.id)
if not has_text(xcfg.content): if not has_text(xcfg.content):
raise HTTPException(400, "No text authored on this widget yet") raise HTTPException(400, "No text authored on this widget yet")
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb) png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
theme_name=frame.theme)
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@@ -1379,7 +1381,8 @@ def api_widget_preview_whiteboard(
target_w, target_h = logical_render_size(frame.orientation) target_w, target_h = logical_render_size(frame.orientation)
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
display_mode="letterbox") display_mode="letterbox")
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb) img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
"whiteboard")
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0) quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized) png = _png_bytes(quantized)
else: else:
+2 -1
View File
@@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import weather from .. import theme_tokens, weather
from ..auth import can_view_frame, current_user from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db from ..db import get_db
@@ -94,6 +94,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
palette_to_hex=palette_to_hex, palette_to_hex=palette_to_hex,
photo_widget_id=photo_widget_id, photo_widget_id=photo_widget_id,
global_action_labels=GLOBAL_ACTION_LABELS, global_action_labels=GLOBAL_ACTION_LABELS,
themes=theme_tokens.THEMES,
) )
+18
View File
@@ -260,6 +260,24 @@ document.getElementById('photo-palette-reset').addEventListener('click', () => {
savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' }); savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' });
}); });
// ---- Theme ----
document.getElementById('theme-save').addEventListener('click', async () => {
const body = new URLSearchParams({ theme: document.getElementById('theme-select').value });
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadPreview();
} catch (e) {
showStatus(false, e.message);
}
});
// ---- Preview: current photo vs. how it renders with saved settings ---- // ---- Preview: current photo vs. how it renders with saved settings ----
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API, // Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
// set by the template) rather than window.FRAME_API -- palette/color/ // set by the template) rather than window.FRAME_API -- palette/color/
+17
View File
@@ -196,6 +196,23 @@
Compare against the physical panel before keeping it.</p> Compare against the physical panel before keeping it.</p>
</details> </details>
<details class="card">
<summary class="card-title">Theme</summary>
<p class="sub">A curated visual style for widgets using the
"modern" (experimental) render style -- font, corner radius,
shadow, and header accent. Widgets rendered in the classic
style are unaffected. Photos are unaffected too (see Photos
configuration below).</p>
<label>Theme
<select id="theme-select">
{% for key, t in themes.items() %}
<option value="{{ key }}" {% if frame.theme == key %}selected{% endif %}>{{ t.label }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="theme-save" style="margin-top: 16px;">Save</button>
</details>
<details class="card"> <details class="card">
<summary class="card-title">Photos configuration</summary> <summary class="card-title">Photos configuration</summary>
<p class="sub">Palette and dithering used <strong>only</strong> by <p class="sub">Palette and dithering used <strong>only</strong> by
@@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html><head><style> <html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .card {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
@@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html><head><style> <html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .card {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
@@ -10,7 +10,7 @@
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px; border-radius: {{ radius }}px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2); {% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff; background: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html><head><style> <html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .card {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
@@ -10,7 +10,7 @@
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px; border-radius: {{ radius }}px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2); {% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff; background: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html><head><style> <html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .card {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
@@ -10,7 +10,7 @@
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px; border-radius: {{ radius }}px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2); {% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff; background: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html><head><style> <html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .card {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
@@ -10,7 +10,7 @@
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px; border-radius: {{ radius }}px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2); {% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff; background: #ffffff;
display: flex; display: flex;
} }
@@ -8,7 +8,7 @@
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px; border-radius: {{ radius }}px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25); {% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
background: #ffffff; background: #ffffff;
} }
.card img { .card img {
@@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html><head><style> <html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .card {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
@@ -10,7 +10,7 @@
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px; border-radius: {{ radius }}px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2); {% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff; background: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html><head><style> <html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .card {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
@@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html><head><style> <html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .card {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
@@ -10,7 +10,7 @@
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px; border-radius: {{ radius }}px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25); {% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
background: #ffffff; background: #ffffff;
} }
.header { .header {
+202
View File
@@ -0,0 +1,202 @@
"""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"],
}
+1 -1
View File
@@ -96,7 +96,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
# image_pipeline.render_placeholder's local `import qrcode`). # image_pipeline.render_placeholder's local `import qrcode`).
from .. import html_render from .. import html_render
return html_render.build_battery(percent, lines, target_w, target_h, palette_rgb) return html_render.build_battery(percent, lines, target_w, target_h, palette_rgb, frame.theme)
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
cx = cx0 + cw // 2 cx = cx0 + cw // 2
+1
View File
@@ -59,6 +59,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
return calendar_html_render.build( return calendar_html_render.build(
events, cfg.view, cfg.browse_offset, target_w, target_h, tz, cfg.week_start, frame.palette_rgb, events, cfg.view, cfg.browse_offset, target_w, target_h, tz, cfg.week_start, frame.palette_rgb,
weather_cities, cfg.weather_units, cfg.week_days, cfg.week_layout, cfg.week_start_offset, weather_cities, cfg.weather_units, cfg.week_days, cfg.week_layout, cfg.week_start_offset,
frame.theme,
) )
return _build( return _build(
+1 -1
View File
@@ -39,5 +39,5 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
# own classic path, should never pay for it. # own classic path, should never pay for it.
from .. import html_render from .. import html_render
return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb) return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme, "static")
return composed return composed
+1 -1
View File
@@ -43,7 +43,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
# own classic path, should never pay for it. # own classic path, should never pay for it.
from .. import html_render from .. import html_render
return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title) return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title) return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title)
+23 -66
View File
@@ -20,12 +20,11 @@ block of authored text."""
from __future__ import annotations from __future__ import annotations
import re 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 sqlalchemy.orm import Session
from .. import theme_tokens
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
from ..models import Frame, TextWidgetConfig, Widget from ..models import Frame, TextWidgetConfig, Widget
from ..text_content import has_text from ..text_content import has_text
@@ -40,66 +39,20 @@ LINE_HEIGHT_FACTOR = 1.35
DEFAULT_FG = (0, 0, 0) DEFAULT_FG = (0, 0, 0)
DEFAULT_BG = (255, 255, 255) DEFAULT_BG = (255, 255, 255)
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts" # The font-family table (a small curated set, not an open-ended picker --
# each entry needs a real vendored Regular/Bold/Italic/BoldItalic file)
# A small curated set, not an open-ended picker -- each entry needs a # lives in app/theme_tokens.py now, shared with every modern-style
# real vendored Regular/Bold/Italic/BoldItalic file, so families that # widget's own font resolution -- re-exported here under their original
# only ship as a variable font (Playfair Display, Lora, Merriweather, # names since this was the text widget's own table before the theme
# stock "Inter"/"Source Sans 3" from Google Fonts) were skipped in favor # system needed it too (see app/fonts/OFL-*.txt for each non-Noto
# of static builds from their own upstream repos where one exists (see # family's own license/copyright).
# app/fonts/OFL-*.txt for each non-Noto family's own license/copyright -- DEFAULT_FONT_FAMILY = theme_tokens.DEFAULT_FONT_FAMILY
# they're all OFL, same as the Noto fonts already vendored here, but FONT_FAMILIES = theme_tokens.FONT_FAMILIES
# each has a different copyright holder so gets its own license file _FONT_FILES = theme_tokens._FONT_FILES
# rather than sharing app/fonts/OFL.txt). _font = theme_tokens.font
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",
},
}
_WORD_OR_SPACE = re.compile(r"\S+|\s+") _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]]: def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
"""One paragraph's styled runs -> word groups: each group is a list """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 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, 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 """classic vs modern (app/html_render.py) -- shared by render() and
render_preview_png() so both honor render_style identically (weather render_preview_png() so both honor render_style identically (weather
once shipped with its preview endpoint bypassing render_style 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). dispatch point exists specifically so that bug can't happen here).
palette_rgb is unused by the classic path (it never quantizes itself palette_rgb is unused by the classic path (it never quantizes itself
-- see module docstring), only threaded through for modern's own -- 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": if cfg.render_style == "modern":
# Local import: html_render pulls in Playwright, a real headless- # Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's # Chromium dependency -- every other widget type, and this one's
# own classic path, should never pay for it. # own classic path, should never pay for it.
from .. import html_render 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) 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) cfg = db.get(TextWidgetConfig, widget.id)
if cfg is None or not has_text(cfg.content): if cfg is None or not has_text(cfg.content):
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"]) 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 -- """A normal browser-viewable PNG at full logical panel size --
mirrors calendar_render.render_tasks_preview_png's relationship to mirrors calendar_render.render_tasks_preview_png's relationship to
render_tasks (the dialog's own preview endpoint always renders at 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 import io
target_w, target_h = logical_render_size(orientation) 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) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG") quantized.convert("RGB").save(buf, format="PNG")
+1 -1
View File
@@ -40,7 +40,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
from .. import html_render from .. import html_render
return html_render.build(cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units, return html_render.build(cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
city_label=cfg.city_label or "") city_label=cfg.city_label or "", theme_name=frame.theme)
return weather_render.build( return weather_render.build(
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units, cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
+2 -1
View File
@@ -43,7 +43,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
# own classic path, should never pay for it. # own classic path, should never pay for it.
from .. import html_render from .. import html_render
return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb) return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
"whiteboard")
return composed return composed
+65
View File
@@ -0,0 +1,65 @@
"""app.html_render's shared ordered-dithering primitives -- ordered_dither
and ordered_dither_regions -- exercised directly against synthetic
images, no Chromium/Playwright involved (these two functions run purely
on whatever Image render_html_to_image already handed back)."""
from __future__ import annotations
from PIL import Image
from app import html_render
from app.image_pipeline import DEFAULT_PALETTE_RGB
_PALETTE = set(DEFAULT_PALETTE_RGB)
# A muddy hue nowhere near any of the 6 exact palette colors -- at the
# "modern" style's tuned default amplitude (48), this should still snap
# flatly to a single nearest ink (see theme_tokens.py's module docstring
# for why 48 was chosen for icon/text legibility); only a much higher
# amplitude (as a rich theme's accent_amplitude would use) stipples it
# into a multi-ink approximation.
_RICH_HUE = (168, 75, 42) # a terracotta-ish RGB, not one of the 6 inks
def test_ordered_dither_output_is_exact_palette_colors():
img = Image.new("RGB", (40, 30), (128, 128, 128))
dithered = html_render.ordered_dither(img, None)
assert set(dithered.getdata()) <= _PALETTE
def test_ordered_dither_regions_outside_the_region_matches_plain_dither():
"""Pixels outside every accent_regions rect must come out identical
to a plain ordered_dither call at base_amplitude -- the region-aware
variant must not perturb anything it wasn't asked to."""
img = Image.new("RGB", (100, 80), (90, 140, 200))
base_only = html_render.ordered_dither(img, None, amplitude=48.0)
regions = html_render.ordered_dither_regions(
img, None, base_amplitude=48.0, accent_regions=[((10, 10, 40, 30), 130.0)]
)
for x in range(100):
for y in range(80):
if 10 <= x < 40 and 10 <= y < 30:
continue # inside the accent region -- expected to differ
assert regions.getpixel((x, y)) == base_only.getpixel((x, y))
def test_ordered_dither_regions_stipples_a_rich_hue_the_base_amplitude_would_flatten():
"""The whole reason ordered_dither_regions exists: a rich accent hue
dithered at the base (icon/text-safe) amplitude just snaps to one
nearest ink, but the same hue in an accent region at a theme's higher
accent_amplitude resolves to a believable multi-ink stipple instead --
assert that difference directly, not just "some image came back"."""
img = Image.new("RGB", (60, 60), _RICH_HUE)
rect = (0, 0, 60, 60)
flat = html_render.ordered_dither(img, None, amplitude=48.0)
richer = html_render.ordered_dither_regions(img, None, base_amplitude=48.0, accent_regions=[(rect, 130.0)])
assert len(set(flat.getdata())) == 1
assert len(set(richer.getdata())) > 1
assert set(richer.getdata()) <= _PALETTE
def test_ordered_dither_regions_with_no_accent_regions_matches_plain_dither():
img = Image.new("RGB", (30, 30), (50, 60, 70))
assert list(html_render.ordered_dither_regions(img, None, base_amplitude=48.0).getdata()) == \
list(html_render.ordered_dither(img, None, amplitude=48.0).getdata())
+92
View File
@@ -0,0 +1,92 @@
"""app.theme_tokens -- resolve_theme()'s fallback logic (does "classic"
stay a byte-identical no-op vs. each widget kind's pre-theme-system
look?) and the font table every preset resolves against. No rendering
here -- see test_html_render.py for ordered_dither_regions and each
widget's own test_widgets_*.py for dispatch-level "does a theme actually
change the output" coverage."""
from __future__ import annotations
from pathlib import Path
from app import panel_style, theme_tokens
def test_classic_weather_matches_historical_fixed_gradient():
"""Weather's modern style never went through panel_style.THEME --
its header was always this fixed blue gradient (see html_render.py's
removed ACCENT_START/ACCENT_END). "classic" must reproduce it
byte-for-byte so themes are additive, not a silent regression."""
resolved = theme_tokens.resolve_theme("classic", "weather", None)
assert resolved["accent_hex"] == "#1c4fd6"
assert resolved["accent_hex_dark"] == "#6fa8ff"
def test_classic_tasks_and_calendar_use_flat_theme_ink():
"""Tasks/calendar's classic accent was always a flat single ink (no
gradient) resolved through panel_style.THEME -- both ends of the
"gradient" must be that same ink, not two different shades."""
tasks = theme_tokens.resolve_theme("classic", "tasks", None)
calendar = theme_tokens.resolve_theme("classic", "calendar", None)
assert tasks["accent_hex"] == tasks["accent_hex_dark"]
assert calendar["accent_hex"] == calendar["accent_hex_dark"]
from app.image_pipeline import DEFAULT_PALETTE_RGB
expected_tasks = "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.THEME_TASKS])
expected_calendar = "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.THEME_CALENDAR])
assert tasks["accent_hex"] == expected_tasks
assert calendar["accent_hex"] == expected_calendar
def test_classic_unmapped_widget_kind_falls_back_to_black():
resolved = theme_tokens.resolve_theme("classic", "battery", None)
from app.image_pipeline import DEFAULT_PALETTE_RGB
assert resolved["accent_hex"] == "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.BLACK])
def test_unknown_theme_name_falls_back_to_classic():
assert theme_tokens.resolve_theme("not-a-real-theme", "weather", None) == \
theme_tokens.resolve_theme("classic", "weather", None)
assert theme_tokens.resolve_theme(None, "weather", None) == \
theme_tokens.resolve_theme("classic", "weather", None)
def test_every_preset_resolves_without_error_for_every_widget_kind():
widget_kinds = ["weather", "tasks", "calendar", "battery", "text", "static", "whiteboard"]
for theme_name in theme_tokens.THEMES:
for kind in widget_kinds:
resolved = theme_tokens.resolve_theme(theme_name, kind, None)
assert resolved["accent_hex"].startswith("#") and len(resolved["accent_hex"]) == 7
assert resolved["accent_hex_dark"].startswith("#") and len(resolved["accent_hex_dark"]) == 7
assert resolved["font_family"] in theme_tokens.FONT_FAMILIES
assert Path(resolved["font_regular"]).exists()
assert Path(resolved["font_bold"]).exists()
def test_non_gradient_theme_has_equal_accent_stops():
""""moss" is configured with gradient=False -- its two CSS gradient
stops must be identical (a flat fill), unlike a gradient theme's."""
resolved = theme_tokens.resolve_theme("moss", "tasks", None)
assert resolved["gradient"] is False
assert resolved["accent_hex"] == resolved["accent_hex_dark"]
def test_gradient_theme_has_a_darker_second_stop():
resolved = theme_tokens.resolve_theme("terracotta", "tasks", None)
assert resolved["gradient"] is True
assert resolved["accent_hex"] == "#a84b2a"
assert resolved["accent_hex_dark"] != resolved["accent_hex"]
def test_font_path_covers_every_family_and_style_combination():
for family in theme_tokens.FONT_FAMILIES:
for bold in (False, True):
for italic in (False, True):
assert theme_tokens.font_path(family, bold, italic).exists()
def test_font_path_falls_back_to_default_family_for_unknown_name():
assert theme_tokens.font_path("not-a-real-family", False, False) == \
theme_tokens.font_path(theme_tokens.DEFAULT_FONT_FAMILY, False, False)
+32
View File
@@ -208,3 +208,35 @@ def test_render_modern_month_falls_back_to_agenda_below_small_tier(db_session, m
img = widgets.calendar.render(db_session, frame, widget, 300, 192) img = widgets.calendar.render(db_session, frame, widget, 300, 192)
assert img.size == (300, 192) assert img.size == (300, 192)
def test_render_modern_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
"""Same spy-on-resolve_theme approach as test_widgets_weather.py's
equivalent test -- confirms calendar.py's render() passes frame.theme
into calendar_html_render.build (proving the widget-level threading,
not re-testing ordered_dither_regions itself, which
test_html_render.py already covers)."""
from app import calendar_html_render, theme_tokens
calls = []
real_resolve = theme_tokens.resolve_theme
def _spy(theme_name, widget_kind, palette_rgb):
calls.append((theme_name, widget_kind))
return real_resolve(theme_name, widget_kind, palette_rgb)
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
monkeypatch.setattr(calendar_html_render, "theme_tokens", theme_tokens)
frame, widget = _make_widget(db_session, view="agenda", render_style="modern")
frame.theme = "moss"
# calendar.py's render() takes widget_locked's write path (it resets
# browse_offset on a normal wake), which commits and would otherwise
# expire-and-reload frame from the DB, discarding this uncommitted
# attribute change.
db_session.commit()
_stub_fetches(monkeypatch)
_stub_render_html_to_image(monkeypatch)
widgets.calendar.render(db_session, frame, widget, 380, 300)
assert ("moss", "calendar") in calls
+30
View File
@@ -153,3 +153,33 @@ def test_render_modern_style(db_session, monkeypatch):
img = widgets.tasks.render(db_session, frame, widget, 300, 200) img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200) assert img.size == (300, 200)
assert img.mode == "RGB" assert img.mode == "RGB"
def test_render_modern_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
"""Same spy-on-resolve_theme approach as test_widgets_weather.py's
equivalent test -- confirms tasks.py's render() passes frame.theme
into html_render.build_tasks (proving the widget-level threading, not
re-testing ordered_dither_regions itself, which test_html_render.py
already covers)."""
from PIL import Image
from app import html_render, theme_tokens
calls = []
real_resolve = theme_tokens.resolve_theme
def _spy(theme_name, widget_kind, palette_rgb):
calls.append((theme_name, widget_kind))
return real_resolve(theme_name, widget_kind, palette_rgb)
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
monkeypatch.setattr(html_render, "theme_tokens", theme_tokens)
monkeypatch.setattr(html_render, "render_html_to_image",
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
frame, widget = _make_widget(db_session, render_style="modern")
frame.theme = "slate"
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
widgets.tasks.render(db_session, frame, widget, 300, 200)
assert ("slate", "tasks") in calls
+31
View File
@@ -187,3 +187,34 @@ def test_render_style_default_is_classic(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current") frame, widget = _make_widget(db_session, mode="current")
cfg = db_session.get(WeatherWidgetConfig, widget.id) cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.render_style == "classic" assert cfg.render_style == "classic"
def test_render_modern_daily_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
"""widgets/weather.py's render() must pass frame.theme all the way
into html_render.build_daily's theme resolution -- spies on
theme_tokens.resolve_theme (still delegating to the real
implementation) rather than diffing final pixels, since the stubbed
render_html_to_image below never actually executes the template's CSS
(that's the whole point of stubbing out Chromium), so a theme's
accent color has nothing to visibly change in the fake screenshot."""
from app import theme_tokens
calls = []
real_resolve = theme_tokens.resolve_theme
def _spy(theme_name, widget_kind, palette_rgb):
calls.append((theme_name, widget_kind))
return real_resolve(theme_name, widget_kind, palette_rgb)
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
monkeypatch.setattr(html_render, "theme_tokens", theme_tokens)
daily = {"2026-07-31": {"high": 75, "low": 55, "category": "clear"}}
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
_stub_render_html_to_image(monkeypatch)
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", render_style="modern")
frame.theme = "terracotta"
widgets.weather.render(db_session, frame, widget, 200, 160)
assert ("terracotta", "weather") in calls