From e331f5e5a146c6ebe371ebc9a1cb54f4b3fc4d97 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Fri, 31 Jul 2026 03:52:19 +0000 Subject: [PATCH] Roll out "modern" HTML/CSS render style to every widget except photos Extends weather's experimental Chromium+Jinja2 render style to battery, text, tasks, static image, whiteboard, and calendar (all four view modes -- agenda/today_tomorrow/week/month), and gives the photos widget its own genuinely independent palette + dithering strength. Photos: Frame.photo_palette_rgb/photo_dither_strength (mirroring the existing palette_rgb/dither_strength), with a second "Photos configuration" card in Advanced Configuration. widgets/photos.py's render() quantizes itself against these before returning -- no render_panel changes needed, since photos is the only widget that genuinely needs a different reference palette and can carry that itself, the same way modern-style widgets already self-dither via ordered_dither. Battery/text/tasks/static image/whiteboard: same render_style pattern weather established (render_style column, html_render.py build function, Jinja2 template, dialog toggle). Static image/whiteboard get their first-ever visual chrome (a rounded-corner shadowed card, shared framed_image.html.jinja) since classic draws them with zero frame at all. Fixed the same "preview endpoint bypasses render_style" bug weather originally shipped with, for tasks/static/whiteboard/ calendar's preview endpoints. Calendar: own module (app/calendar_html_render.py, mirroring calendar_render.py's separation from the simpler widgets) covering all four view modes, not just agenda -- reuses calendar_render's own private helpers so event colors/times/weather/month-grid math match classic exactly. Found and fixed two real cross-day layout bugs along the way: a per-day header height that varied based on whether that specific day had a weather entry (misaligning where every other day's event rows started across the week/month grid), and regular-weight small text being fragile under Bayer ordered dithering (out-of-month day numbers degraded into unrecognizable speckle) -- fixed by using bold everywhere and de-emphasizing via size instead of weight/gray, since gray text has the same dithering fragility this project's PIL renderers already avoid for exactly this reason. Migrations 32-38 (Frame's two new columns, then one render_style column per widget config table). 452 tests passing, including new dispatch/ migration coverage per widget type and a dedicated photos test proving photo_palette_rgb produces genuinely independent quantization from the frame's main palette_rgb. --- docs/widgets.md | 90 ++++-- server/app/calendar_html_render.py | 291 ++++++++++++++++++ server/app/html_render.py | 213 ++++++++++++- server/app/migration.py | 73 +++++ server/app/models.py | 30 +- server/app/routers/api_frames.py | 14 + server/app/routers/api_layouts.py | 12 +- server/app/routers/api_widgets.py | 112 +++++-- server/app/routers/frame_pages.py | 1 + server/app/static/frame_config.js | 81 +++-- server/app/static/widget_dialog_battery.js | 1 + server/app/static/widget_dialog_calendar.js | 1 + server/app/static/widget_dialog_static.js | 1 + server/app/static/widget_dialog_tasks.js | 1 + server/app/static/widget_dialog_text.js | 1 + server/app/static/widget_dialog_whiteboard.js | 19 ++ .../app/templates/_widget_dialog_battery.html | 6 + .../templates/_widget_dialog_calendar.html | 6 + .../app/templates/_widget_dialog_static.html | 6 + .../app/templates/_widget_dialog_tasks.html | 6 + server/app/templates/_widget_dialog_text.html | 6 + .../templates/_widget_dialog_whiteboard.html | 13 + server/app/templates/frame_config.html | 50 ++- .../_calendar_day_section.html.jinja | 32 ++ .../templates/widget_html/battery.html.jinja | 53 ++++ .../widget_html/calendar_agenda.html.jinja | 36 +++ .../widget_html/calendar_month.html.jinja | 65 ++++ .../calendar_today_tomorrow.html.jinja | 39 +++ .../calendar_week_horizontal.html.jinja | 60 ++++ .../widget_html/framed_image.html.jinja | 27 ++ .../templates/widget_html/tasks.html.jinja | 62 ++++ .../app/templates/widget_html/text.html.jinja | 35 +++ server/app/widgets/battery.py | 51 ++- server/app/widgets/calendar.py | 14 + server/app/widgets/photos.py | 23 +- server/app/widgets/static_image.py | 10 +- server/app/widgets/tasks.py | 10 +- server/app/widgets/text.py | 24 +- server/app/widgets/whiteboard.py | 13 +- server/tests/test_migrations.py | 10 + server/tests/test_widgets_battery.py | 24 +- server/tests/test_widgets_calendar.py | 45 +++ server/tests/test_widgets_photos.py | 29 ++ server/tests/test_widgets_static.py | 15 + server/tests/test_widgets_tasks.py | 24 ++ server/tests/test_widgets_text.py | 21 ++ server/tests/test_widgets_whiteboard.py | 22 +- 47 files changed, 1662 insertions(+), 116 deletions(-) create mode 100644 server/app/calendar_html_render.py create mode 100644 server/app/templates/widget_html/_calendar_day_section.html.jinja create mode 100644 server/app/templates/widget_html/battery.html.jinja create mode 100644 server/app/templates/widget_html/calendar_agenda.html.jinja create mode 100644 server/app/templates/widget_html/calendar_month.html.jinja create mode 100644 server/app/templates/widget_html/calendar_today_tomorrow.html.jinja create mode 100644 server/app/templates/widget_html/calendar_week_horizontal.html.jinja create mode 100644 server/app/templates/widget_html/framed_image.html.jinja create mode 100644 server/app/templates/widget_html/tasks.html.jinja create mode 100644 server/app/templates/widget_html/text.html.jinja diff --git a/docs/widgets.md b/docs/widgets.md index 15939e1..9c48461 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -148,6 +148,70 @@ grid footprint, rather than continuously scaling constants tuned for a full ~800x480 canvas -- falls back to agenda view if a widget is too small for month view to stay legible. +### "Modern" render style (experimental) + +Every widget type except photos has a `render_style` column (`"classic"` +default | `"modern"`) that swaps its hand-drawn PIL primitives for an +HTML/CSS render: a Jinja2 template (`app/templates/widget_html/`) drawn +through a persistent headless-Chromium browser (`app/html_render.py`, +Playwright) instead of `ImageDraw` -- gradients, shadows, and soft icon +shading PIL can't easily do. Calendar's own modern-style builders (all +four view modes) live in `app/calendar_html_render.py` rather than +`html_render.py` itself, mirroring `calendar_render.py`'s own separation +from the simpler widget types. + +Every modern-style builder runs its own `ordered_dither` (Bayer/ordered, +not Floyd-Steinberg) before returning, committing the widget to exact +palette colors *before* compositing -- safe to mix with photo/other +classic-rendered widgets on the same frame without a Floyd-Steinberg +seam at the boundary, because ordered dithering has no cross-pixel error +term the way Floyd-Steinberg's diffusion does (see `html_render.py`'s +module docstring). No `Frame`-level dithering setting was needed to make +this work. + +Not offered for the **photos** widget -- a real photograph isn't a +synthesized dashboard card, and photos has a different concern instead: +its own independent palette/dithering strength (`Frame.photo_palette_rgb` +/ `photo_dither_strength`, a second "Photos configuration" card in +Advanced Configuration, separate from the main `palette_rgb`/ +`dither_strength` every other widget uses). `widgets/photos.py`'s +`render()` quantizes itself against these before returning, so a frame +can tune the rest of its widgets' look (e.g. a calibrated palette for +modern-style dashboard widgets) independently of what actually looks +best for real photographs, with no `render_panel` changes needed -- +see that module's own docstring for the one small, accepted edge case +(a border on a photos widget whose palette genuinely diverges from the +frame's main one). + +Playwright/Chromium is a real, heavyweight runtime dependency imported +lazily only when a widget actually uses modern style. Its browser binary +is fetched by `start.sh` at container startup rather than baked into the +image (see `server/Dockerfile`'s own comment) -- a single ~181MB +`chrome-headless-shell` binary can't be split across Docker layers the +way this project's pip/npm installs were, and confirmed-failed to push +to the registry as a build-time layer; cached on the `/data` volume +(`PLAYWRIGHT_BROWSERS_PATH`) so only the very first boot on a fresh +volume actually downloads it. Still real-panel-unverified -- treat every +"modern" style as experimental regardless of deploy status. + +Per-widget-type notes: + +- **weather**: `current`/`daily` modes only -- `hourly`/`multi_city` + always render classic regardless of this setting (see the Weather + widget section below). +- **calendar**: all four view modes (agenda/today_tomorrow/week/month) + have a modern builder -- the only widget type with full modern-style + coverage from the start, rather than a partial rollout like weather's. + Month view's "falls back to agenda below a size threshold" behavior + (`_month_view_fits`) is honored identically in both styles. +- **battery/text/tasks**: full coverage (both battery modes; text reuses + its own `_fit()` shrink-to-fit sizing logic, only the drawing differs). +- **static image/whiteboard**: modern style is the *first* visual chrome + either widget type has ever had (classic draws the image with zero + frame/card at all) -- a rounded-corner, shadowed card + (`framed_image.html.jinja`, shared between the two) wrapping the + already-composed image. + ## Button actions Each physical button (NEXT/BACK) runs the `(widget, action)` binding of @@ -270,29 +334,9 @@ modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like widget's whole content instead of a strip above an agenda day. **Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default -| `"modern"`, experimental): `current`/`daily` only -- `hourly`/ -`multi_city` always render classic regardless of this setting. `modern` -draws the widget as an HTML/CSS card (Jinja2 templates under -`app/templates/widget_html/`) through a persistent headless Chromium -browser (`app/html_render.py`, Playwright) instead of `app/weather_render. -py`'s hand-drawn PIL primitives -- gradients/shadows/soft icon shading -PIL can't easily do. Its own `ordered_dither` (Bayer/ordered, not Floyd- -Steinberg) commits the rendered widget to exact palette colors *before* -compositing, so it's safe to mix with photo/other classic-rendered -widgets on the same frame without a Floyd-Steinberg seam at the boundary -(see that module's docstring for why ordered dithering doesn't have this -problem and Floyd-Steinberg does) -- no `Frame`-level dithering setting -was needed. Playwright/Chromium is a real, heavyweight runtime dependency -imported lazily only when a weather widget actually uses this style. -Its browser binary is fetched by `start.sh` at container startup rather -than baked into the image (see `server/Dockerfile`'s own comment) -- -a single ~181MB `chrome-headless-shell` binary can't be split across -Docker layers the way this project's pip/npm installs were, and -confirmed-failed to push to the registry as a build-time layer; cached -on the `/data` volume (`PLAYWRIGHT_BROWSERS_PATH`) so only the very -first boot on a fresh volume actually downloads it. Still real-panel- -unverified (see this widget's own render_style rollout notes/PR) -- -treat "modern" style as experimental regardless of deploy status. +| `"modern"`, experimental) -- see "Modern render style" above; weather's +own modern coverage is `current`/`daily` only, `hourly`/`multi_city` +always render classic regardless of this setting. `current`/`hourly`/`daily` share one configured location (`city_label`/`city_latitude`/`city_longitude`, set via `POST .../ diff --git a/server/app/calendar_html_render.py b/server/app/calendar_html_render.py new file mode 100644 index 0000000..0a33a0a --- /dev/null +++ b/server/app/calendar_html_render.py @@ -0,0 +1,291 @@ +"""Calendar widget's "modern" render style -- all four view modes +(agenda/today_tomorrow/week/month), mirroring calendar_render.py's own +_build dispatch shape exactly so app/widgets/calendar.py and the +calendar preview endpoint can call either module identically. Kept in +its own module rather than joining app/html_render.py's other build_* +functions, mirroring calendar_render.py's own separation from the +simpler widgets (calendar is the one case where html_render.py growing +a 5th unrelated builder starts to hurt readability). + +Reuses calendar_render's own private helpers (_events_on_day/_event_ +colors/_event_start/_fmt_time/_weather_for_day/_month_view_fits/ +_add_months) so a modern-style view's event list/colors/times/weather/ +month-grid math match the classic renderer's data exactly -- only the +drawing differs, same relationship weather's build_current/build_daily +have with weather_render.py.""" + +from __future__ import annotations + +import calendar as calendar_module +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +from PIL import Image + +from . import html_render, panel_style +from .calendar_render import ( + MARGIN, + WEEKDAY_NAMES, + _add_months, + _event_colors, + _event_start, + _events_on_day, + _fmt_time, + _month_view_fits, + _weather_for_day, +) + + +def _weather_row(weather_cities, day, units) -> list[dict]: + entries = _weather_for_day(weather_cities, day) + return [ + {"emoji": html_render.CATEGORY_EMOJI.get(e["category"], ""), "high": round(e["high"]), "low": round(e["low"])} + for e in entries + ] + + +def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb, weather_cities, + weather_units: str, owners_seen: list[str], rows_avail_h: int, row_h: int) -> dict: + """One day's {header, weather_entries, rows, more_count} -- shared by + build_agenda/build_today_tomorrow/build_week's vertical layout, same + reuse relationship calendar_render._draw_agenda_day has with + _build_agenda/_build_today_tomorrow. `rows_avail_h` is the *rows* + area's own pixel budget only -- the caller has already reserved a + separate, uniform header_h (which is where weather actually renders, + see the day-header macro) for every section, so this function + doesn't need to account for weather space itself.""" + header = day.strftime("%A, %B ") + str(day.day) + weather_entries = _weather_row(weather_cities, day, weather_units) + max_rows = max(0, rows_avail_h // row_h) + + day_events = _events_on_day(events, day, tz) + rows = [] + for event in day_events[:max_rows]: + colors = _event_colors(event, owners_seen, palette_rgb) + time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz)) + rows.append({"colors": [html_render._rgb_to_hex(c) for c in colors], "time": time_str, + "summary": event["summary"]}) + return {"header": header, "weather_entries": weather_entries, "rows": rows, + "more_count": max(0, len(day_events) - max_rows)} + + +def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, + palette_rgb: list | None = None, weather_cities: list[dict] | None = None, + weather_units: str = "fahrenheit") -> Image.Image: + """HTML/CSS-rendered analogue of calendar_render._build_agenda.""" + day = datetime.now(tz).date() + timedelta(days=browse_offset) + title_size = max(14, min(target_w, target_h) // 12) + body_size = max(11, min(target_w, target_h) // 20) + weather_size = max(10, body_size - 2) + row_h = body_size + 14 + unit_suffix = "F" if weather_units == "fahrenheit" else "C" + + # Single day -- no cross-section alignment concern, so header_h can + # simply reflect whether THIS day actually has weather (unlike + # build_today_tomorrow/build_week's vertical layout, which must + # reserve the same header_h for every stacked section regardless). + has_weather = bool(_weather_row(weather_cities, day, weather_units)) + header_h = title_size + 24 + ((weather_size + 12) if has_weather else 0) + + owners_seen: list[str] = [] + data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen, + target_h - header_h - MARGIN, row_h) + + accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR)) + template = html_render._jinja_env.get_template("calendar_agenda.html.jinja") + html = template.render( + w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, + font_dir=str(html_render._FONT_DIR), header=data["header"], title_size=title_size, header_h=header_h, + accent_start=accent, accent_end=accent, weather_entries=data["weather_entries"], + weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"], + more_count=data["more_count"], row_h=row_h, body_size=body_size, + ) + rendered = html_render.render_html_to_image(html, target_w, target_h) + return html_render.ordered_dither(rendered, palette_rgb) + + +def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, + palette_rgb: list | None = None, weather_cities: list[dict] | None = None, + weather_units: str = "fahrenheit") -> Image.Image: + """HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow + -- two day-sections stacked (see _day_section_data).""" + start_day = datetime.now(tz).date() + timedelta(days=browse_offset) + section_h = target_h // 2 + title_size = max(13, section_h // 8) + body_size = max(10, min(target_w, target_h) // 26) + weather_size = max(9, body_size - 2) + row_h = body_size + 12 + unit_suffix = "F" if weather_units == "fahrenheit" else "C" + + day_dates = [start_day + timedelta(days=i) for i in range(2)] + # Uniform across both stacked sections regardless of which day(s) + # actually have weather -- see _day_section_data's own docstring for + # why a per-day header height misaligns where rows start. + any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates) + header_h = title_size + 16 + ((weather_size + 10) if any_weather else 0) + + owners_seen: list[str] = [] + days = [ + _day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen, + section_h - header_h, row_h) + for d in day_dates + ] + + accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR)) + template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja") + html = template.render( + w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, + font_dir=str(html_render._FONT_DIR), days=days, title_size=title_size, header_h=header_h, + accent_start=accent, accent_end=accent, weather_size=weather_size, unit_suffix=unit_suffix, + row_h=row_h, body_size=body_size, + ) + rendered = html_render.render_html_to_image(html, target_w, target_h) + return html_render.ordered_dither(rendered, palette_rgb) + + +def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, + week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None, + weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal", + start_offset: int = 0) -> Image.Image: + """HTML/CSS-rendered analogue of calendar_render._build_week -- both + the vertical (stacked day-sections, reusing build_today_tomorrow's + template with an arbitrary day count) and horizontal (side-by-side + columns) layouts.""" + today = datetime.now(tz).date() + if days == 7: + days_since_start = (today.weekday() - week_start) % 7 + week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset) + else: + week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset) + unit_suffix = "F" if weather_units == "fahrenheit" else "C" + accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR)) + owners_seen: list[str] = [] + + if layout == "vertical": + section_h = target_h // days + title_size = max(11, min(20, section_h // 6)) + body_size = max(9, min(target_w, target_h) // (18 + days)) + weather_size = max(8, body_size - 2) + row_h = body_size + 10 + day_dates = [week_first_day + timedelta(days=i) for i in range(days)] + # Uniform across all `days` stacked sections -- see + # _day_section_data's own docstring for why a per-day header + # height misaligns where rows start. + any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates) + header_h = title_size + 12 + ((weather_size + 8) if any_weather else 0) + day_sections = [ + _day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen, + section_h - header_h, row_h) + for d in day_dates + ] + template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja") + html = template.render( + w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, + font_dir=str(html_render._FONT_DIR), days=day_sections, title_size=title_size, header_h=header_h, + accent_start=accent, accent_end=accent, weather_size=weather_size, unit_suffix=unit_suffix, + row_h=row_h, body_size=body_size, + ) + rendered = html_render.render_html_to_image(html, target_w, target_h) + return html_render.ordered_dither(rendered, palette_rgb) + + header_size = max(10, min(16, (target_w // days) // 6)) + chip_size = max(9, header_size - 3) + weather_size = max(8, chip_size - 1) + col_w = max(1, (target_w - panel_style.GUTTER * 2) // days) + row_h = chip_size + 8 + # Reserve weather-line room in every column's header uniformly + # (whether or not THIS specific day has a cached forecast) -- a + # per-column height that depends on that day's own data would + # misalign where each column's event rows start across the week + # grid the moment any single day lacks a forecast entry. + header_h = header_size + 22 + (weather_size + 6 if weather_cities else 0) + max_rows = max(0, (target_h - panel_style.GUTTER * 2 - header_h) // row_h) + + cols = [] + for i in range(days): + day = week_first_day + timedelta(days=i) + label = day.strftime("%a %-d") if day != today else f"★ {day.strftime('%a %-d')}" + weather_entries = _weather_row(weather_cities, day, weather_units) + day_events = _events_on_day(events, day, tz) + rows = [] + for event in day_events[:max_rows]: + color = html_render._rgb_to_hex(_event_colors(event, owners_seen, palette_rgb)[0]) + summary = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}" + rows.append({"color": color, "summary": summary}) + cols.append({ + "label": label, "weather": weather_entries[0] if weather_entries else None, + "rows": rows, "more_count": max(0, len(day_events) - max_rows), + }) + + template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja") + html = template.render( + w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, + font_dir=str(html_render._FONT_DIR), cols=cols, header_size=header_size, chip_size=chip_size, + header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix, accent_start=accent, accent_end=accent, + ) + rendered = html_render.render_html_to_image(html, target_w, target_h) + return html_render.ordered_dither(rendered, palette_rgb) + + +def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, + week_start: int, palette_rgb: list | None = None) -> Image.Image: + """HTML/CSS-rendered analogue of calendar_render._build_month -- + density dots per day, not literal event text, same reasoning as the + classic renderer (real text at typical month-cell size is close to + unreadable on a 6-color dithered e-ink panel).""" + today = datetime.now(tz).date() + target_month = _add_months(date(today.year, today.month, 1), browse_offset) + weeks_dates = list( + calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month) + ) + day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])] + + header_size = max(11, min(16, target_h // 30)) + day_size = max(10, min(15, target_w // 55)) + dot_size = max(4, day_size // 2) + accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR)) + + owners_seen: list[str] = [] + weeks = [] + for week in weeks_dates: + row = [] + for day in week: + day_events = _events_on_day(events, day, tz) + dots = [html_render._rgb_to_hex(_event_colors(e, owners_seen, palette_rgb)[0]) for e in day_events[:4]] + row.append({ + "day_num": day.day, "in_month": day.month == target_month.month, + "is_today": day == today, "dots": dots, "more_count": max(0, len(day_events) - 4), + }) + weeks.append(row) + + template = html_render._jinja_env.get_template("calendar_month.html.jinja") + html = template.render( + w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, + font_dir=str(html_render._FONT_DIR), day_names=day_names, weeks=weeks, + header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=accent, + ) + rendered = html_render.render_html_to_image(html, target_w, target_h) + return html_render.ordered_dither(rendered, palette_rgb) + + +def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, + week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None, + weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal", + week_start_offset: int = 0) -> Image.Image: + """Dispatches to the right build_* -- mirrors calendar_render._build's + exact "month falls back to agenda when it doesn't fit" resolution, so + a narrow month-mode widget set to modern style still gets a sensible + modern view instead of erroring or silently reverting to classic.""" + effective_view = view + if view == "month" and not _month_view_fits(target_w, target_h): + effective_view = "agenda" + + if effective_view == "agenda": + return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, weather_units) + if effective_view == "today_tomorrow": + return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, + weather_units) + if effective_view == "week": + return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities, + weather_units, week_days, week_layout, week_start_offset) + return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb) diff --git a/server/app/html_render.py b/server/app/html_render.py index 7c5f9e3..2ebe189 100644 --- a/server/app/html_render.py +++ b/server/app/html_render.py @@ -1,9 +1,19 @@ -"""Experimental "modern" weather widget render style: Jinja2 + a -persistent headless Chromium browser (Playwright) instead of the hand- -drawn PIL primitives in weather_render.py -- see docs/widgets.md and the -`html-widget-render` branch's PR description for the design rationale -(gradients/shadows/soft shading that PIL can't easily do, at the cost of -a real browser-process dependency). +"""Experimental "modern" render style, offered as an opt-in alternative +to several widget types' hand-drawn PIL primitives: Jinja2 + a +persistent headless Chromium browser (Playwright) -- see docs/widgets.md +for the design rationale (gradients/shadows/soft shading that PIL can't +easily do, at the cost of a real browser-process dependency). Everything +in this module is shared infrastructure (the persistent browser, ordered +dithering) plus one `build_*` function per widget type that has a +modern-style builder -- battery/text/tasks/static image/whiteboard live +here directly (mirroring how those widget types are themselves "inlined" +in their own widget.py rather than getting a dedicated render module); +calendar's (agenda mode only, see calendar_html_render.py) is the one +exception, kept separate the same reason calendar_render.py itself is +its own 800+ line file rather than joining battery/text/tasks inline. +Not offered for the photos widget -- a real photograph isn't a +synthesized dashboard card, and photos has its own separate palette/ +dithering concern instead (see widgets/photos.py). Two things this module owns that nothing else in the codebase needed before: @@ -57,7 +67,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape from PIL import Image from . import panel_style -from .image_pipeline import DEFAULT_PALETTE_RGB +from .image_pipeline import DEFAULT_PALETTE_RGB, hex_to_rgb _TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html" _FONT_DIR = Path(__file__).resolve().parent / "fonts" @@ -87,6 +97,17 @@ ACCENT_START = "#1c4fd6" ACCENT_END = "#6fa8ff" +def _rgb_to_hex(rgb: tuple[int, int, int]) -> str: + return "#%02x%02x%02x" % tuple(rgb) + + +def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str: + """A darker shade of `rgb` for a CSS gradient's second stop -- purely + decorative (ordered_dither commits everything to exact palette colors + regardless), same reasoning ACCENT_START/END's own comment gives.""" + return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb)) + + # --- Persistent background browser ------------------------------------- _loop: asyncio.AbstractEventLoop | None = None @@ -325,3 +346,181 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l img = build(mode, data, target_w, target_h, palette_rgb, units, city_label) quantized = _quantize(img, palette_rgb, dither_strength=1.0) return _png_bytes(quantized) + + +# --- Battery "modern" style ------------------------------------------------ + +def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict: + base = min(target_w, target_h) + icon_h = max(16, int(base // 3 * scale)) + pct_size = max(14, int(base // 3 * scale)) + line_size = max(9, int(base // 9 * scale)) + gap = 8 + total = icon_h + gap + pct_size + num_lines * (line_size + gap) + return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "total": total} + + +def build_battery(percent: int, lines: list[str], target_w: int, target_h: int, + palette_rgb: list | None = None) -> Image.Image: + """HTML/CSS-rendered analogue of widgets/battery.py's classic PIL + drawing -- same icon+percent+caption-lines shape, `lines` already + resolved by the caller (widgets/battery.py's _lines_for(), shared + with the classic path so the estimate/age formatting only lives in + one place). Returns an already-palette-exact RGB image (see + ordered_dither). + + Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until + the whole stack actually fits the available height -- the classic + PIL path solves the same "icon + percent + 0-2 lines in a fixed box" + problem by truncating lines that don't fit; scaling down instead + keeps every resolved line visible, which reads better for a widget + that only ever has at most 2 short caption lines to begin with.""" + avail_h = target_h - panel_style.GUTTER * 2 + num_lines = len(lines) + scale = 1.0 + sizes = _battery_sizes(target_w, target_h, num_lines, scale) + while sizes["total"] > avail_h and scale > 0.3: + scale -= 0.05 + sizes = _battery_sizes(target_w, target_h, num_lines, scale) + # Extreme case (a 1x1-grid-cell-sized widget in "detailed" mode): + # scale bottomed out and it still doesn't fit -- drop the least + # important line rather than render overlapping text, same + # graceful-degradation idiom the classic PIL path's own + # `if y + small_font_size > ...: break` truncation already uses. + while sizes["total"] > avail_h and lines: + lines = lines[:-1] + num_lines = len(lines) + sizes = _battery_sizes(target_w, target_h, num_lines, scale) + + fill_color = panel_style.battery_fill_color(percent, palette_rgb) + icon_h = sizes["icon_h"] + icon_w = int(icon_h * 1.8) + stroke = max(2, icon_h // 12) + nub_w = max(3, icon_w // 10) + template = _jinja_env.get_template("battery.html.jinja") + html = template.render( + w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, + font_dir=str(_FONT_DIR), percent=percent, lines=lines, + icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke, + fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke), + fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color), + nub_w=nub_w, nub_h=icon_h // 2, nub_radius=max(1, nub_w // 3), + pct_size=sizes["pct_size"], line_size=sizes["line_size"], + ) + rendered = render_html_to_image(html, target_w, target_h) + return ordered_dither(rendered, palette_rgb) + + +# --- Text "modern" style --------------------------------------------------- + +def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None) -> Image.Image: + """HTML/CSS-rendered analogue of widgets/text.py's classic PIL + drawing. Reuses widgets/text.py's own `_fit()` for the one piece of + logic CSS has no native equivalent for (shrink-to-fit sizing) -- + `_fit` measures against the exact same vendored font files via PIL, + so the resolved size is a real fit decision, not a guess -- but lets + the browser do its own text wrapping/line-breaking at that size + (paragraphs/runs passed through directly as HTML) rather than + replicating `_fit`'s own word-wrapped line list; the two wrapping + algorithms can disagree on exact break points, an acceptable + approximation since this style only needs to look good and fit + reasonably, not be pixel-identical to classic. Returns an already- + palette-exact RGB image (see ordered_dither).""" + from PIL import ImageDraw + + from . import widgets # local import: heavy-ish, and only "modern" text needs it + + text_widget = widgets.text + bg_rgb = (hex_to_rgb(cfg.background_color) if cfg.background_color else None) or (255, 255, 255) + family = cfg.font_family if cfg.font_family in text_widget.FONT_FAMILIES else text_widget.DEFAULT_FONT_FAMILY + paragraphs = cfg.content or [] + margin = text_widget.MARGIN + max_width = max(10, target_w - 2 * margin) + max_height = max(10, target_h - 2 * margin) + + measure_img = Image.new("RGB", (1, 1)) + draw = ImageDraw.Draw(measure_img) + size, _lines = text_widget._fit(draw, paragraphs, family, cfg.font_size, max_width, max_height) + + files = text_widget._FONT_FILES.get(family) or text_widget._FONT_FILES[text_widget.DEFAULT_FONT_FAMILY] + align = cfg.align if cfg.align in ("left", "center", "right") else "left" + template = _jinja_env.get_template("text.html.jinja") + html = template.render( + w=target_w, h=target_h, margin=margin, bg_color=_rgb_to_hex(bg_rgb), + size=size, line_height=text_widget.LINE_HEIGHT_FACTOR, align=align, + font_regular=str(_FONT_DIR / files[(False, False)]), font_bold=str(_FONT_DIR / files[(True, False)]), + font_italic=str(_FONT_DIR / files[(False, True)]), font_bold_italic=str(_FONT_DIR / files[(True, True)]), + paragraphs=paragraphs, + ) + rendered = render_html_to_image(html, target_w, target_h) + return ordered_dither(rendered, palette_rgb) + + +# --- Tasks "modern" style --------------------------------------------------- + +def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None, + title: str = "Tasks") -> Image.Image: + """HTML/CSS-rendered analogue of calendar_render._build_tasks -- + same header+checklist shape. Reuses calendar_render's own + _event_colors/_fmt_task_due (the exact color-dedup/due-date-format + logic the classic renderer uses) so a task's color chip/due string + matches classic style exactly; only the drawing differs. Returns an + already-palette-exact RGB image (see ordered_dither).""" + from .calendar_render import _event_colors, _fmt_task_due + + header_h = max(28, min(target_w, target_h) // 8) + body_size = max(11, min(target_w, target_h) // 20) + row_h = body_size + 14 + box_size = max(10, body_size - 4) + avail_h = target_h - header_h - 16 + max_rows = max(0, avail_h // row_h) + + owners_seen: list[str] = [] + rows = [] + for task in tasks[:max_rows]: + colors = _event_colors(task, owners_seen, palette_rgb) + done = task.get("completed_at") is not None + due = None if done else (_fmt_task_due(task.get("due")) or None) + rows.append({ + "colors": [_rgb_to_hex(c) for c in colors], + "done": done, + "due": due, + "summary": task["summary"], + }) + more_count = max(0, len(tasks) - max_rows) + + template = _jinja_env.get_template("tasks.html.jinja") + html = template.render( + w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, + font_dir=str(_FONT_DIR), title=title, header_h=header_h, title_size=max(14, header_h - 12), + accent_start=_rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_TASKS)), + accent_end=_rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_TASKS)), + rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size, + ) + rendered = render_html_to_image(html, target_w, target_h) + return ordered_dither(rendered, palette_rgb) + + +# --- Static image / whiteboard "modern" style (shared) --------------------- + +def build_framed_image(composed: Image.Image, target_w: int, target_h: int, + palette_rgb: list | None = None) -> Image.Image: + """Wraps an already-composed image (static_image.py/whiteboard.py's + own compose_into() output, exactly target_w x target_h, already + cropped/fit per that widget's own display_mode) in a rounded-corner, + shadowed card -- the first visual chrome either widget type has ever + had (both currently draw with zero chrome of their own). Returns an + already-palette-exact RGB image (see ordered_dither).""" + import base64 + + buf = io.BytesIO() + composed.convert("RGB").save(buf, format="PNG") + image_b64 = base64.b64encode(buf.getvalue()).decode("ascii") + + template = _jinja_env.get_template("framed_image.html.jinja") + html = template.render( + w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS, + image_b64=image_b64, + ) + rendered = render_html_to_image(html, target_w, target_h) + return ordered_dither(rendered, palette_rgb) diff --git a/server/app/migration.py b/server/app/migration.py index 157ece4..096f713 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -814,6 +814,72 @@ def _migration_31(conn) -> None: conn.execute(text("ALTER TABLE weather_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'")) +def _migration_32(conn) -> None: + """Photos widget's own independent palette/dithering (models.Frame. + photo_palette_rgb/photo_dither_strength -- see widgets/photos.py's + render()). NULL/1.0 defaults reproduce the exact previous rendering + (same reference palette/strength as the main fields) until a frame's + Configuration tab sets them differently. + + Guarded per-column, same reasoning as migration 30/31's own comments.""" + existing = {c["name"] for c in inspect(conn).get_columns("frames")} + if "photo_palette_rgb" not in existing: + conn.execute(text("ALTER TABLE frames ADD COLUMN photo_palette_rgb TEXT")) + if "photo_dither_strength" not in existing: + conn.execute(text("ALTER TABLE frames ADD COLUMN photo_dither_strength REAL NOT NULL DEFAULT 1.0")) + + +def _migration_33(conn) -> None: + """Battery widget render style (models.BatteryWidgetConfig. + render_style) -- same shape as migration 31's weather one. Every + existing battery widget defaults to "classic", no behavior change.""" + existing = {c["name"] for c in inspect(conn).get_columns("battery_widget_configs")} + if "render_style" not in existing: + conn.execute(text("ALTER TABLE battery_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'")) + + +def _migration_34(conn) -> None: + """Text widget render style (models.TextWidgetConfig.render_style) -- + same shape as migration 31/33. Every existing text widget defaults to + "classic", no behavior change.""" + existing = {c["name"] for c in inspect(conn).get_columns("text_widget_configs")} + if "render_style" not in existing: + conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'")) + + +def _migration_35(conn) -> None: + """Tasks widget render style (models.TaskWidgetConfig.render_style) + -- same shape as migration 31/33/34. Every existing tasks widget + defaults to "classic", no behavior change.""" + existing = {c["name"] for c in inspect(conn).get_columns("task_widget_configs")} + if "render_style" not in existing: + conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'")) + + +def _migration_36(conn) -> None: + """Static image widget render style (models.StaticWidgetConfig. + render_style) -- same shape as migration 31/33/34/35.""" + existing = {c["name"] for c in inspect(conn).get_columns("static_widget_configs")} + if "render_style" not in existing: + conn.execute(text("ALTER TABLE static_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'")) + + +def _migration_37(conn) -> None: + """Whiteboard widget render style (models.WhiteboardWidgetConfig. + render_style) -- same shape as migration 36.""" + existing = {c["name"] for c in inspect(conn).get_columns("whiteboard_widget_configs")} + if "render_style" not in existing: + conn.execute(text("ALTER TABLE whiteboard_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'")) + + +def _migration_38(conn) -> None: + """Calendar widget render style (models.CalendarWidgetConfig. + render_style) -- same shape as migration 31/33/34/35/36/37.""" + existing = {c["name"] for c in inspect(conn).get_columns("calendar_widget_configs")} + if "render_style" not in existing: + conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -846,6 +912,13 @@ MIGRATIONS = [ (29, _migration_29), (30, _migration_30), (31, _migration_31), + (32, _migration_32), + (33, _migration_33), + (34, _migration_34), + (35, _migration_35), + (36, _migration_36), + (37, _migration_37), + (38, _migration_38), ] diff --git a/server/app/models.py b/server/app/models.py index fa0f8c9..c9ba298 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -178,6 +178,14 @@ class Frame(Base): # 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's # original always-on full-strength Floyd-Steinberg dithering. dither_strength: Mapped[float] = mapped_column(Float, default=1.0) + # Same shape as palette_rgb/dither_strength above, but scoped to only + # the photos widget (widgets/photos.py quantizes against these itself, + # before returning -- see its own docstring) -- lets a frame tune the + # rest of its widgets' palette/dithering (e.g. a "modern" HTML- + # rendered dashboard look) independently of what actually looks best + # for real photographs. NULL/1.0 = same defaults as the main fields. + photo_palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) + photo_dither_strength: Mapped[float] = mapped_column(Float, default=1.0) # -- calendar mode (see calendar_feed.py, calendar_render.py, # routers/device.py's RENDERERS["calendar"]) -- @@ -542,6 +550,10 @@ class CalendarWidgetConfig(Base): week_days: Mapped[int] = mapped_column(Integer, default=7) week_layout: Mapped[str] = mapped_column(String, default="horizontal") week_start_offset: Mapped[int] = mapped_column(Integer, default=0) + # classic (calendar_render.py) vs modern (app/calendar_html_render.py, + # agenda mode only so far -- see that module's docstring) -- see + # widgets/calendar.py's render(). + render_style: Mapped[str] = mapped_column(String, default="classic") class TaskWidgetConfig(Base): @@ -580,6 +592,9 @@ class TaskWidgetConfig(Base): # outstanding ones -- off by default, same "opt into more" posture # as calendar_weather_enabled. show_completed: Mapped[bool] = mapped_column(Boolean, default=False) + # classic (hand-drawn PIL, calendar_render._build_tasks) vs modern + # (app/html_render.py) -- see widgets/tasks.py's render(). + render_style: Mapped[str] = mapped_column(String, default="classic") class WhiteboardWidgetConfig(Base): @@ -593,6 +608,10 @@ class WhiteboardWidgetConfig(Base): url: Mapped[str] = mapped_column(String, default="") checked_at: Mapped[float] = mapped_column(Float, default=0.0) cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + # classic (no chrome, unchanged) vs modern (app/html_render.py's + # rounded-corner shadowed card) -- see widgets/whiteboard.py's + # render(). + render_style: Mapped[str] = mapped_column(String, default="classic") class WeatherWidgetConfig(Base): @@ -668,6 +687,9 @@ class TextWidgetConfig(Base): font_family: Mapped[str] = mapped_column(String, default="sans") align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right" background_color: Mapped[str] = mapped_column(String, default="#ffffff") + # classic (hand-drawn PIL) vs modern (app/html_render.py) -- see + # widgets/text.py's render()/render_preview_png(). + render_style: Mapped[str] = mapped_column(String, default="classic") class StaticWidgetConfig(Base): @@ -689,6 +711,10 @@ class StaticWidgetConfig(Base): # minus crop_faces -- no face detection for an uploaded image (see # image_pipeline.STATIC_DISPLAY_MODES). display_mode: Mapped[str] = mapped_column(String, default="crop_fill") + # classic (no chrome, unchanged) vs modern (app/html_render.py's + # rounded-corner shadowed card) -- see widgets/static_image.py's + # render(). + render_style: Mapped[str] = mapped_column(String, default="classic") class BatteryWidgetConfig(Base): @@ -699,12 +725,14 @@ class BatteryWidgetConfig(Base): of anything the widget itself fetches or the user authors. `mode` "compact" is icon + percent only; "detailed" (default) adds the routers.common.battery_estimate_s time-remaining estimate and the - last report's age.""" + last report's age. `render_style` picks classic (hand-drawn PIL) vs + modern (app/html_render.py) -- see widgets/battery.py's render().""" __tablename__ = "battery_widget_configs" widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed + render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern # widget_type -> its per-type extension table, keyed by widget_id. Used diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index 76bb782..2898622 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -105,6 +105,9 @@ def api_config_save( color_boost: float | None = Form(None), contrast_boost: float | None = Form(None), dither_strength: float | None = Form(None), + photo_palette: list[str] | None = Form(None), + photo_palette_reset: bool | None = Form(None), + photo_dither_strength: float | None = Form(None), hold_duration_ms: int | None = Form(None), next_hold_action: str | None = Form(None), back_hold_action: str | None = Form(None), @@ -182,6 +185,17 @@ def api_config_save( cfg.contrast_boost = max(0.0, min(2.0, contrast_boost)) if dither_strength is not None: cfg.dither_strength = max(0.0, min(1.0, dither_strength)) + if photo_palette_reset: + cfg.photo_palette_rgb = None + elif photo_palette is not None: + if len(photo_palette) != len(PALETTE_LABELS): + raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(photo_palette)}") + parsed = [hex_to_rgb(h) for h in photo_palette] + if any(rgb is None for rgb in parsed): + raise HTTPException(400, "Palette colors must be #rrggbb hex values") + cfg.photo_palette_rgb = [list(rgb) for rgb in parsed] + if photo_dither_strength is not None: + cfg.photo_dither_strength = max(0.0, min(1.0, photo_dither_strength)) if hold_duration_ms is not None: cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms)) if next_hold_action is not None: diff --git a/server/app/routers/api_layouts.py b/server/app/routers/api_layouts.py index f2f32ff..b4a8241 100644 --- a/server/app/routers/api_layouts.py +++ b/server/app/routers/api_layouts.py @@ -56,13 +56,13 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = { "photos": ("album_id", "order", "display_mode", "queue_target_len"), "calendar": ( "view", "week_start", "weather_enabled", "weather_units", "weather_cities", - "week_days", "week_layout", "week_start_offset", + "week_days", "week_layout", "week_start_offset", "render_style", ), - "tasks": ("name", "show_completed"), - "static": ("display_mode", "original_filename"), - "text": ("content", "font_size", "font_family", "align", "background_color"), - "whiteboard": ("user_id", "url"), - "battery": ("mode",), + "tasks": ("name", "show_completed", "render_style"), + "static": ("display_mode", "original_filename", "render_style"), + "text": ("content", "font_size", "font_family", "align", "background_color", "render_style"), + "whiteboard": ("user_id", "url", "render_style"), + "battery": ("mode", "render_style"), "weather": ( "mode", "provider", "units", "city_label", "city_latitude", "city_longitude", "hourly_interval_hours", "daily_days", "cities", "render_style", diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index 18f459f..5924fc2 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -35,11 +35,16 @@ from ..image_pipeline import ( DEFAULT_STATIC_DISPLAY_MODE, DISPLAY_MODES, hex_to_rgb, + logical_render_size, MAX_BORDER_THICKNESS, MIN_BORDER_THICKNESS, PALETTE_LABELS, STATIC_DISPLAY_MODES, render_preview_png, + compose_into, + _enhance, + _png_bytes, + _quantize, ) from ..image_upload import decode_upload from ..models import ( @@ -317,9 +322,12 @@ def api_widget_config_save( album_id: str | None = Form(None), order: str | None = Form(None), display_mode: str | None = Form(None), + static_render_style: str | None = Form(None), + whiteboard_render_style: str | None = Form(None), queue_target_len: int | None = Form(None), # calendar calendar_view: str | None = Form(None), + calendar_render_style: str | None = Form(None), calendar_week_start: int | None = Form(None), calendar_week_days: int | None = Form(None), calendar_week_layout: str | None = Form(None), @@ -329,10 +337,12 @@ def api_widget_config_save( # tasks tasks_name: str | None = Form(None), tasks_show_completed: bool | None = Form(None), + tasks_render_style: str | None = Form(None), # text text_html: str | None = Form(None), text_font_size: int | None = Form(None), text_font_family: str | None = Form(None), + text_render_style: str | None = Form(None), text_align: str | None = Form(None), text_background_color: str | None = Form(None), # weather @@ -344,6 +354,7 @@ def api_widget_config_save( weather_render_style: str | None = Form(None), # battery battery_mode: str | None = Form(None), + battery_render_style: str | None = Form(None), ): """Every field optional -- same partial-update, form-urlencoded convention as the old frame-level api_config_save, now scoped to one @@ -406,6 +417,8 @@ def api_widget_config_save( # new unit label. ccfg.weather_checked_at = 0.0 ccfg.weather_units = calendar_weather_units + if calendar_render_style is not None and calendar_render_style in ("classic", "modern"): + ccfg.render_style = calendar_render_style elif widget.widget_type == "tasks": with widget_locked(db, frame.id, widget.id) as (_, _, tcfg): if tasks_name is not None: @@ -416,10 +429,22 @@ def api_widget_config_save( if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed: tcfg.show_completed = tasks_show_completed tcfg.checked_at = 0.0 # pick up the change promptly + if tasks_render_style is not None and tasks_render_style in ("classic", "modern"): + tcfg.render_style = tasks_render_style elif widget.widget_type == "static": with widget_locked(db, frame.id, widget.id) as (_, _, scfg): if display_mode is not None: scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE + if static_render_style is not None and static_render_style in ("classic", "modern"): + scfg.render_style = static_render_style + elif widget.widget_type == "whiteboard": + # user_id/url go through the dedicated /whiteboard-source endpoint + # (owner-only, JSON body) -- render_style is the one setting this + # widget type takes through the shared /config form, same as + # every other widget type's own render_style. + with widget_locked(db, frame.id, widget.id) as (_, _, wbcfg): + if whiteboard_render_style is not None and whiteboard_render_style in ("classic", "modern"): + wbcfg.render_style = whiteboard_render_style elif widget.widget_type == "text": with widget_locked(db, frame.id, widget.id) as (_, _, xcfg): if text_html is not None: @@ -440,6 +465,8 @@ def api_widget_config_save( xcfg.background_color = ( text_background_color if hex_to_rgb(text_background_color) else "#ffffff" ) + if text_render_style is not None and text_render_style in ("classic", "modern"): + xcfg.render_style = text_render_style elif widget.widget_type == "weather": with widget_locked(db, frame.id, widget.id) as (_, _, wcfg): if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"): @@ -476,6 +503,8 @@ def api_widget_config_save( with widget_locked(db, frame.id, widget.id) as (_, _, bcfg): if battery_mode is not None: bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed" + if battery_render_style is not None and battery_render_style in ("classic", "modern"): + bcfg.render_style = battery_render_style with frame_locked(db, frame.id) as cfg: cfg.stats_config_saves += 1 return {"status": "saved"} @@ -836,14 +865,29 @@ def api_widget_preview_calendar( ccfg = db.get(CalendarWidgetConfig, widget.id) events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget) weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None - png = calendar_render.render_calendar_preview_png( - events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation, - palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary, - week_start=ccfg.week_start, - weather_cities=weather_cities, weather_units=ccfg.weather_units, - week_days=ccfg.week_days, week_layout=ccfg.week_layout, - week_start_offset=ccfg.week_start_offset, - ) + + target_w, target_h = logical_render_size(frame.orientation) + if ccfg.render_style == "modern": + from zoneinfo import ZoneInfo + + from .. import calendar_html_render + + tz = ZoneInfo(frame.timezone) if frame.timezone else ZoneInfo("UTC") + img = calendar_html_render.build( + events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb, + weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset, + ) + quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0) + png = _png_bytes(quantized) + else: + png = calendar_render.render_calendar_preview_png( + events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation, + palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary, + week_start=ccfg.week_start, + weather_cities=weather_cities, weather_units=ccfg.weather_units, + week_days=ccfg.week_days, week_layout=ccfg.week_layout, + week_start_offset=ccfg.week_start_offset, + ) return Response(content=png, media_type="image/png") @@ -862,8 +906,17 @@ def api_widget_preview_tasks( raise HTTPException(400, "No task lists included on this widget yet") tcfg = db.get(TaskWidgetConfig, widget.id) tasks = get_or_refresh_tasks_for_widget(db, frame, widget) - png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation, - palette_rgb=frame.palette_rgb, title=tcfg.name or "Tasks") + title = tcfg.name or "Tasks" + if tcfg.render_style == "modern": + from .. import html_render + + target_w, target_h = logical_render_size(frame.orientation) + img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title) + quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0) + png = _png_bytes(quantized) + else: + png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation, + palette_rgb=frame.palette_rgb, title=title) return Response(content=png, media_type="image/png") @@ -1170,11 +1223,22 @@ def api_widget_preview_static( if not scfg.image: raise HTTPException(400, "No image uploaded to this widget yet") source = Image.open(io.BytesIO(scfg.image)).convert("RGB") - png = render_preview_png( - source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, - display_mode=scfg.display_mode, color_boost=frame.color_boost, - contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, - ) + if scfg.render_style == "modern": + from .. import html_render + + target_w, target_h = logical_render_size(frame.orientation) + composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, + display_mode=scfg.display_mode) + fitted = _enhance(composed, frame.color_boost, frame.contrast_boost) + img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb) + quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0) + png = _png_bytes(quantized) + else: + png = render_preview_png( + source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, + display_mode=scfg.display_mode, color_boost=frame.color_boost, + contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, + ) return Response(content=png, media_type="image/png") @@ -1309,8 +1373,18 @@ def api_widget_preview_whiteboard( raise HTTPException(400, "No whiteboard configured on this widget yet") raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials") source = Image.open(io.BytesIO(png_bytes)).convert("RGB") - png = render_preview_png( - source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, - display_mode="letterbox", - ) + if wcfg.render_style == "modern": + from .. import html_render + + target_w, target_h = logical_render_size(frame.orientation) + composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, + display_mode="letterbox") + img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb) + quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0) + png = _png_bytes(quantized) + else: + png = render_preview_png( + source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, + display_mode="letterbox", + ) return Response(content=png, media_type="image/png") diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index ceae180..7d43c8c 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -306,6 +306,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = ) return templates.TemplateResponse("_widget_dialog_whiteboard.html", { "request": request, "frame": frame, "widget": widget, "user": user, + "whiteboard_cfg": whiteboard_cfg, "whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg), "viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx, }) diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js index 3fb9f24..5442268 100644 --- a/server/app/static/frame_config.js +++ b/server/app/static/frame_config.js @@ -114,7 +114,7 @@ async function loadControl() { document.getElementById('take-control').addEventListener('click', takeControl); -// ---- Advanced configuration: color palette ---- +// ---- Advanced configuration: color palette(s) ---- // // Hex field and R/G/B number fields are kept in sync live, both // directions -- editing either updates the other plus the preview @@ -122,9 +122,15 @@ document.getElementById('take-control').addEventListener('click', takeControl); // the server already validates as #rrggbb); the R/G/B fields are purely // an alternate, more precise way to arrive at the same value than // eyeballing a color-picker swatch. +// +// Parameterized by classPrefix ("palette" for the main one, "photo- +// palette" for the photos-only one added alongside it) rather than +// duplicated wholesale -- there are exactly two real instances of this, +// not a speculative future one, and the two would otherwise be ~90 +// near-identical lines apart. -function paletteHexInputs() { - return Array.from(document.querySelectorAll('.palette-hex')) +function paletteHexInputs(classPrefix) { + return Array.from(document.querySelectorAll(`.${classPrefix}-hex`)) .sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index)); } @@ -140,45 +146,53 @@ function rgbFromHex(hex) { return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } -function paletteFieldsFor(index) { +function paletteFieldsFor(classPrefix, index) { const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`); - return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') }; + return { + hex: at(`${classPrefix}-hex`), r: at(`${classPrefix}-r`), g: at(`${classPrefix}-g`), b: at(`${classPrefix}-b`), + swatch: at(`${classPrefix}-swatch-preview`), + }; } -function syncPaletteFromHex(index) { - const f = paletteFieldsFor(index); +function syncPaletteFromHex(classPrefix, index) { + const f = paletteFieldsFor(classPrefix, index); const rgb = rgbFromHex(f.hex.value); if (!rgb) return; [f.r.value, f.g.value, f.b.value] = rgb; f.swatch.style.background = f.hex.value; } -function syncPaletteFromRgb(index) { - const f = paletteFieldsFor(index); +function syncPaletteFromRgb(classPrefix, index) { + const f = paletteFieldsFor(classPrefix, index); const hex = hexFromRgb(f.r.value, f.g.value, f.b.value); f.hex.value = hex; f.swatch.style.background = hex; } -const palettePickerCount = paletteHexInputs().length; -for (let i = 0; i < palettePickerCount; i++) { - const f = paletteFieldsFor(i); - f.hex.addEventListener('input', () => syncPaletteFromHex(i)); - [f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i))); +function wirePaletteInputs(classPrefix) { + const count = paletteHexInputs(classPrefix).length; + for (let i = 0; i < count; i++) { + const f = paletteFieldsFor(classPrefix, i); + f.hex.addEventListener('input', () => syncPaletteFromHex(classPrefix, i)); + [f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(classPrefix, i))); + } } +wirePaletteInputs('palette'); +wirePaletteInputs('photo-palette'); + // Sliders: live numeric readout next to each, no save until the button // below is clicked. -['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => { +['color_boost', 'contrast_boost', 'dither_strength', 'photo_dither_strength'].forEach((id) => { const input = document.getElementById(id); const readout = document.getElementById(`${id}_value`); input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); }); }); -async function savePalette(extra) { +async function savePalette(classPrefix, paletteFormKey, extra) { const body = new URLSearchParams(extra || {}); - for (const input of paletteHexInputs()) { - body.append('palette', input.value); + for (const input of paletteHexInputs(classPrefix)) { + body.append(paletteFormKey, input.value); } try { const resp = await fetch(`${window.FRAME_API}/config`, { @@ -195,7 +209,7 @@ async function savePalette(extra) { } document.getElementById('palette-save').addEventListener('click', () => { - savePalette({ + savePalette('palette', 'palette', { color_boost: document.getElementById('color_boost').value, contrast_boost: document.getElementById('contrast_boost').value, dither_strength: document.getElementById('dither_strength').value, @@ -203,16 +217,16 @@ document.getElementById('palette-save').addEventListener('click', () => { }); document.getElementById('palette-reset').addEventListener('click', () => { - const inputs = paletteHexInputs(); + const inputs = paletteHexInputs('palette'); window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { inputs[i].value = hex; - syncPaletteFromHex(i); + syncPaletteFromHex('palette', i); }); ['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => { document.getElementById(id).value = '1'; document.getElementById(`${id}_value`).textContent = '1.00'; }); - savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' }); + savePalette('palette', 'palette', { palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' }); }); // Fills the table with a community-measured starting point (see the @@ -220,13 +234,32 @@ document.getElementById('palette-reset').addEventListener('click', () => { // editing the hex/RGB fields by hand; the user still clicks Save (or // Reset) to commit or discard it. document.getElementById('palette-load-calibrated').addEventListener('click', () => { - const inputs = paletteHexInputs(); + const inputs = paletteHexInputs('palette'); window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => { inputs[i].value = hex; - syncPaletteFromHex(i); + syncPaletteFromHex('palette', i); }); }); +// ---- Photos configuration: its own separate palette/dithering ---- + +document.getElementById('photo-palette-save').addEventListener('click', () => { + savePalette('photo-palette', 'photo_palette', { + photo_dither_strength: document.getElementById('photo_dither_strength').value, + }); +}); + +document.getElementById('photo-palette-reset').addEventListener('click', () => { + const inputs = paletteHexInputs('photo-palette'); + window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { + inputs[i].value = hex; + syncPaletteFromHex('photo-palette', i); + }); + document.getElementById('photo_dither_strength').value = '1'; + document.getElementById('photo_dither_strength_value').textContent = '1.00'; + savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' }); +}); + // ---- Preview: current photo vs. how it renders with saved settings ---- // Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API, // set by the template) rather than window.FRAME_API -- palette/color/ diff --git a/server/app/static/widget_dialog_battery.js b/server/app/static/widget_dialog_battery.js index 42ee8cd..1fc53e2 100644 --- a/server/app/static/widget_dialog_battery.js +++ b/server/app/static/widget_dialog_battery.js @@ -13,6 +13,7 @@ function initBatteryDialog() { e.preventDefault(); const body = new URLSearchParams({ battery_mode: document.getElementById('battery_mode').value, + battery_render_style: document.getElementById('battery_render_style').value, }); try { const resp = await fetch(`${window.FRAME_API}/config`, { diff --git a/server/app/static/widget_dialog_calendar.js b/server/app/static/widget_dialog_calendar.js index 581c3a9..36b5ae4 100644 --- a/server/app/static/widget_dialog_calendar.js +++ b/server/app/static/widget_dialog_calendar.js @@ -81,6 +81,7 @@ function initCalendarDialog() { calendar_week_days: document.getElementById('calendar_week_days').value, calendar_week_layout: document.getElementById('calendar_week_layout').value, calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value, + calendar_render_style: document.getElementById('calendar_render_style').value, }); try { const resp = await fetch(`${window.FRAME_API}/config`, { diff --git a/server/app/static/widget_dialog_static.js b/server/app/static/widget_dialog_static.js index 623f09c..bfd033d 100644 --- a/server/app/static/widget_dialog_static.js +++ b/server/app/static/widget_dialog_static.js @@ -38,6 +38,7 @@ function initStaticDialog() { e.preventDefault(); const body = new URLSearchParams({ display_mode: document.getElementById('display_mode').value, + static_render_style: document.getElementById('static_render_style').value, }); try { const resp = await fetch(`${window.FRAME_API}/config`, { diff --git a/server/app/static/widget_dialog_tasks.js b/server/app/static/widget_dialog_tasks.js index 672bac0..dfa1934 100644 --- a/server/app/static/widget_dialog_tasks.js +++ b/server/app/static/widget_dialog_tasks.js @@ -71,6 +71,7 @@ function initTasksDialog() { const body = new URLSearchParams({ tasks_name: document.getElementById('tasks_name').value, tasks_show_completed: String(document.getElementById('tasks_show_completed').checked), + tasks_render_style: document.getElementById('tasks_render_style').value, }); try { const resp = await fetch(`${window.FRAME_API}/config`, { diff --git a/server/app/static/widget_dialog_text.js b/server/app/static/widget_dialog_text.js index d31722c..1f867bd 100644 --- a/server/app/static/widget_dialog_text.js +++ b/server/app/static/widget_dialog_text.js @@ -113,6 +113,7 @@ function initTextDialog() { text_font_size: document.getElementById('text_font_size').value, text_align: document.getElementById('text_align').value, text_background_color: document.getElementById('text_background_color').value, + text_render_style: document.getElementById('text_render_style').value, }); try { const resp = await fetch(`${window.FRAME_API}/config`, { diff --git a/server/app/static/widget_dialog_whiteboard.js b/server/app/static/widget_dialog_whiteboard.js index 4ee5946..d18aff6 100644 --- a/server/app/static/widget_dialog_whiteboard.js +++ b/server/app/static/widget_dialog_whiteboard.js @@ -90,6 +90,25 @@ function initWhiteboardDialog() { whiteboardClearBtn.addEventListener('click', clearWhiteboardSource); } + document.getElementById('whiteboard-config-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const body = new URLSearchParams({ + whiteboard_render_style: document.getElementById('whiteboard_render_style').value, + }); + try { + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + loadWhiteboardPreview(false); + } catch (e) { + showStatus(false, e.message); + } + }); + // Shows whatever's already cached (cheap, no refetch) on open; the // button is the one place that means "no really, go check now" -- // bypasses the fetch throttle server-side (see api_widget_preview_ diff --git a/server/app/templates/_widget_dialog_battery.html b/server/app/templates/_widget_dialog_battery.html index 7a7f8f3..45ca5ee 100644 --- a/server/app/templates/_widget_dialog_battery.html +++ b/server/app/templates/_widget_dialog_battery.html @@ -11,6 +11,12 @@ + diff --git a/server/app/templates/_widget_dialog_calendar.html b/server/app/templates/_widget_dialog_calendar.html index bcf063b..433af60 100644 --- a/server/app/templates/_widget_dialog_calendar.html +++ b/server/app/templates/_widget_dialog_calendar.html @@ -40,6 +40,12 @@

