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.
668 lines
32 KiB
Python
668 lines
32 KiB
Python
"""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)
|