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.
572 lines
27 KiB
Python
572 lines
27 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": "⛈️",
|
||
}
|
||
|
||
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
|
||
<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 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). 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))
|
||
if not entry:
|
||
return img
|
||
|
||
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||
icon_size = max(28, min(target_w, target_h) // 3)
|
||
template = _jinja_env.get_template("weather_current.html.jinja")
|
||
html = template.render(
|
||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
|
||
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,
|
||
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
|
||
label_size=max(12, icon_size // 3),
|
||
)
|
||
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). 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))
|
||
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"
|
||
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
|
||
col_w = max(1, target_w // len(days))
|
||
icon_size = max(16, min(col_w // 2, 36))
|
||
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, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||
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),
|
||
unit_suffix=unit_suffix,
|
||
)
|
||
rendered = render_html_to_image(html, target_w, target_h)
|
||
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")
|
||
|
||
|
||
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) -> 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 _quantize, _png_bytes, logical_render_size
|
||
|
||
target_w, target_h = logical_render_size(orientation)
|
||
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(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, 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/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
|
||
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)
|
||
|
||
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, gutter=panel_style.GUTTER, radius=theme["radius"],
|
||
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"],
|
||
)
|
||
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) -> 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). 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
|
||
|
||
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
|
||
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=theme["radius"], shadow=theme["shadow"],
|
||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||
title=title, header_h=header_h, title_size=max(14, header_h - 12),
|
||
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,
|
||
)
|
||
rendered = render_html_to_image(html, target_w, target_h)
|
||
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) ---------------------
|
||
|
||
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)
|