0 = starts today, negative = starts in the past, positive = starts in the future. Only used when Days to show isn't 7.

+ diff --git a/server/app/templates/_widget_dialog_static.html b/server/app/templates/_widget_dialog_static.html index e58787a..4884da3 100644 --- a/server/app/templates/_widget_dialog_static.html +++ b/server/app/templates/_widget_dialog_static.html @@ -32,6 +32,12 @@ exactly without cropping (an image with a different aspect ratio looks stretched); Shrink to fit shows the whole image, letterboxed if needed.

+ diff --git a/server/app/templates/_widget_dialog_tasks.html b/server/app/templates/_widget_dialog_tasks.html index db2447f..eeadf2b 100644 --- a/server/app/templates/_widget_dialog_tasks.html +++ b/server/app/templates/_widget_dialog_tasks.html @@ -55,6 +55,12 @@ + diff --git a/server/app/templates/_widget_dialog_text.html b/server/app/templates/_widget_dialog_text.html index 2f62706..3ac64bd 100644 --- a/server/app/templates/_widget_dialog_text.html +++ b/server/app/templates/_widget_dialog_text.html @@ -43,6 +43,12 @@ + diff --git a/server/app/templates/_widget_dialog_whiteboard.html b/server/app/templates/_widget_dialog_whiteboard.html index 4a27878..5447c7a 100644 --- a/server/app/templates/_widget_dialog_whiteboard.html +++ b/server/app/templates/_widget_dialog_whiteboard.html @@ -50,6 +50,19 @@ {% endif %} +
+

