Roll out "modern" HTML/CSS render style to every widget except photos
Build and push server image / test (push) Successful in 43s
Build and push server image / build-and-push (push) Successful in 3m51s
Build and push server image / deploy (push) Failing after 1m57s

Extends weather's experimental Chromium+Jinja2 render style to battery,
text, tasks, static image, whiteboard, and calendar (all four view
modes -- agenda/today_tomorrow/week/month), and gives the photos widget
its own genuinely independent palette + dithering strength.

Photos: Frame.photo_palette_rgb/photo_dither_strength (mirroring the
existing palette_rgb/dither_strength), with a second "Photos
configuration" card in Advanced Configuration. widgets/photos.py's
render() quantizes itself against these before returning -- no
render_panel changes needed, since photos is the only widget that
genuinely needs a different reference palette and can carry that
itself, the same way modern-style widgets already self-dither via
ordered_dither.

Battery/text/tasks/static image/whiteboard: same render_style pattern
weather established (render_style column, html_render.py build
function, Jinja2 template, dialog toggle). Static image/whiteboard get
their first-ever visual chrome (a rounded-corner shadowed card,
shared framed_image.html.jinja) since classic draws them with zero
frame at all. Fixed the same "preview endpoint bypasses render_style"
bug weather originally shipped with, for tasks/static/whiteboard/
calendar's preview endpoints.

