Files
tfaour 474b92a282
Build and push server image / test (push) Successful in 45s
Firmware build check / build-check (push) Successful in 2m50s
Build and push server image / build-and-push (push) Successful in 4m36s
Build and push server image / deploy (push) Failing after 1m34s
Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
Server: Frame.panel_type (new column + migration) is auto-derived from
the device's reported board (X-Frame-Board), never user-set -- the
panel is a property of the hardware, not a picker in the UI.
image_pipeline's packing/render pipeline is parameterized by panel
geometry instead of hardcoded 800x480 globals, with the real confirmed
13.3in geometry (1600x1200) registered alongside the original 7.3in
panel. Existing 7.3in frames are unaffected (column default + board
mapping both resolve to the original panel).

Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/
xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO
module -- "xiao" alone stopped disambiguating hardware. The server
keeps accepting the legacy bare names indefinitely for already-flashed
devices.

Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real
chip-target change, not just a same-chip Kconfig variant like xiao) and
a new epd13in3e driver component skeleton. The actual panel init/LUT/
refresh register sequence isn't ported from vendor demo code yet (none
was available), so that component deliberately fails to compile
(#error) rather than risk sending unverified register values to real
hardware -- devkit/xiao are unaffected and build identically to before.
CI's ee02 build step is continue-on-error for the same reason.
2026-08-04 20:08:22 +00:00

668 lines
32 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (all four view modes, 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:
1. A **persistent** background browser process. Widget rendering already
happens concurrently across a fresh `ThreadPoolExecutor` per frame
request (routers/device.py's _render_widgets) -- Playwright's sync
API is thread-affine (an object must be used from the thread that
created it), so a single browser object can't be handed across those
ad-hoc worker threads, and relaunching a full Chromium process on
every widget render would be real, avoidable latency. Fix: one
background thread runs its own persistent asyncio event loop hosting
one long-lived `Browser`, lazily started on first use (see start()) --
not eagerly at server startup, so a deployment that never enables the
weather widget's "modern" style never launches Chromium at all and
never needs Playwright's browser binaries installed. main.py's
lifespan only wires up the *shutdown* half (stop()), so a clean
server restart doesn't leave an orphaned Chromium process behind if
this was ever actually used. render_html_to_image() is a plain sync
function any worker thread can call, bridging in via
`asyncio.run_coroutine_threadsafe` (the standard safe cross-thread
entry point into a *running* loop on another thread).
2. **Per-region ordered (Bayer) dithering against the palette**, done
here rather than in the shared image_pipeline.py pipeline.
render_panel's whole-canvas single Floyd-Steinberg pass exists
because Floyd-Steinberg's error diffusion can't be split across
independently-quantized regions without a visible seam at the
boundary -- but that reasoning doesn't apply to ordered dithering,
which has no cross-pixel error term (each pixel's dither decision
only depends on its own position + color). So this module dithers its
own rendered widget to *already-exact* palette colors before
returning it; the later shared Floyd-Steinberg pass sees zero
quantization error there and leaves it untouched -- the same
"pre-commit to exact palette colors" trick image_pipeline.draw_text
and the hand-drawn weather icons already rely on, just reached a
different way. Floyd-Steinberg keeps working exactly as before for
photos and every other (classic-rendered) widget region.
"""
from __future__ import annotations
import asyncio
import io
import threading
from datetime import date
from pathlib import Path
import numpy as np
from jinja2 import Environment, FileSystemLoader, select_autoescape
from PIL import Image
from . import panel_style, theme_tokens
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"
_jinja_env = Environment(
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
autoescape=select_autoescape(["html", "jinja"]),
)
CATEGORY_EMOJI = {
"clear": "☀️",
"partly_cloudy": "⛅",
"cloudy": "☁️",
"fog": "\U0001f32b",
"rain": "\U0001f327",
"snow": "❄️",
"thunderstorm": "⛈️",
}
# Spelled-out condition word for the "bold minimal" current-mode layout --
# classic's build_current never needed one (icon + temp only), but the
# redesigned modern layout has room for a secondary line under the temp.
CATEGORY_LABEL = {
"clear": "Clear",
"partly_cloudy": "Partly cloudy",
"cloudy": "Cloudy",
"fog": "Fog",
"rain": "Rain",
"snow": "Snow",
"thunderstorm": "Thunderstorm",
}
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 of which literal hex a gradient starts from)."""
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
def _clamp(value: float, lo: float, hi: float) -> float:
"""Keeps a size/spacing value proportional to widget dimensions
(`value` is always some fraction of target_w/target_h) while still
guaranteeing a floor (stays legible on a 1-2 grid-cell widget) and a
ceiling (stops padding/type from just growing forever on a
near-full-panel widget -- see build_current's docstring)."""
return max(lo, min(hi, value))
# --- Persistent background browser -------------------------------------
_loop: asyncio.AbstractEventLoop | None = None
_loop_thread: threading.Thread | None = None
_browser = None
_playwright_cm = None
_start_lock = threading.Lock()
async def _launch_browser() -> None:
global _browser, _playwright_cm
from playwright.async_api import async_playwright
_playwright_cm = async_playwright()
playwright = await _playwright_cm.__aenter__()
_browser = await playwright.chromium.launch()
async def _close_browser() -> None:
global _browser, _playwright_cm
if _browser is not None:
await _browser.close()
_browser = None
if _playwright_cm is not None:
await _playwright_cm.__aexit__(None, None, None)
_playwright_cm = None
def start() -> None:
"""Launches the background event loop + persistent Chromium browser,
if not already running. Called lazily by render_html_to_image on
first use (not from main.py's lifespan -- see module docstring for
why this must stay opt-in) -- exposed directly too, for tests that
want to control startup explicitly. Idempotent -- a second call
while already started is a no-op."""
global _loop, _loop_thread
if _loop is not None:
return
ready = threading.Event()
def _run() -> None:
global _loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_loop = loop
ready.set()
loop.run_forever()
_loop_thread = threading.Thread(target=_run, daemon=True, name="html-render-loop")
_loop_thread.start()
ready.wait()
asyncio.run_coroutine_threadsafe(_launch_browser(), _loop).result()
def stop() -> None:
"""Closes the browser and stops the background loop -- called from
main.py's lifespan shutdown so a server restart never leaves an
orphaned Chromium process behind. No-op if start() was never called
(the common case: most deployments never enable "modern" style)."""
global _loop, _loop_thread
if _loop is None:
return
asyncio.run_coroutine_threadsafe(_close_browser(), _loop).result()
_loop.call_soon_threadsafe(_loop.stop)
_loop_thread.join(timeout=5)
_loop = None
_loop_thread = None
async def _screenshot(html: str, target_w: int, target_h: int) -> bytes:
page = await _browser.new_page(viewport={"width": target_w, "height": target_h}, device_scale_factor=1)
try:
await page.set_content(html, wait_until="networkidle")
return await page.screenshot()
finally:
await page.close()
def render_html_to_image(html: str, target_w: int, target_h: int) -> Image.Image:
"""Renders `html` (already sized to target_w x target_h via its own
<style>) through the persistent headless Chromium browser and
returns an RGB image of exactly that size. Safe to call from any
thread -- bridges into the dedicated background asyncio loop via
run_coroutine_threadsafe. Lazily calls start() on first use (see its
docstring) -- the first "modern" style render on a freshly-started
server pays Chromium's launch latency; every render after that reuses
the same persistent browser."""
if _loop is None:
with _start_lock:
if _loop is None:
start()
future = asyncio.run_coroutine_threadsafe(_screenshot(html, target_w, target_h), _loop)
png_bytes = future.result()
return Image.open(io.BytesIO(png_bytes)).convert("RGB")
# --- Ordered (Bayer 8x8) dithering against an arbitrary palette ---------
_BAYER8 = (
np.array(
[
[0, 32, 8, 40, 2, 34, 10, 42],
[48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38],
[60, 28, 52, 20, 62, 30, 54, 22],
[3, 35, 11, 43, 1, 33, 9, 41],
[51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37],
[63, 31, 55, 23, 61, 29, 53, 21],
],
dtype=np.float32,
)
/ 64.0
- 0.5
)
def ordered_dither(img: Image.Image, palette_rgb: list | None, amplitude: float = 48.0) -> Image.Image:
"""Bayer-ordered dither of `img` against `palette_rgb` (falls back to
DEFAULT_PALETTE_RGB) -- every output pixel is one of the palette's
exact colors, spatially patterned rather than error-diffused, so it's
safe to run per-region before compositing (see module docstring for
why that's not true of Floyd-Steinberg). `amplitude` is the Bayer
bias's full swing in 0-255 RGB units before nearest-palette-color
matching -- 48 was the value this render style was tuned against in
the exploratory spike behind this feature; not exposed as a per-frame
setting (unlike dither_strength) since there's only one consumer of
it today."""
palette = np.array(palette_rgb or DEFAULT_PALETTE_RGB, dtype=np.float32)
arr = np.asarray(img.convert("RGB"), dtype=np.float32)
h, w, _ = arr.shape
tile = np.tile(_BAYER8, (h // 8 + 1, w // 8 + 1))[:h, :w]
biased = np.clip(arr + tile[:, :, None] * amplitude, 0, 255)
diffs = biased[:, :, None, :] - palette[None, None, :, :]
dists = np.einsum("hwkc,hwkc->hwk", diffs, diffs)
idx = np.argmin(dists, axis=2)
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 ----------------------------------------------
def _day_label(day_date: date) -> str:
delta = (day_date - date.today()).days
if delta == 0:
return "Today"
if delta == 1:
return "Tomorrow"
return day_date.strftime("%a")
def _short_city(city_label: str) -> str:
"""geocode_city (see docs/widgets.md's Weather widget section) hands
back a full "City, Region, Country" string -- fine for classic's
build_current (just drawn as one line, however wide) but wrong for
the bold-minimal layout's small top-row label, where a 1-2 grid-
cell widget has no room for the whole thing. Every phone-homescreen
weather widget this style is drawing from shows just the city, so
that's what this keeps -- CSS `text-overflow: ellipsis` is still in
the template as a safety net for a custom single-segment label
that's itself too long, not as the primary truncation strategy."""
return city_label.split(",")[0].strip()
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_current --
same call signature, so app/widgets/weather.py can dispatch to
either interchangeably. Returns an already-palette-exact RGB image
(see ordered_dither).
"Bold minimal" layout: the temperature itself is the graphic --
city label + icon in a top row, the temp (dominant) and spelled-out
condition anchored to the bottom, no card/border/shadow at all. This
is a deliberate departure from every other modern-style widget's
card-on-white-canvas chrome (see docs/widgets.md) -- there's nothing
for a "card" to visually separate from here, so `theme["radius"]`/
`theme["shadow"]` have no effect on this template; still theme-aware
for font_family only, same as before. No header/accent region either
(see ordered_dither_regions' docstring) -- a themed accent has
nothing to attach to in a chrome-free layout.
Every size below is a fraction of `base` (the widget's shorter side),
clamped to a floor/ceiling rather than fixed -- so a 1-grid-cell
widget doesn't get comically oversized padding relative to its
content, and a near-full-panel widget doesn't get comically large
padding relative to *its* content either. Floors/ceilings are tuned
by eye against real widget sizes, not derived from anything."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
if not entry:
return img
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
base = min(target_w, target_h)
pad = _clamp(base * 0.09, 10, 26)
icon_size = _clamp(base * 0.20, 22, 60)
temp_size = _clamp(base * 0.46, 30, 150)
deg_size = _clamp(temp_size * 0.28, 12, 40)
cond_size = _clamp(base * 0.075, 11, 20)
city_size = _clamp(base * 0.06, 10, 15)
template = _jinja_env.get_template("weather_current.html.jinja")
html = template.render(
w=target_w, h=target_h, pad=round(pad),
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
emoji=CATEGORY_EMOJI.get(entry["category"], ""),
condition=CATEGORY_LABEL.get(entry["category"], ""),
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=_short_city(city_label),
icon_size=round(icon_size), temp_size=round(temp_size), deg_size=round(deg_size),
cond_size=round(cond_size), city_size=round(city_size),
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
call signature. Returns an already-palette-exact RGB image (see
ordered_dither).
"Bold minimal" layout, matching build_current: no card/border/
shadow, a row of day columns each carrying its own high (dominant)
/ low (muted) temp the same way build_current makes the current
temp dominant. The old full-width gradient banner is gone --
city_label, when set, is a slim accent-colored rule (not a block)
with the city name understated beneath it, so there's still
somewhere for a theme's accent hue to show up (dithered richer via
ordered_dither_regions, same mechanism as before) without dragging
back the "card with a colored header" chrome this redesign is
moving away from. `theme["radius"]`/`theme["shadow"]` are unused
here for the same reason as build_current -- no card for them to
apply to."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
days = list(daily.items())
if not days:
return img
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
base = min(target_w, target_h)
pad = round(_clamp(base * 0.08, 10, 22))
col_w = max(1, (target_w - pad * 2) // len(days))
col_gap = round(_clamp(col_w * 0.12, 4, 16))
icon_size = round(_clamp(col_w * 0.30, 16, 32))
day_label_size = round(_clamp(col_w * 0.15, 10, 14))
high_size = round(_clamp(col_w * 0.32, 16, 32))
low_size = round(max(9, high_size * 0.55))
city_size = round(_clamp(base * 0.055, 10, 14))
accent_h = round(_clamp(base * 0.025, 4, 8))
day_entries = [
{
"label": _day_label(date.fromisoformat(day_str)),
"emoji": CATEGORY_EMOJI.get(d["category"], ""),
"high": round(d["high"]),
"low": round(d["low"]),
}
for day_str, d in days
]
template = _jinja_env.get_template("weather_daily.html.jinja")
html = template.render(
w=target_w, h=target_h, pad=pad, col_gap=col_gap,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
city_label=_short_city(city_label), city_size=city_size, accent_h=accent_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
days=day_entries, icon_size=icon_size, day_label_size=day_label_size,
high_size=high_size, low_size=low_size, unit_suffix=unit_suffix,
)
rendered = render_html_to_image(html, target_w, target_h)
if not city_label:
return ordered_dither(rendered, palette_rgb)
accent_rect = (pad, pad, target_w - pad, pad + accent_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
SUPPORTED_MODES = ("current", "daily")
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""Dispatches to build_current/build_daily -- mirrors weather_render.
build()'s signature (minus interval_hours, which no modern-style mode
uses) so app/widgets/weather.py and the weather preview endpoint can
call either module identically. Only call this for mode in
SUPPORTED_MODES -- callers are expected to have already fallen back to
weather_render.build() for hourly/multi_city (see weather.py)."""
if mode == "current":
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, theme_name)
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", city_label: str = "",
theme_name: str | None = None, panel_w: int | None = None,
panel_h: int | None = None) -> bytes:
"""Modern-style analogue of weather_render.render_weather_preview_png
-- same browser-viewable-PNG convention every other widget's preview
endpoint uses. build()'s output is already palette-exact (see
ordered_dither), so the final _quantize pass here is a no-op on it,
same reasoning as the module docstring's compositing story."""
from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _quantize, _png_bytes, logical_render_size
target_w, target_h = logical_render_size(orientation, panel_w or EPD_WIDTH, panel_h or EPD_HEIGHT)
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, theme_name)
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(14, int(base * 0.15 * scale))
pct_size = max(20, int(base * 0.42 * scale))
line_size = max(9, int(base * 0.085 * scale))
gap = max(4, int(base * 0.035 * scale))
total = icon_h + gap + pct_size + num_lines * (line_size + gap)
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "gap": gap, "total": total}
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
palette_rgb: list | None = None, theme_name: str | 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). Theme-aware for font 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.
Bold-minimal: no card (theme["radius"] unused, same carve-out as
weather's build_current -- see docs/widgets.md); the percent is the
hero value anchored toward the bottom, same treatment build_current
gives the temperature, with the icon small and secondary above it
instead of both competing at the same size like the old centered
layout did.
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."""
pad = round(_clamp(min(target_w, target_h) * 0.09, 10, 26))
avail_h = target_h - pad * 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)
theme = theme_tokens.resolve_theme(theme_name, "battery", palette_rgb)
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, pad=pad,
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,
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"], line_gap=sizes["gap"],
)
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,
theme_name: str | 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).
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 . 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", theme_name: str | None = None, font_scale: float = 1.0) -> 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 -- and a
theme's accent never touches those per-owner chip colors (identity-
coding, not style) or the done-checkbox fill (a completion state
signal, not a style choice -- it happens to reuse the accent color,
but that's incidental, same as before this redesign).
Bold-minimal: no card (theme["shadow"]/["radius"] unused, same
carve-out as calendar's redesigned views -- see docs/widgets.md).
The old gradient header banner is now a slim accent rule + plain
bold title, matching every calendar view's day-header language --
only the rule dithers at the theme's richer accent_amplitude, not
the title text sitting on it. Returns an already-palette-exact RGB
image (see ordered_dither)."""
from .calendar_render import _event_colors, _fmt_task_due
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
base = min(target_w, target_h)
accent_h = round(_clamp(base * 0.025, 3, 6))
title_size = panel_style.scaled_size(max(14, base // 12), font_scale)
body_size = panel_style.scaled_size(max(11, base // 20), font_scale)
row_h = body_size + 14
box_size = max(10, body_size - 4)
header_h = accent_h + 6 + title_size
avail_h = target_h - panel_style.GUTTER * 2 - header_h - 8
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,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
title=title, header_h=header_h, accent_h=accent_h, title_size=title_size,
accent_start=theme["accent_hex"],
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)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
# --- Static image / whiteboard "modern" style (shared) ---------------------
def build_framed_image(composed: Image.Image, target_w: int, target_h: int,
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
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). Theme-aware
for radius/shadow only -- no text/header content to accent or font.
Returns an already-palette-exact RGB image (see ordered_dither)."""
import base64
theme = theme_tokens.resolve_theme(theme_name, widget_kind, palette_rgb)
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=theme["radius"], shadow=theme["shadow"],
image_b64=image_b64,
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)