Settings

+
+ + +
+
+ {% include "_widget_border_fields.html" %} {% include "_widget_button_fields.html" %} diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index 5ac4811..23af66e 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -139,11 +139,12 @@
Advanced configuration -

Color-quantization values used when dithering photos - for this panel -- approximations by default, since exact primaries - aren't published. Tune them by comparing a rendered photo against - the physical panel; different panel units can vary enough to be - worth calibrating per frame.

+

Color-quantization values used for every widget + except photos (which has its own separate + settings below) -- approximations by default, since exact + primaries aren't published. Tune them by comparing a rendered + widget against the physical panel; different panel units can + vary enough to be worth calibrating per frame.

@@ -195,6 +196,45 @@ Compare against the physical panel before keeping it.

+
+ Photos configuration +

Palette and dithering used only by + the photos widget, independent of Advanced configuration above -- + lets you tune the rest of this frame's widgets (e.g. a "modern" + HTML-rendered look) without changing what looks best for actual + photographs, or vice versa.

+
+
ColorHexRGB
+ + + {% set current_photo_palette = frame.photo_palette_rgb or default_palette_rgb %} + {% set current_photo_hex = palette_to_hex(current_photo_palette) %} + {% for label in palette_labels %} + + + + + + + + + {% endfor %} + +
ColorHexRGB
{{ label }}
+
+ + + +
+

