"""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": "⛈️", } 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)) # --- 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