Calendar: own module (app/calendar_html_render.py, mirroring
calendar_render.py's separation from the simpler widgets) covering all
four view modes, not just agenda -- reuses calendar_render's own
private helpers so event colors/times/weather/month-grid math match
classic exactly. Found and fixed two real cross-day layout bugs along
the way: a per-day header height that varied based on whether that
specific day had a weather entry (misaligning where every other day's
event rows started across the week/month grid), and regular-weight
small text being fragile under Bayer ordered dithering (out-of-month
day numbers degraded into unrecognizable speckle) -- fixed by using
bold everywhere and de-emphasizing via size instead of weight/gray,
since gray text has the same dithering fragility this project's PIL
renderers already avoid for exactly this reason.

Migrations 32-38 (Frame's two new columns, then one render_style column
per widget config table). 452 tests passing, including new dispatch/
migration coverage per widget type and a dedicated photos test proving
photo_palette_rgb produces genuinely independent quantization from the
frame's main palette_rgb.
This commit is contained in:
2026-07-31 03:52:19 +00:00
parent c6dad191fb
commit e331f5e5a1
47 changed files with 1662 additions and 116 deletions
+206 -7
View File
@@ -1,9 +1,19 @@
"""Experimental "modern" weather widget render style: Jinja2 + a
persistent headless Chromium browser (Playwright) instead of the hand-
drawn PIL primitives in weather_render.py -- see docs/widgets.md and the
`html-widget-render` branch's PR description for the design rationale
(gradients/shadows/soft shading that PIL can't easily do, at the cost of
a real browser-process dependency).
"""Experimental "modern" render style, offered as an opt-in alternative
to several widget types' hand-drawn PIL primitives: Jinja2 + a
persistent headless Chromium browser (Playwright) -- see docs/widgets.md
for the design rationale (gradients/shadows/soft shading that PIL can't
easily do, at the cost of a real browser-process dependency). Everything
in this module is shared infrastructure (the persistent browser, ordered
dithering) plus one `build_*` function per widget type that has a
modern-style builder -- battery/text/tasks/static image/whiteboard live
here directly (mirroring how those widget types are themselves "inlined"
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
exception, kept separate the same reason calendar_render.py itself is
its own 800+ line file rather than joining battery/text/tasks inline.
Not offered for the photos widget -- a real photograph isn't a
synthesized dashboard card, and photos has its own separate palette/
dithering concern instead (see widgets/photos.py).
Two things this module owns that nothing else in the codebase needed
before:
@@ -57,7 +67,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
from PIL import Image
from . import panel_style
from .image_pipeline import DEFAULT_PALETTE_RGB
from .image_pipeline import DEFAULT_PALETTE_RGB, hex_to_rgb
_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html"
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
@@ -87,6 +97,17 @@ ACCENT_START = "#1c4fd6"
ACCENT_END = "#6fa8ff"
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:
"""A darker shade of `rgb` for a CSS gradient's second stop -- purely
decorative (ordered_dither commits everything to exact palette colors
regardless), same reasoning ACCENT_START/END's own comment gives."""
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
# --- Persistent background browser -------------------------------------
_loop: asyncio.AbstractEventLoop | None = None
@@ -325,3 +346,181 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized)
# --- Battery "modern" style ------------------------------------------------
def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict:
base = min(target_w, target_h)
icon_h = max(16, int(base // 3 * scale))
pct_size = max(14, int(base // 3 * scale))
line_size = max(9, int(base // 9 * scale))
gap = 8
total = icon_h + gap + pct_size + num_lines * (line_size + gap)
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "total": total}
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
palette_rgb: list | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of widgets/battery.py's classic PIL
drawing -- same icon+percent+caption-lines shape, `lines` already
resolved by the caller (widgets/battery.py's _lines_for(), shared
with the classic path so the estimate/age formatting only lives in
one place). Returns an already-palette-exact RGB image (see
ordered_dither).
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
the whole stack actually fits the available height -- the classic
PIL path solves the same "icon + percent + 0-2 lines in a fixed box"
problem by truncating lines that don't fit; scaling down instead
keeps every resolved line visible, which reads better for a widget
that only ever has at most 2 short caption lines to begin with."""
avail_h = target_h - panel_style.GUTTER * 2
num_lines = len(lines)
scale = 1.0
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
while sizes["total"] > avail_h and scale > 0.3:
scale -= 0.05
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
# Extreme case (a 1x1-grid-cell-sized widget in "detailed" mode):
# scale bottomed out and it still doesn't fit -- drop the least
# important line rather than render overlapping text, same
# graceful-degradation idiom the classic PIL path's own
# `if y + small_font_size > ...: break` truncation already uses.
while sizes["total"] > avail_h and lines:
lines = lines[:-1]
num_lines = len(lines)
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
fill_color = panel_style.battery_fill_color(percent, palette_rgb)
icon_h = sizes["icon_h"]
icon_w = int(icon_h * 1.8)
stroke = max(2, icon_h // 12)
nub_w = max(3, icon_w // 10)
template = _jinja_env.get_template("battery.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
font_dir=str(_FONT_DIR), percent=percent, lines=lines,
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_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color),
nub_w=nub_w, nub_h=icon_h // 2, nub_radius=max(1, nub_w // 3),
pct_size=sizes["pct_size"], line_size=sizes["line_size"],
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
# --- Text "modern" style ---------------------------------------------------
def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of widgets/text.py's classic PIL
drawing. Reuses widgets/text.py's own `_fit()` for the one piece of
logic CSS has no native equivalent for (shrink-to-fit sizing) --
`_fit` measures against the exact same vendored font files via PIL,
so the resolved size is a real fit decision, not a guess -- but lets
the browser do its own text wrapping/line-breaking at that size
(paragraphs/runs passed through directly as HTML) rather than
replicating `_fit`'s own word-wrapped line list; the two wrapping
algorithms can disagree on exact break points, an acceptable
approximation since this style only needs to look good and fit
reasonably, not be pixel-identical to classic. Returns an already-
palette-exact RGB image (see ordered_dither)."""
from PIL import ImageDraw
from . import widgets # local import: heavy-ish, and only "modern" text needs it
text_widget = widgets.text
bg_rgb = (hex_to_rgb(cfg.background_color) if cfg.background_color else None) or (255, 255, 255)
family = cfg.font_family if cfg.font_family in text_widget.FONT_FAMILIES else text_widget.DEFAULT_FONT_FAMILY
paragraphs = cfg.content or []
margin = text_widget.MARGIN
max_width = max(10, target_w - 2 * margin)
max_height = max(10, target_h - 2 * margin)
measure_img = Image.new("RGB", (1, 1))
draw = ImageDraw.Draw(measure_img)
size, _lines = text_widget._fit(draw, paragraphs, family, cfg.font_size, max_width, max_height)
files = text_widget._FONT_FILES.get(family) or text_widget._FONT_FILES[text_widget.DEFAULT_FONT_FAMILY]
align = cfg.align if cfg.align in ("left", "center", "right") else "left"
template = _jinja_env.get_template("text.html.jinja")
html = template.render(
w=target_w, h=target_h, margin=margin, bg_color=_rgb_to_hex(bg_rgb),
size=size, line_height=text_widget.LINE_HEIGHT_FACTOR, align=align,
font_regular=str(_FONT_DIR / files[(False, False)]), font_bold=str(_FONT_DIR / files[(True, False)]),
font_italic=str(_FONT_DIR / files[(False, True)]), font_bold_italic=str(_FONT_DIR / files[(True, True)]),
paragraphs=paragraphs,
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
# --- Tasks "modern" style ---------------------------------------------------
def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
title: str = "Tasks") -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_tasks --
same header+checklist shape. Reuses calendar_render's own
_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
matches classic style exactly; only the drawing differs. Returns an
already-palette-exact RGB image (see ordered_dither)."""
from .calendar_render import _event_colors, _fmt_task_due
header_h = max(28, min(target_w, target_h) // 8)
body_size = max(11, min(target_w, target_h) // 20)
row_h = body_size + 14
box_size = max(10, body_size - 4)
avail_h = target_h - header_h - 16
max_rows = max(0, avail_h // row_h)
owners_seen: list[str] = []
rows = []
for task in tasks[:max_rows]:
colors = _event_colors(task, owners_seen, palette_rgb)
done = task.get("completed_at") is not None
due = None if done else (_fmt_task_due(task.get("due")) or None)
rows.append({
"colors": [_rgb_to_hex(c) for c in colors],
"done": done,
"due": due,
"summary": task["summary"],
})
more_count = max(0, len(tasks) - max_rows)
template = _jinja_env.get_template("tasks.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
font_dir=str(_FONT_DIR), title=title, header_h=header_h, title_size=max(14, header_h - 12),
accent_start=_rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_TASKS)),
accent_end=_rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_TASKS)),
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)
return ordered_dither(rendered, palette_rgb)
# --- Static image / whiteboard "modern" style (shared) ---------------------
def build_framed_image(composed: Image.Image, target_w: int, target_h: int,
palette_rgb: list | None = None) -> Image.Image:
"""Wraps an already-composed image (static_image.py/whiteboard.py's
own compose_into() output, exactly target_w x target_h, already
cropped/fit per that widget's own display_mode) in a rounded-corner,
shadowed card -- the first visual chrome either widget type has ever
had (both currently draw with zero chrome of their own). Returns an
already-palette-exact RGB image (see ordered_dither)."""
import base64
buf = io.BytesIO()
composed.convert("RGB").save(buf, format="PNG")
image_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
template = _jinja_env.get_template("framed_image.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
image_b64=image_b64,
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)