Preview

{% if photo_widget_id %} diff --git a/server/app/templates/widget_html/_calendar_day_section.html.jinja b/server/app/templates/widget_html/_calendar_day_section.html.jinja new file mode 100644 index 0000000..ec70b55 --- /dev/null +++ b/server/app/templates/widget_html/_calendar_day_section.html.jinja @@ -0,0 +1,32 @@ +{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) %} +
+ {#- Fixed height (not auto) -- every stacked day-section's header + must be exactly this tall regardless of whether THIS particular + day has a weather entry, or days with/without weather misalign + where their event rows start (see calendar_week_horizontal's + identical fix/reasoning). #} +
+
{{ header }}
+ {% if weather_entries %} +
+ {% for we in weather_entries %} +
{{ we.emoji }}{{ we.high }}°/{{ we.low }}°{{ unit_suffix }}
+ {% endfor %} +
+ {% endif %} +
+
+ {% if not rows and not more_count %} +
Nothing scheduled
+ {% endif %} + {% for row in rows %} +
+
{% for c in row.colors %}{% endfor %}
+
{{ row.time }}
+
{{ row.summary }}
+
+ {% endfor %} + {% if more_count %}
+{{ more_count }} more
{% endif %} +
+
+{% endmacro %} diff --git a/server/app/templates/widget_html/battery.html.jinja b/server/app/templates/widget_html/battery.html.jinja new file mode 100644 index 0000000..2cfca71 --- /dev/null +++ b/server/app/templates/widget_html/battery.html.jinja @@ -0,0 +1,53 @@ + + + +
+
+
+
+
+
{{ percent }}%
+ {% for line in lines %}
{{ line }}
{% endfor %} +
+ diff --git a/server/app/templates/widget_html/calendar_agenda.html.jinja b/server/app/templates/widget_html/calendar_agenda.html.jinja new file mode 100644 index 0000000..9bb7fb9 --- /dev/null +++ b/server/app/templates/widget_html/calendar_agenda.html.jinja @@ -0,0 +1,36 @@ + + + + {% import "_calendar_day_section.html.jinja" as ds %} +
+ {{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }} +
+ diff --git a/server/app/templates/widget_html/calendar_month.html.jinja b/server/app/templates/widget_html/calendar_month.html.jinja new file mode 100644 index 0000000..011db3f --- /dev/null +++ b/server/app/templates/widget_html/calendar_month.html.jinja @@ -0,0 +1,65 @@ + + + +
+
+ {% for name in day_names %}
{{ name }}
{% endfor %} +
+
+ {% for week in weeks %} +
+ {% for day in week %} +
+ {{ day.day_num }} + {% if day.dots %} +
+ {% for c in day.dots %}
{% endfor %} + {% if day.more_count %}+{{ day.more_count }}{% endif %} +
+ {% endif %} +
+ {% endfor %} +
+ {% endfor %} +
+
+ diff --git a/server/app/templates/widget_html/calendar_today_tomorrow.html.jinja b/server/app/templates/widget_html/calendar_today_tomorrow.html.jinja new file mode 100644 index 0000000..ff4be8b --- /dev/null +++ b/server/app/templates/widget_html/calendar_today_tomorrow.html.jinja @@ -0,0 +1,39 @@ + + + + {% import "_calendar_day_section.html.jinja" as ds %} +
+ {% for day in days %} + {{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }} + {% endfor %} +
+ diff --git a/server/app/templates/widget_html/calendar_week_horizontal.html.jinja b/server/app/templates/widget_html/calendar_week_horizontal.html.jinja new file mode 100644 index 0000000..3bc77e1 --- /dev/null +++ b/server/app/templates/widget_html/calendar_week_horizontal.html.jinja @@ -0,0 +1,60 @@ + + + +
+ {% for col in cols %} +
+
+
{{ col.label }}
+ {% if col.weather %}
{{ col.weather.emoji }}{{ col.weather.high }}°/{{ col.weather.low }}°{{ unit_suffix }}
{% endif %} +
+
+ {% for row in col.rows %} +
{{ row.summary }}
+ {% endfor %} + {% if col.more_count %}
+{{ col.more_count }}
{% endif %} +
+
+ {% endfor %} +
+ diff --git a/server/app/templates/widget_html/framed_image.html.jinja b/server/app/templates/widget_html/framed_image.html.jinja new file mode 100644 index 0000000..9225c39 --- /dev/null +++ b/server/app/templates/widget_html/framed_image.html.jinja @@ -0,0 +1,27 @@ + + + +
+ diff --git a/server/app/templates/widget_html/tasks.html.jinja b/server/app/templates/widget_html/tasks.html.jinja new file mode 100644 index 0000000..cdd6442 --- /dev/null +++ b/server/app/templates/widget_html/tasks.html.jinja @@ -0,0 +1,62 @@ + + + +
+
{{ title }}
+
+ {% if not rows %} +
Nothing outstanding
+ {% endif %} + {% for row in rows %} +
+
{% for c in row.colors %}{% endfor %}
+
+ {% if row.due %}
{{ row.due }}
{% endif %} +
{{ row.summary }}
+
+ {% endfor %} + {% if more_count %}
+{{ more_count }} more
{% endif %} +
+
+ diff --git a/server/app/templates/widget_html/text.html.jinja b/server/app/templates/widget_html/text.html.jinja new file mode 100644 index 0000000..f425f0e --- /dev/null +++ b/server/app/templates/widget_html/text.html.jinja @@ -0,0 +1,35 @@ + + + +
+ {% for paragraph in paragraphs %} +

+ {% if not paragraph %} {% endif %} + {% for run in paragraph %}{{ run.text }}{% endfor %} +

+ {% endfor %} +
+ diff --git a/server/app/widgets/battery.py b/server/app/widgets/battery.py index d2dd047..373da0e 100644 --- a/server/app/widgets/battery.py +++ b/server/app/widgets/battery.py @@ -60,6 +60,21 @@ def _format_age(as_of: float) -> str: return f"{round(delta / 86400)}d ago" +def _lines_for(mode: str, frame: Frame, db: Session) -> list[str]: + """The 0-2 caption lines "detailed" mode shows below the percent -- + shared by both render styles so the estimate/age formatting only + lives in one place.""" + if mode != "detailed": + return [] + lines = [] + estimate_s = battery_estimate_s(frame, db) + if estimate_s is not None: + lines.append(_format_estimate(estimate_s)) + if frame.battery_as_of: + lines.append(f"Reported {_format_age(frame.battery_as_of)}") + return lines + + def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int, is_normal_wake: bool = True) -> Image.Image: """is_normal_wake is unused -- see app/widgets/whiteboard.py's @@ -72,6 +87,16 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i cfg = db.get(BatteryWidgetConfig, widget.id) mode = cfg.mode if cfg else "detailed" palette_rgb = frame.palette_rgb + lines = _lines_for(mode, frame, db) + + if cfg and cfg.render_style == "modern": + # Local import: html_render pulls in Playwright, a real headless- + # Chromium dependency -- every other widget type, and this one's + # own classic path, should never pay for it (same reasoning as + # image_pipeline.render_placeholder's local `import qrcode`). + from .. import html_render + + return html_render.build_battery(percent, lines, target_w, target_h, palette_rgb) img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) cx = cx0 + cw // 2 @@ -92,23 +117,15 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font, panel_style.battery_fill_color(percent, palette_rgb)) - if mode == "detailed": - lines = [] - estimate_s = battery_estimate_s(frame, db) - if estimate_s is not None: - lines.append(_format_estimate(estimate_s)) - if frame.battery_as_of: - lines.append(f"Reported {_format_age(frame.battery_as_of)}") - - small_font_size = max(11, pct_font_size // 3) - small_font = panel_style.font_regular(small_font_size) - y = pct_y + pct_font_size + 12 - for line in lines: - if y + small_font_size > cy0 + ch - 4: - break - lbbox = draw.textbbox((0, 0), line, font=small_font) - draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font) - y += small_font_size + 6 + small_font_size = max(11, pct_font_size // 3) + small_font = panel_style.font_regular(small_font_size) + y = pct_y + pct_font_size + 12 + for line in lines: + if y + small_font_size > cy0 + ch - 4: + break + lbbox = draw.textbbox((0, 0), line, font=small_font) + draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font) + y += small_font_size + 6 return img diff --git a/server/app/widgets/calendar.py b/server/app/widgets/calendar.py index 445b87e..f36b6d7 100644 --- a/server/app/widgets/calendar.py +++ b/server/app/widgets/calendar.py @@ -19,6 +19,8 @@ from __future__ import annotations from PIL import Image from sqlalchemy.orm import Session +from zoneinfo import ZoneInfo + from ..calendar_render import _build from ..db import widget_locked from ..models import CalendarWidgetConfig, Frame, Widget @@ -47,6 +49,18 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget) weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None + if cfg.render_style == "modern": + # Local import: html_render pulls in Playwright, a real headless- + # Chromium dependency -- every other widget type, and this one's + # own classic path, should never pay for it. + from .. import calendar_html_render + + tz = ZoneInfo(frame.timezone) if frame.timezone else ZoneInfo("UTC") + return calendar_html_render.build( + events, cfg.view, cfg.browse_offset, target_w, target_h, tz, cfg.week_start, frame.palette_rgb, + weather_cities, cfg.weather_units, cfg.week_days, cfg.week_layout, cfg.week_start_offset, + ) + return _build( events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h, timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start, diff --git a/server/app/widgets/photos.py b/server/app/widgets/photos.py index de8badc..e97a832 100644 --- a/server/app/widgets/photos.py +++ b/server/app/widgets/photos.py @@ -8,7 +8,23 @@ take down the whole panel's render just because one region out of several couldn't be composed this cycle; it falls back to a small placeholder instead, the same resilience calendar mode's old photo-inlay already had (see routers/device.py's `except HTTPException: pass` around -its own inlay fetch).""" +its own inlay fetch). + +Unlike every other widget type, render() quantizes its own output +(against Frame.photo_palette_rgb/photo_dither_strength, not the main +palette_rgb/dither_strength the rest of the frame uses) before +returning, so a frame can tune its other widgets' look (e.g. the +"modern" HTML-rendered widgets' Bayer dithering) independently of +whatever looks best for actual photographs -- see image_pipeline. +render_panel's docstring for why this is safe to do per-widget without +a shared-canvas seam risk. One small, accepted edge case: widget +borders are always drawn afterward (routers/device.py's +_render_one_widget) against the *main* palette_rgb, so a border on a +photos widget whose photo_palette_rgb genuinely diverges from +palette_rgb can sit against already-quantized-to-a-different-reference +photo pixels -- cosmetically arguable, not a bug, and not worth +special-casing border resolution for what's a deliberate, uncommon +customization.""" from __future__ import annotations @@ -18,7 +34,7 @@ from sqlalchemy.orm import Session from .. import photo_queue, quiet_hours from ..db import widget_locked -from ..image_pipeline import compose_into +from ..image_pipeline import _quantize, compose_into from ..models import Frame, PhotoWidgetConfig, Widget from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets from ._shared import placeholder_image @@ -50,7 +66,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i except HTTPException as e: return placeholder_image(target_w, target_h, ["Photos widget", str(e.detail)[:40]]) - return compose_into(source, faces, target_w, target_h, cfg.display_mode) + composed = compose_into(source, faces, target_w, target_h, cfg.display_mode) + return _quantize(composed, frame.photo_palette_rgb, frame.photo_dither_strength).convert("RGB") def _advance(db: Session, frame: Frame, widget: Widget) -> None: diff --git a/server/app/widgets/static_image.py b/server/app/widgets/static_image.py index 502dbe3..f71c8aa 100644 --- a/server/app/widgets/static_image.py +++ b/server/app/widgets/static_image.py @@ -32,4 +32,12 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i if not cfg.image: return placeholder_image(target_w, target_h, ["Static image widget", "not configured yet"]) source = Image.open(io.BytesIO(cfg.image)).convert("RGB") - return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode=cfg.display_mode) + composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode=cfg.display_mode) + if cfg.render_style == "modern": + # Local import: html_render pulls in Playwright, a real headless- + # Chromium dependency -- every other widget type, and this one's + # own classic path, should never pay for it. + from .. import html_render + + return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb) + return composed diff --git a/server/app/widgets/tasks.py b/server/app/widgets/tasks.py index 3036e97..d08258f 100644 --- a/server/app/widgets/tasks.py +++ b/server/app/widgets/tasks.py @@ -36,7 +36,15 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i signature regardless of which ones actually care.""" tasks = get_or_refresh_tasks_for_widget(db, frame, widget) cfg = db.get(TaskWidgetConfig, widget.id) - return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, cfg.name or "Tasks") + title = cfg.name or "Tasks" + if cfg.render_style == "modern": + # Local import: html_render pulls in Playwright, a real headless- + # Chromium dependency -- every other widget type, and this one's + # own classic path, should never pay for it. + from .. import html_render + + return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title) + return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title) ACTIONS: dict = {} diff --git a/server/app/widgets/text.py b/server/app/widgets/text.py index fc255c5..16d0a95 100644 --- a/server/app/widgets/text.py +++ b/server/app/widgets/text.py @@ -224,6 +224,26 @@ def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.I return img +def _render_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int, + palette_rgb: list | None) -> Image.Image: + """classic vs modern (app/html_render.py) -- shared by render() and + render_preview_png() so both honor render_style identically (weather + once shipped with its preview endpoint bypassing render_style + entirely by calling the classic renderer directly -- this shared + dispatch point exists specifically so that bug can't happen here). + palette_rgb is unused by the classic path (it never quantizes itself + -- see module docstring), only threaded through for modern's own + ordered_dither.""" + if cfg.render_style == "modern": + # Local import: html_render pulls in Playwright, a real headless- + # Chromium dependency -- every other widget type, and this one's + # own classic path, should never pay for it. + from .. import html_render + + return html_render.build_text(cfg, target_w, target_h, palette_rgb) + return _render_text(cfg, target_w, target_h) + + def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int, is_normal_wake: bool = True) -> Image.Image: """is_normal_wake is unused -- see app/widgets/whiteboard.py's @@ -232,7 +252,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i cfg = db.get(TextWidgetConfig, widget.id) if cfg is None or not has_text(cfg.content): return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"]) - return _render_text(cfg, target_w, target_h) + return _render_dispatch(cfg, target_w, target_h, frame.palette_rgb) def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None) -> bytes: @@ -244,7 +264,7 @@ def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: lis import io target_w, target_h = logical_render_size(orientation) - img = _render_text(cfg, target_w, target_h) + img = _render_dispatch(cfg, target_w, target_h, palette_rgb) quantized = _quantize(img, palette_rgb, dither_strength=1.0) buf = io.BytesIO() quantized.convert("RGB").save(buf, format="PNG") diff --git a/server/app/widgets/whiteboard.py b/server/app/widgets/whiteboard.py index 85cdebf..e12db2f 100644 --- a/server/app/widgets/whiteboard.py +++ b/server/app/widgets/whiteboard.py @@ -15,7 +15,7 @@ from PIL import Image from sqlalchemy.orm import Session from ..image_pipeline import compose_into -from ..models import Frame, Widget +from ..models import Frame, Widget, WhiteboardWidgetConfig from ..routers.common import get_or_refresh_whiteboard_for_widget from ._shared import placeholder_image @@ -35,7 +35,16 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i # letterbox, never cropped: unlike a photo, losing part of a # whiteboard to a crop loses actual content, not just some background # (see the old _render_whiteboard_mode's identical reasoning). - return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode="letterbox") + composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode="letterbox") + cfg = db.get(WhiteboardWidgetConfig, widget.id) + if cfg and cfg.render_style == "modern": + # Local import: html_render pulls in Playwright, a real headless- + # Chromium dependency -- every other widget type, and this one's + # own classic path, should never pay for it. + from .. import html_render + + return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb) + return composed def _check_now(db: Session, frame: Frame, widget: Widget) -> None: diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index f481766..4579bc3 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -94,6 +94,16 @@ def test_expected_columns_exist_on_current_schema(): assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30 assert "render_style" in weather_widget_columns # migration 31 + assert {"photo_palette_rgb", "photo_dither_strength"} <= frame_columns # migration 32 + assert "render_style" in battery_widget_columns # migration 33 + assert "render_style" in text_widget_columns # migration 34 + assert "render_style" in task_widget_columns # migration 35 + static_widget_columns = {c["name"] for c in inspector.get_columns("static_widget_configs")} + assert "render_style" in static_widget_columns # migration 36 + whiteboard_widget_columns = {c["name"] for c in inspector.get_columns("whiteboard_widget_configs")} + assert "render_style" in whiteboard_widget_columns # migration 37 + calendar_widget_columns = {c["name"] for c in inspector.get_columns("calendar_widget_configs")} + assert "render_style" in calendar_widget_columns # migration 38 # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- diff --git a/server/tests/test_widgets_battery.py b/server/tests/test_widgets_battery.py index 69775ff..b7831f4 100644 --- a/server/tests/test_widgets_battery.py +++ b/server/tests/test_widgets_battery.py @@ -7,17 +7,19 @@ from __future__ import annotations import time -from app import widgets +from PIL import Image + +from app import html_render, widgets from app.models import BatteryWidgetConfig, Frame, Widget -def _make_widget(db_session, mode="detailed") -> tuple[Frame, Widget]: +def _make_widget(db_session, mode="detailed", render_style="classic") -> tuple[Frame, Widget]: frame = db_session.get(Frame, 1) widget = Widget(frame_id=frame.id, widget_type="battery", x=0, y=0, w=1, h=1, sort_order=0, created_at=time.time()) db_session.add(widget) db_session.flush() - db_session.add(BatteryWidgetConfig(widget_id=widget.id, mode=mode)) + db_session.add(BatteryWidgetConfig(widget_id=widget.id, mode=mode, render_style=render_style)) db_session.commit() return frame, widget @@ -81,3 +83,19 @@ def test_no_button_actions(): advance/back/check.""" assert widgets.battery.ACTIONS == {} assert widgets.battery.ACTION_LABELS == {} + + +def test_render_modern_style(db_session, monkeypatch): + frame, widget = _make_widget(db_session, render_style="modern") + frame.battery_percent = 42 + frame.battery_as_of = time.time() + db_session.commit() + + def _stub(html, target_w, target_h): + return Image.new("RGB", (target_w, target_h), (255, 255, 255)) + + monkeypatch.setattr(html_render, "render_html_to_image", _stub) + + img = widgets.battery.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB" diff --git a/server/tests/test_widgets_calendar.py b/server/tests/test_widgets_calendar.py index 74be407..6e9d6e4 100644 --- a/server/tests/test_widgets_calendar.py +++ b/server/tests/test_widgets_calendar.py @@ -9,6 +9,8 @@ from __future__ import annotations import time +import pytest + from app import widgets from app.db import widget_locked from app.models import CalendarWidgetConfig, Frame, Widget @@ -163,3 +165,46 @@ def test_month_view_falls_back_to_agenda_layout_below_small_tier(db_session, mon _stub_fetches(monkeypatch) img = widgets.calendar.render(db_session, frame, widget, 300, 192) assert img.size == (300, 192) + + +# --- "modern" style (app/calendar_html_render.py) ----------------------- +# No real browser here -- html_render.render_html_to_image is monkeypatched +# to a stub, so these exercise calendar.py's dispatch + calendar_html_ +# render's own layout/data logic, not Playwright/Chromium itself. + +def _stub_render_html_to_image(monkeypatch): + from PIL import Image + + from app import html_render + + monkeypatch.setattr(html_render, "render_html_to_image", + lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255))) + + +@pytest.mark.parametrize("view", ["agenda", "today_tomorrow", "week", "month"]) +def test_render_modern_style_every_view(db_session, monkeypatch, view): + """All four view modes have a modern-style builder (unlike weather's + own current/daily-only modern style) -- each must dispatch correctly + from calendar.py's render().""" + frame, widget = _make_widget(db_session, view=view, render_style="modern") + events = [{"summary": "Standup", "start": "2026-07-31T09:00:00+00:00", + "end": "2026-07-31T09:30:00+00:00", "all_day": False, + "sources": [{"owner_display_name": "Alice", "color_index": None}]}] + _stub_fetches(monkeypatch, events=events) + _stub_render_html_to_image(monkeypatch) + + img = widgets.calendar.render(db_session, frame, widget, 380, 300) + assert img.size == (380, 300) + assert img.mode == "RGB" + + +def test_render_modern_month_falls_back_to_agenda_below_small_tier(db_session, monkeypatch): + """Same "month needs real column width" fallback classic has (see + test_month_view_falls_back_to_agenda_layout_below_small_tier above), + still honored when render_style is modern.""" + frame, widget = _make_widget(db_session, view="month", render_style="modern") + _stub_fetches(monkeypatch) + _stub_render_html_to_image(monkeypatch) + + img = widgets.calendar.render(db_session, frame, widget, 300, 192) + assert img.size == (300, 192) diff --git a/server/tests/test_widgets_photos.py b/server/tests/test_widgets_photos.py index 37fe2d9..445f537 100644 --- a/server/tests/test_widgets_photos.py +++ b/server/tests/test_widgets_photos.py @@ -109,6 +109,35 @@ def test_advance_action_is_a_no_op_when_unconfigured(db_session): assert cfg.current_asset_id == "" +def test_render_quantizes_against_photo_palette_not_the_main_frame_palette(db_session, monkeypatch): + """Regression/design test: photos.py's render() must quantize against + Frame.photo_palette_rgb, genuinely independent of Frame.palette_rgb -- + the whole point of giving photos its own palette (see widgets/photos. + py's module docstring). Uses a custom photo_palette_rgb whose "black" + slot is a distinctive color that doesn't appear anywhere in + DEFAULT_PALETTE_RGB, so the assertion only passes if photo_palette_rgb + was actually the one used.""" + frame, widget = _make_widget(db_session) + custom_photo_palette = [ + [10, 20, 30], [255, 255, 255], [255, 219, 0], [207, 0, 15], [0, 39, 133], [0, 133, 55], + ] + frame.photo_palette_rgb = custom_photo_palette + frame.photo_dither_strength = 0.0 # flat quantize -- exact, no diffusion noise to account for + db_session.commit() + assert frame.palette_rgb is None # main palette stays at its default throughout + + monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object()) + monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS) + # A near-black source photo -- under the MAIN default palette this + # would quantize to (0, 0, 0); under custom_photo_palette's distinctive + # "black" slot it must quantize to exactly (10, 20, 30) instead. + source = Image.new("RGB", (100, 80), (5, 5, 5)) + monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None)) + + img = widgets.photos.render(db_session, frame, widget, 400, 300) + assert set(img.getdata()) == {(10, 20, 30)} + + def test_render_does_not_advance_when_locked(db_session, monkeypatch): frame, widget = _make_widget(db_session) monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object()) diff --git a/server/tests/test_widgets_static.py b/server/tests/test_widgets_static.py index 672f786..8e6f9ee 100644 --- a/server/tests/test_widgets_static.py +++ b/server/tests/test_widgets_static.py @@ -72,3 +72,18 @@ def test_no_button_actions(): """A fixed uploaded image -- nothing to advance/back/check.""" assert widgets.static_image.ACTIONS == {} assert widgets.static_image.ACTION_LABELS == {} + + +def test_render_modern_style(db_session, monkeypatch): + from app import html_render + + frame, widget = _make_widget(db_session, image=_png_bytes(), render_style="modern") + + def _stub(html, target_w, target_h): + return Image.new("RGB", (target_w, target_h), (255, 255, 255)) + + monkeypatch.setattr(html_render, "render_html_to_image", _stub) + + img = widgets.static_image.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB" diff --git a/server/tests/test_widgets_tasks.py b/server/tests/test_widgets_tasks.py index 6e04bdc..525a9d2 100644 --- a/server/tests/test_widgets_tasks.py +++ b/server/tests/test_widgets_tasks.py @@ -129,3 +129,27 @@ def test_no_button_actions(): weather -- nothing to advance/back/force.""" assert widgets.tasks.ACTIONS == {} assert widgets.tasks.ACTION_LABELS == {} + + +def test_render_modern_style(db_session, monkeypatch): + from PIL import Image + + from app import html_render + + frame, widget = _make_widget(db_session, render_style="modern", name="Chores") + tasks = [ + {"summary": "Buy milk", "due": None, "completed_at": None, + "owner_display_name": "Alice", "color_index": None}, + {"summary": "Walk the dog", "due": "2026-08-01", "completed_at": None, + "owner_display_name": "Alice", "color_index": None}, + ] + monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: tasks) + + def _stub(html, target_w, target_h): + return Image.new("RGB", (target_w, target_h), (255, 255, 255)) + + monkeypatch.setattr(html_render, "render_html_to_image", _stub) + + img = widgets.tasks.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB" diff --git a/server/tests/test_widgets_text.py b/server/tests/test_widgets_text.py index 8317688..8650ecc 100644 --- a/server/tests/test_widgets_text.py +++ b/server/tests/test_widgets_text.py @@ -121,3 +121,24 @@ def test_no_button_actions(): """Fixed authored text -- nothing to advance/back/check.""" assert widgets.text.ACTIONS == {} assert widgets.text.ACTION_LABELS == {} + + +def test_render_modern_style(db_session, monkeypatch): + from PIL import Image + + from app import html_render + + frame, widget = _make_widget( + db_session, + content=[[_run("Hello, ", bold=True), _run("world!", italic=True, color="#cc0000")]], + render_style="modern", + ) + + def _stub(html, target_w, target_h): + return Image.new("RGB", (target_w, target_h), (255, 255, 255)) + + monkeypatch.setattr(html_render, "render_html_to_image", _stub) + + img = widgets.text.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB" diff --git a/server/tests/test_widgets_whiteboard.py b/server/tests/test_widgets_whiteboard.py index 488ea98..4aa7cdb 100644 --- a/server/tests/test_widgets_whiteboard.py +++ b/server/tests/test_widgets_whiteboard.py @@ -16,13 +16,14 @@ from app import widgets from app.models import Frame, Widget, WhiteboardWidgetConfig -def _make_widget(db_session) -> tuple[Frame, Widget]: +def _make_widget(db_session, render_style="classic") -> tuple[Frame, Widget]: frame = db_session.get(Frame, 1) widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, sort_order=0, created_at=time.time()) db_session.add(widget) db_session.flush() - db_session.add(WhiteboardWidgetConfig(widget_id=widget.id, url="https://example.com/board.whiteboard")) + db_session.add(WhiteboardWidgetConfig(widget_id=widget.id, url="https://example.com/board.whiteboard", + render_style=render_style)) db_session.commit() return frame, widget @@ -83,3 +84,20 @@ def test_both_buttons_map_to_check_now(): """No real "next"/"back" concept for a static board -- both physical buttons mean the same thing for a whiteboard widget.""" assert set(widgets.whiteboard.ACTIONS) == {"check_now"} + + +def test_render_modern_style(db_session, monkeypatch): + from app import html_render + + frame, widget = _make_widget(db_session, render_style="modern") + monkeypatch.setattr(widgets.whiteboard, "get_or_refresh_whiteboard_for_widget", + lambda db, frame, widget: _tiny_png()) + + def _stub(html, target_w, target_h): + return Image.new("RGB", (target_w, target_h), (255, 255, 255)) + + monkeypatch.setattr(html_render, "render_html_to_image", _stub) + + img = widgets.whiteboard.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB"