Roll out "modern" HTML/CSS render style to every widget except photos
Build and push server image / test (push) Successful in 43s
Build and push server image / build-and-push (push) Successful in 3m51s
Build and push server image / deploy (push) Failing after 1m57s

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.
This commit is contained in:
2026-07-31 03:52:19 +00:00
parent c6dad191fb
commit e331f5e5a1
47 changed files with 1662 additions and 116 deletions
+67 -23
View File
@@ -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 full ~800x480 canvas -- falls back to agenda view if a widget is too small
for month view to stay legible. 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 ## Button actions
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of 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. widget's whole content instead of a strip above an agenda day.
**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default **Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default
| `"modern"`, experimental): `current`/`daily` only -- `hourly`/ | `"modern"`, experimental) -- see "Modern render style" above; weather's
`multi_city` always render classic regardless of this setting. `modern` own modern coverage is `current`/`daily` only, `hourly`/`multi_city`
draws the widget as an HTML/CSS card (Jinja2 templates under always render classic regardless of this setting.
`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.
`current`/`hourly`/`daily` share one configured location `current`/`hourly`/`daily` share one configured location
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../ (`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
+291
View File
@@ -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)
+206 -7
View File
@@ -1,9 +1,19 @@
"""Experimental "modern" weather widget render style: Jinja2 + a """Experimental "modern" render style, offered as an opt-in alternative
persistent headless Chromium browser (Playwright) instead of the hand- to several widget types' hand-drawn PIL primitives: Jinja2 + a
drawn PIL primitives in weather_render.py -- see docs/widgets.md and the persistent headless Chromium browser (Playwright) -- see docs/widgets.md
`html-widget-render` branch's PR description for the design rationale for the design rationale (gradients/shadows/soft shading that PIL can't
(gradients/shadows/soft shading that PIL can't easily do, at the cost of easily do, at the cost of a real browser-process dependency). Everything
a real browser-process dependency). 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 Two things this module owns that nothing else in the codebase needed
before: before:
@@ -57,7 +67,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
from PIL import Image from PIL import Image
from . import panel_style 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" _TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html"
_FONT_DIR = Path(__file__).resolve().parent / "fonts" _FONT_DIR = Path(__file__).resolve().parent / "fonts"
@@ -87,6 +97,17 @@ ACCENT_START = "#1c4fd6"
ACCENT_END = "#6fa8ff" 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 ------------------------------------- # --- Persistent background browser -------------------------------------
_loop: asyncio.AbstractEventLoop | None = None _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) img = build(mode, data, target_w, target_h, palette_rgb, units, city_label)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized) 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)
+73
View File
@@ -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'")) 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 = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -846,6 +912,13 @@ MIGRATIONS = [
(29, _migration_29), (29, _migration_29),
(30, _migration_30), (30, _migration_30),
(31, _migration_31), (31, _migration_31),
(32, _migration_32),
(33, _migration_33),
(34, _migration_34),
(35, _migration_35),
(36, _migration_36),
(37, _migration_37),
(38, _migration_38),
] ]
+29 -1
View File
@@ -178,6 +178,14 @@ class Frame(Base):
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's # 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
# original always-on full-strength Floyd-Steinberg dithering. # original always-on full-strength Floyd-Steinberg dithering.
dither_strength: Mapped[float] = mapped_column(Float, default=1.0) 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, # -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) -- # routers/device.py's RENDERERS["calendar"]) --
@@ -542,6 +550,10 @@ class CalendarWidgetConfig(Base):
week_days: Mapped[int] = mapped_column(Integer, default=7) week_days: Mapped[int] = mapped_column(Integer, default=7)
week_layout: Mapped[str] = mapped_column(String, default="horizontal") week_layout: Mapped[str] = mapped_column(String, default="horizontal")
week_start_offset: Mapped[int] = mapped_column(Integer, default=0) 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): class TaskWidgetConfig(Base):
@@ -580,6 +592,9 @@ class TaskWidgetConfig(Base):
# outstanding ones -- off by default, same "opt into more" posture # outstanding ones -- off by default, same "opt into more" posture
# as calendar_weather_enabled. # as calendar_weather_enabled.
show_completed: Mapped[bool] = mapped_column(Boolean, default=False) 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): class WhiteboardWidgetConfig(Base):
@@ -593,6 +608,10 @@ class WhiteboardWidgetConfig(Base):
url: Mapped[str] = mapped_column(String, default="") url: Mapped[str] = mapped_column(String, default="")
checked_at: Mapped[float] = mapped_column(Float, default=0.0) checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) 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): class WeatherWidgetConfig(Base):
@@ -668,6 +687,9 @@ class TextWidgetConfig(Base):
font_family: Mapped[str] = mapped_column(String, default="sans") font_family: Mapped[str] = mapped_column(String, default="sans")
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right" align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
background_color: Mapped[str] = mapped_column(String, default="#ffffff") 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): class StaticWidgetConfig(Base):
@@ -689,6 +711,10 @@ class StaticWidgetConfig(Base):
# minus crop_faces -- no face detection for an uploaded image (see # minus crop_faces -- no face detection for an uploaded image (see
# image_pipeline.STATIC_DISPLAY_MODES). # image_pipeline.STATIC_DISPLAY_MODES).
display_mode: Mapped[str] = mapped_column(String, default="crop_fill") 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): class BatteryWidgetConfig(Base):
@@ -699,12 +725,14 @@ class BatteryWidgetConfig(Base):
of anything the widget itself fetches or the user authors. `mode` of anything the widget itself fetches or the user authors. `mode`
"compact" is icon + percent only; "detailed" (default) adds the "compact" is icon + percent only; "detailed" (default) adds the
routers.common.battery_estimate_s time-remaining estimate and 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" __tablename__ = "battery_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed 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 # widget_type -> its per-type extension table, keyed by widget_id. Used
+14
View File
@@ -105,6 +105,9 @@ def api_config_save(
color_boost: float | None = Form(None), color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None), contrast_boost: float | None = Form(None),
dither_strength: 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), hold_duration_ms: int | None = Form(None),
next_hold_action: str | None = Form(None), next_hold_action: str | None = Form(None),
back_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)) cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None: if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength)) 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: if hold_duration_ms is not None:
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms)) cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
if next_hold_action is not None: if next_hold_action is not None:
+6 -6
View File
@@ -56,13 +56,13 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
"photos": ("album_id", "order", "display_mode", "queue_target_len"), "photos": ("album_id", "order", "display_mode", "queue_target_len"),
"calendar": ( "calendar": (
"view", "week_start", "weather_enabled", "weather_units", "weather_cities", "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"), "tasks": ("name", "show_completed", "render_style"),
"static": ("display_mode", "original_filename"), "static": ("display_mode", "original_filename", "render_style"),
"text": ("content", "font_size", "font_family", "align", "background_color"), "text": ("content", "font_size", "font_family", "align", "background_color", "render_style"),
"whiteboard": ("user_id", "url"), "whiteboard": ("user_id", "url", "render_style"),
"battery": ("mode",), "battery": ("mode", "render_style"),
"weather": ( "weather": (
"mode", "provider", "units", "city_label", "city_latitude", "city_longitude", "mode", "provider", "units", "city_label", "city_latitude", "city_longitude",
"hourly_interval_hours", "daily_days", "cities", "render_style", "hourly_interval_hours", "daily_days", "cities", "render_style",
+93 -19
View File
@@ -35,11 +35,16 @@ from ..image_pipeline import (
DEFAULT_STATIC_DISPLAY_MODE, DEFAULT_STATIC_DISPLAY_MODE,
DISPLAY_MODES, DISPLAY_MODES,
hex_to_rgb, hex_to_rgb,
logical_render_size,
MAX_BORDER_THICKNESS, MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS, MIN_BORDER_THICKNESS,
PALETTE_LABELS, PALETTE_LABELS,
STATIC_DISPLAY_MODES, STATIC_DISPLAY_MODES,
render_preview_png, render_preview_png,
compose_into,
_enhance,
_png_bytes,
_quantize,
) )
from ..image_upload import decode_upload from ..image_upload import decode_upload
from ..models import ( from ..models import (
@@ -317,9 +322,12 @@ def api_widget_config_save(
album_id: str | None = Form(None), album_id: str | None = Form(None),
order: str | None = Form(None), order: str | None = Form(None),
display_mode: 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), queue_target_len: int | None = Form(None),
# calendar # calendar
calendar_view: str | None = Form(None), calendar_view: str | None = Form(None),
calendar_render_style: str | None = Form(None),
calendar_week_start: int | None = Form(None), calendar_week_start: int | None = Form(None),
calendar_week_days: int | None = Form(None), calendar_week_days: int | None = Form(None),
calendar_week_layout: str | None = Form(None), calendar_week_layout: str | None = Form(None),
@@ -329,10 +337,12 @@ def api_widget_config_save(
# tasks # tasks
tasks_name: str | None = Form(None), tasks_name: str | None = Form(None),
tasks_show_completed: bool | None = Form(None), tasks_show_completed: bool | None = Form(None),
tasks_render_style: str | None = Form(None),
# text # text
text_html: str | None = Form(None), text_html: str | None = Form(None),
text_font_size: int | None = Form(None), text_font_size: int | None = Form(None),
text_font_family: str | None = Form(None), text_font_family: str | None = Form(None),
text_render_style: str | None = Form(None),
text_align: str | None = Form(None), text_align: str | None = Form(None),
text_background_color: str | None = Form(None), text_background_color: str | None = Form(None),
# weather # weather
@@ -344,6 +354,7 @@ def api_widget_config_save(
weather_render_style: str | None = Form(None), weather_render_style: str | None = Form(None),
# battery # battery
battery_mode: str | None = Form(None), battery_mode: str | None = Form(None),
battery_render_style: str | None = Form(None),
): ):
"""Every field optional -- same partial-update, form-urlencoded """Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one 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. # new unit label.
ccfg.weather_checked_at = 0.0 ccfg.weather_checked_at = 0.0
ccfg.weather_units = calendar_weather_units 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": elif widget.widget_type == "tasks":
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg): with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
if tasks_name is not None: 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: if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
tcfg.show_completed = tasks_show_completed tcfg.show_completed = tasks_show_completed
tcfg.checked_at = 0.0 # pick up the change promptly 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": elif widget.widget_type == "static":
with widget_locked(db, frame.id, widget.id) as (_, _, scfg): with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
if display_mode is not None: if display_mode is not None:
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE 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": elif widget.widget_type == "text":
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg): with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
if text_html is not None: if text_html is not None:
@@ -440,6 +465,8 @@ def api_widget_config_save(
xcfg.background_color = ( xcfg.background_color = (
text_background_color if hex_to_rgb(text_background_color) else "#ffffff" 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": elif widget.widget_type == "weather":
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg): 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"): 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): with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
if battery_mode is not None: if battery_mode is not None:
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed" 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: with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1 cfg.stats_config_saves += 1
return {"status": "saved"} return {"status": "saved"}
@@ -836,14 +865,29 @@ def api_widget_preview_calendar(
ccfg = db.get(CalendarWidgetConfig, widget.id) ccfg = db.get(CalendarWidgetConfig, widget.id)
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget) 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 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, target_w, target_h = logical_render_size(frame.orientation)
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary, if ccfg.render_style == "modern":
week_start=ccfg.week_start, from zoneinfo import ZoneInfo
weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout, from .. import calendar_html_render
week_start_offset=ccfg.week_start_offset,
) 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") 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") raise HTTPException(400, "No task lists included on this widget yet")
tcfg = db.get(TaskWidgetConfig, widget.id) tcfg = db.get(TaskWidgetConfig, widget.id)
tasks = get_or_refresh_tasks_for_widget(db, frame, widget) tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation, title = tcfg.name or "Tasks"
palette_rgb=frame.palette_rgb, 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") return Response(content=png, media_type="image/png")
@@ -1170,11 +1223,22 @@ def api_widget_preview_static(
if not scfg.image: if not scfg.image:
raise HTTPException(400, "No image uploaded to this widget yet") raise HTTPException(400, "No image uploaded to this widget yet")
source = Image.open(io.BytesIO(scfg.image)).convert("RGB") source = Image.open(io.BytesIO(scfg.image)).convert("RGB")
png = render_preview_png( if scfg.render_style == "modern":
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, from .. import html_render
display_mode=scfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, 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") 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(400, "No whiteboard configured on this widget yet")
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials") raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
source = Image.open(io.BytesIO(png_bytes)).convert("RGB") source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
png = render_preview_png( if wcfg.render_style == "modern":
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, from .. import html_render
display_mode="letterbox",
) 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") return Response(content=png, media_type="image/png")
+1
View File
@@ -306,6 +306,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
) )
return templates.TemplateResponse("_widget_dialog_whiteboard.html", { return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
"request": request, "frame": frame, "widget": widget, "user": user, "request": request, "frame": frame, "widget": widget, "user": user,
"whiteboard_cfg": whiteboard_cfg,
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg), "whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx, "viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
}) })
+57 -24
View File
@@ -114,7 +114,7 @@ async function loadControl() {
document.getElementById('take-control').addEventListener('click', takeControl); 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 // Hex field and R/G/B number fields are kept in sync live, both
// directions -- editing either updates the other plus the preview // 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 // 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 // an alternate, more precise way to arrive at the same value than
// eyeballing a color-picker swatch. // 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() { function paletteHexInputs(classPrefix) {
return Array.from(document.querySelectorAll('.palette-hex')) return Array.from(document.querySelectorAll(`.${classPrefix}-hex`))
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index)); .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]; 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}"]`); 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) { function syncPaletteFromHex(classPrefix, index) {
const f = paletteFieldsFor(index); const f = paletteFieldsFor(classPrefix, index);
const rgb = rgbFromHex(f.hex.value); const rgb = rgbFromHex(f.hex.value);
if (!rgb) return; if (!rgb) return;
[f.r.value, f.g.value, f.b.value] = rgb; [f.r.value, f.g.value, f.b.value] = rgb;
f.swatch.style.background = f.hex.value; f.swatch.style.background = f.hex.value;
} }
function syncPaletteFromRgb(index) { function syncPaletteFromRgb(classPrefix, index) {
const f = paletteFieldsFor(index); const f = paletteFieldsFor(classPrefix, index);
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value); const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
f.hex.value = hex; f.hex.value = hex;
f.swatch.style.background = hex; f.swatch.style.background = hex;
} }
const palettePickerCount = paletteHexInputs().length; function wirePaletteInputs(classPrefix) {
for (let i = 0; i < palettePickerCount; i++) { const count = paletteHexInputs(classPrefix).length;
const f = paletteFieldsFor(i); for (let i = 0; i < count; i++) {
f.hex.addEventListener('input', () => syncPaletteFromHex(i)); const f = paletteFieldsFor(classPrefix, i);
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(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 // Sliders: live numeric readout next to each, no save until the button
// below is clicked. // 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 input = document.getElementById(id);
const readout = document.getElementById(`${id}_value`); const readout = document.getElementById(`${id}_value`);
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); }); 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 || {}); const body = new URLSearchParams(extra || {});
for (const input of paletteHexInputs()) { for (const input of paletteHexInputs(classPrefix)) {
body.append('palette', input.value); body.append(paletteFormKey, input.value);
} }
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -195,7 +209,7 @@ async function savePalette(extra) {
} }
document.getElementById('palette-save').addEventListener('click', () => { document.getElementById('palette-save').addEventListener('click', () => {
savePalette({ savePalette('palette', 'palette', {
color_boost: document.getElementById('color_boost').value, color_boost: document.getElementById('color_boost').value,
contrast_boost: document.getElementById('contrast_boost').value, contrast_boost: document.getElementById('contrast_boost').value,
dither_strength: document.getElementById('dither_strength').value, dither_strength: document.getElementById('dither_strength').value,
@@ -203,16 +217,16 @@ document.getElementById('palette-save').addEventListener('click', () => {
}); });
document.getElementById('palette-reset').addEventListener('click', () => { document.getElementById('palette-reset').addEventListener('click', () => {
const inputs = paletteHexInputs(); const inputs = paletteHexInputs('palette');
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex; inputs[i].value = hex;
syncPaletteFromHex(i); syncPaletteFromHex('palette', i);
}); });
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => { ['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
document.getElementById(id).value = '1'; document.getElementById(id).value = '1';
document.getElementById(`${id}_value`).textContent = '1.00'; 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 // 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 // editing the hex/RGB fields by hand; the user still clicks Save (or
// Reset) to commit or discard it. // Reset) to commit or discard it.
document.getElementById('palette-load-calibrated').addEventListener('click', () => { document.getElementById('palette-load-calibrated').addEventListener('click', () => {
const inputs = paletteHexInputs(); const inputs = paletteHexInputs('palette');
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => { window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
inputs[i].value = hex; 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 ---- // ---- Preview: current photo vs. how it renders with saved settings ----
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API, // Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
// set by the template) rather than window.FRAME_API -- palette/color/ // set by the template) rather than window.FRAME_API -- palette/color/
@@ -13,6 +13,7 @@ function initBatteryDialog() {
e.preventDefault(); e.preventDefault();
const body = new URLSearchParams({ const body = new URLSearchParams({
battery_mode: document.getElementById('battery_mode').value, battery_mode: document.getElementById('battery_mode').value,
battery_render_style: document.getElementById('battery_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -81,6 +81,7 @@ function initCalendarDialog() {
calendar_week_days: document.getElementById('calendar_week_days').value, calendar_week_days: document.getElementById('calendar_week_days').value,
calendar_week_layout: document.getElementById('calendar_week_layout').value, calendar_week_layout: document.getElementById('calendar_week_layout').value,
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value, calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
calendar_render_style: document.getElementById('calendar_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -38,6 +38,7 @@ function initStaticDialog() {
e.preventDefault(); e.preventDefault();
const body = new URLSearchParams({ const body = new URLSearchParams({
display_mode: document.getElementById('display_mode').value, display_mode: document.getElementById('display_mode').value,
static_render_style: document.getElementById('static_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
+1
View File
@@ -71,6 +71,7 @@ function initTasksDialog() {
const body = new URLSearchParams({ const body = new URLSearchParams({
tasks_name: document.getElementById('tasks_name').value, tasks_name: document.getElementById('tasks_name').value,
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked), tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
tasks_render_style: document.getElementById('tasks_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
+1
View File
@@ -113,6 +113,7 @@ function initTextDialog() {
text_font_size: document.getElementById('text_font_size').value, text_font_size: document.getElementById('text_font_size').value,
text_align: document.getElementById('text_align').value, text_align: document.getElementById('text_align').value,
text_background_color: document.getElementById('text_background_color').value, text_background_color: document.getElementById('text_background_color').value,
text_render_style: document.getElementById('text_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -90,6 +90,25 @@ function initWhiteboardDialog() {
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource); 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 // Shows whatever's already cached (cheap, no refetch) on open; the
// button is the one place that means "no really, go check now" -- // button is the one place that means "no really, go check now" --
// bypasses the fetch throttle server-side (see api_widget_preview_ // bypasses the fetch throttle server-side (see api_widget_preview_
@@ -11,6 +11,12 @@
<option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option> <option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option>
</select> </select>
</label> </label>
<label>Render style
<select id="battery_render_style">
<option value="classic" {% if not battery_cfg or battery_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn icon)</option>
<option value="modern" {% if battery_cfg and battery_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
</select>
</label>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
</section> </section>
@@ -40,6 +40,12 @@
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the <p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
past, positive = starts in the future. Only used when Days to show isn't 7.</p> past, positive = starts in the future. Only used when Days to show isn't 7.</p>
</div> </div>
<label>Render style
<select id="calendar_render_style">
<option value="classic" {% if calendar_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn)</option>
<option value="modern" {% if calendar_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental, all views)</option>
</select>
</label>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
@@ -32,6 +32,12 @@
exactly without cropping (an image with a different aspect ratio exactly without cropping (an image with a different aspect ratio
looks stretched); <strong>Shrink to fit</strong> shows the whole looks stretched); <strong>Shrink to fit</strong> shows the whole
image, letterboxed if needed.</p> image, letterboxed if needed.</p>
<label>Render style
<select id="static_render_style">
<option value="classic" {% if not static_cfg or static_cfg.render_style == 'classic' %}selected{% endif %}>Classic (no frame)</option>
<option value="modern" {% if static_cfg and static_cfg.render_style == 'modern' %}selected{% endif %}>Modern (rounded-corner card, experimental)</option>
</select>
</label>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
</section> </section>
@@ -55,6 +55,12 @@
<input type="checkbox" id="tasks_show_completed" {% if task_cfg.show_completed %}checked{% endif %}> <input type="checkbox" id="tasks_show_completed" {% if task_cfg.show_completed %}checked{% endif %}>
<label for="tasks_show_completed">Also show tasks completed in the last 24 hours</label> <label for="tasks_show_completed">Also show tasks completed in the last 24 hours</label>
</div> </div>
<label>Render style
<select id="tasks_render_style">
<option value="classic" {% if task_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn checklist)</option>
<option value="modern" {% if task_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
</select>
</label>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
</section> </section>
@@ -43,6 +43,12 @@
<label>Background color <label>Background color
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}"> <input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
</label> </label>
<label>Render style
<select id="text_render_style">
<option value="classic" {% if not text_cfg or text_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn text)</option>
<option value="modern" {% if text_cfg and text_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
</select>
</label>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
</section> </section>
@@ -50,6 +50,19 @@
{% endif %} {% endif %}
</section> </section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Settings</h2>
<form id="whiteboard-config-form">
<label>Render style
<select id="whiteboard_render_style">
<option value="classic" {% if not whiteboard_cfg or whiteboard_cfg.render_style == 'classic' %}selected{% endif %}>Classic (no frame)</option>
<option value="modern" {% if whiteboard_cfg and whiteboard_cfg.render_style == 'modern' %}selected{% endif %}>Modern (rounded-corner card, experimental)</option>
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
{% include "_widget_border_fields.html" %} {% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %} {% include "_widget_button_fields.html" %}
+45 -5
View File
@@ -139,11 +139,12 @@
<details class="card"> <details class="card">
<summary class="card-title">Advanced configuration</summary> <summary class="card-title">Advanced configuration</summary>
<p class="sub">Color-quantization values used when dithering photos <p class="sub">Color-quantization values used for every widget
for this panel -- approximations by default, since exact primaries <strong>except photos</strong> (which has its own separate
aren't published. Tune them by comparing a rendered photo against settings below) -- approximations by default, since exact
the physical panel; different panel units can vary enough to be primaries aren't published. Tune them by comparing a rendered
worth calibrating per frame.</p> widget against the physical panel; different panel units can
vary enough to be worth calibrating per frame.</p>
<div class="palette-table-wrap"> <div class="palette-table-wrap">
<table class="palette-table"> <table class="palette-table">
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead> <thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
@@ -195,6 +196,45 @@
Compare against the physical panel before keeping it.</p> Compare against the physical panel before keeping it.</p>
</details> </details>
<details class="card">
<summary class="card-title">Photos configuration</summary>
<p class="sub">Palette and dithering used <strong>only</strong> 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.</p>
<div class="palette-table-wrap">
<table class="palette-table">
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
<tbody>
{% 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 %}
<tr>
<td><span class="photo-palette-swatch-preview" data-index="{{ loop.index0 }}"
style="background: {{ current_photo_hex[loop.index0] }};"></span></td>
<td>{{ label }}</td>
<td><input type="text" class="photo-palette-hex" id="photo_palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
value="{{ current_photo_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
spellcheck="false" autocomplete="off"></td>
<td><input type="number" class="photo-palette-rgb photo-palette-r" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_photo_palette[loop.index0][0] }}"></td>
<td><input type="number" class="photo-palette-rgb photo-palette-g" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_photo_palette[loop.index0][1] }}"></td>
<td><input type="number" class="photo-palette-rgb photo-palette-b" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_photo_palette[loop.index0][2] }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<label>Dithering strength <span class="slider-value" id="photo_dither_strength_value">{{ "%.2f" | format(frame.photo_dither_strength) }}</span>
<input type="range" id="photo_dither_strength" min="0" max="1" step="0.05" value="{{ frame.photo_dither_strength }}">
</label>
<button type="button" class="secondary" id="photo-palette-save" style="margin-top: 16px;">Save</button>
<button type="button" class="secondary" id="photo-palette-reset">Reset to defaults</button>
</details>
<section class="card"> <section class="card">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
{% if photo_widget_id %} {% if photo_widget_id %}
@@ -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) %}
<div class="day-section">
{#- 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). #}
<div class="day-header" style="background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%); font-size: {{ title_size }}px; height: {{ header_h }}px;">
<div class="day-header-title">{{ header }}</div>
{% if weather_entries %}
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
{% for we in weather_entries %}
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}&deg;/{{ we.low }}&deg;{{ unit_suffix }}</span></div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="day-rows">
{% if not rows and not more_count %}
<div class="day-empty" style="font-size: {{ body_size }}px;">Nothing scheduled</div>
{% endif %}
{% for row in rows %}
<div class="day-row" style="height: {{ row_h }}px;">
<div class="day-chip">{% for c in row.colors %}<span style="background:{{ c }};"></span>{% endfor %}</div>
<div class="day-time" style="font-size: {{ body_size }}px;">{{ row.time }}</div>
<div class="day-summary" style="font-size: {{ body_size }}px;">{{ row.summary }}</div>
</div>
{% endfor %}
{% if more_count %}<div class="day-more" style="font-size: {{ body_size }}px;">+{{ more_count }} more</div>{% endif %}
</div>
</div>
{% endmacro %}
@@ -0,0 +1,53 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.icon-wrap { display: flex; align-items: center; }
.icon-body {
width: {{ icon_w }}px;
height: {{ icon_h }}px;
border: {{ stroke }}px solid #000000;
border-radius: {{ icon_radius }}px;
padding: {{ stroke }}px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.icon-fill {
width: {{ fill_pct }}%;
height: 100%;
border-radius: {{ fill_radius }}px;
background: linear-gradient(180deg, {{ fill_color }}, {{ fill_color_dark }});
}
.icon-nub {
width: {{ nub_w }}px;
height: {{ nub_h }}px;
background: #000000;
border-radius: 0 {{ nub_radius }}px {{ nub_radius }}px 0;
}
.pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 1; color: {{ fill_color }}; }
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1; color: #5b6674; }
</style></head>
<body>
<div class="card">
<div class="icon-wrap">
<div class="icon-body"><div class="icon-fill"></div></div>
<div class="icon-nub"></div>
</div>
<div class="pct">{{ percent }}%</div>
{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}
</div>
</body></html>
@@ -0,0 +1,36 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);
background: #ffffff;
display: flex;
flex-direction: column;
}
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 10px 16px; overflow: hidden; }
.day-weather-row { display: flex; gap: 14px; margin-top: 6px; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
.day-weather-icon { line-height: 1; }
.day-rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
.day-row { display: flex; align-items: center; gap: 8px; }
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.day-chip span { flex: 1 1 0; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-empty, .day-more { color: #5b6674; padding-top: 4px; }
</style></head>
<body>
{% import "_calendar_day_section.html.jinja" as ds %}
<div class="card">
{{ 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) }}
</div>
</body></html>
@@ -0,0 +1,65 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);
background: #ffffff;
display: flex;
flex-direction: column;
}
.weekday-row { display: flex; flex: 0 0 auto; background: {{ accent_start }}; }
.weekday-cell { flex: 1 1 0; color: #ffffff; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; }
.week-row { flex: 1 1 0; display: flex; }
.day-cell { flex: 1 1 0; border: 1px solid #e2e6ec; padding: 4px; min-width: 0; overflow: hidden; }
{#- Bold everywhere, including out-of-month -- de-emphasis is via
smaller size only, not weight or a gray color. Regular-weight and
gray text are both individually fragile under Bayer ordered
dithering at small sizes (thin/low-contrast anti-aliased edges
have little "mass" to survive the bias+threshold step), and this
cell combined both, which degraded out-of-month day numbers into
unrecognizable speckle -- classic PIL's own de-emphasis trick
(weight instead of gray, see calendar_render._build_month's
docstring) doesn't transfer safely to this render path. #}
.day-num { font-size: {{ day_size }}px; font-weight: 700; color: #17233b; }
.day-num.out-of-month { font-size: {{ day_size * 0.8 }}px; }
.day-num.today {
display: inline-block; background: {{ accent_start }}; color: #ffffff;
border-radius: 4px; padding: 0 4px;
}
.dots { display: flex; gap: 3px; margin-top: 3px; align-items: center; flex-wrap: wrap; }
.dot { width: {{ dot_size }}px; height: {{ dot_size }}px; border-radius: 50%; flex: 0 0 auto; }
.dot-more { font-size: {{ day_size * 0.8 }}px; color: #5b6674; }
</style></head>
<body>
<div class="card">
<div class="weekday-row">
{% for name in day_names %}<div class="weekday-cell">{{ name }}</div>{% endfor %}
</div>
<div class="weeks">
{% for week in weeks %}
<div class="week-row">
{% for day in week %}
<div class="day-cell">
<span class="day-num {% if day.is_today %}today{% elif not day.in_month %}out-of-month{% endif %}">{{ day.day_num }}</span>
{% if day.dots %}
<div class="dots">
{% for c in day.dots %}<div class="dot" style="background:{{ c }};"></div>{% endfor %}
{% if day.more_count %}<span class="dot-more">+{{ day.more_count }}</span>{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</body></html>
@@ -0,0 +1,39 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);
background: #ffffff;
display: flex;
flex-direction: column;
}
.day-section { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; overflow: hidden; }
.day-section + .day-section { border-top: 1px solid #e2e6ec; }
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 8px 16px; overflow: hidden; }
.day-weather-row { display: flex; gap: 14px; margin-top: 4px; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
.day-weather-icon { line-height: 1; }
.day-rows { flex: 1 1 auto; padding: 6px 14px; overflow: hidden; }
.day-row { display: flex; align-items: center; gap: 8px; }
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.day-chip span { flex: 1 1 0; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-empty, .day-more { color: #5b6674; padding-top: 2px; }
</style></head>
<body>
{% import "_calendar_day_section.html.jinja" as ds %}
<div class="card">
{% 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 %}
</div>
</body></html>
@@ -0,0 +1,60 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);
background: #ffffff;
display: flex;
}
.col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }
.col + .col { border-left: 1px solid #e2e6ec; }
.col-header {
/* Fixed height (not auto) -- every column must be exactly this tall
regardless of whether THIS particular day has a weather entry, or
columns with/without weather misalign their event rows to
different starting Y positions across the week grid. */
height: {{ header_h }}px;
flex: 0 0 auto;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
color: #ffffff; font-weight: 700; font-size: {{ header_size }}px;
padding: 6px 6px;
overflow: hidden;
}
.col-header .label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.col-weather { display: flex; align-items: center; gap: 3px; color: rgba(255,255,255,0.9); font-size: {{ weather_size }}px; margin-top: 2px; }
.col-rows { flex: 1 1 auto; padding: 4px; overflow: hidden; }
.col-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
.col-chip { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; }
.col-summary {
flex: 1 1 0; min-width: 0;
font-size: {{ chip_size }}px; color: #17233b; line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.col-more { font-size: {{ chip_size }}px; color: #5b6674; }
</style></head>
<body>
<div class="card">
{% for col in cols %}
<div class="col">
<div class="col-header">
<div class="label">{{ col.label }}</div>
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}&deg;/{{ col.weather.low }}&deg;{{ unit_suffix }}</span></div>{% endif %}
</div>
<div class="col-rows">
{% for row in col.rows %}
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
{% endfor %}
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
</body></html>
@@ -0,0 +1,27 @@
<!doctype html>
<html><head><style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);
background: #ffffff;
}
.card img {
width: 100%; height: 100%; display: block;
/* "contain", not "cover" -- the source image already went through
compose_into's own crop/fit (e.g. whiteboard's deliberate
letterbox-never-crop mode), so this card must not re-crop it;
the gutter inset is small relative to typical widget sizes, so
"contain" leaves at most a sliver of background visible, not a
real letterbox. */
object-fit: contain;
}
</style></head>
<body>
<div class="card"><img src="data:image/png;base64,{{ image_b64 }}"></div>
</body></html>
@@ -0,0 +1,62 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);
background: #ffffff;
display: flex;
flex-direction: column;
}
.header {
height: {{ header_h }}px;
flex: 0 0 auto;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
display: flex;
align-items: center;
padding: 0 16px;
}
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; line-height: 1; }
.rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
.row { display: flex; align-items: center; gap: 8px; height: {{ row_h }}px; }
.chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.chip span { flex: 1 1 0; }
.box {
width: {{ box_size }}px; height: {{ box_size }}px; border-radius: 3px; flex: 0 0 auto;
border: 2px solid #17233b;
}
.box.done { border-color: {{ accent_start }}; background: {{ accent_start }}; }
.due { font-size: {{ body_size }}px; color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.summary {
font-size: {{ body_size }}px; color: #17233b; line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.empty { font-size: {{ body_size }}px; color: #5b6674; padding-top: 4px; }
.more { font-size: {{ body_size }}px; color: #5b6674; padding-top: 2px; }
</style></head>
<body>
<div class="card">
<div class="header"><div class="title">{{ title }}</div></div>
<div class="rows">
{% if not rows %}
<div class="empty">Nothing outstanding</div>
{% endif %}
{% for row in rows %}
<div class="row">
<div class="chip">{% for c in row.colors %}<span style="background:{{ c }};"></span>{% endfor %}</div>
<div class="box {% if row.done %}done{% endif %}"></div>
{% if row.due %}<div class="due">{{ row.due }}</div>{% endif %}
<div class="summary">{{ row.summary }}</div>
</div>
{% endfor %}
{% if more_count %}<div class="more">+{{ more_count }} more</div>{% endif %}
</div>
</div>
</body></html>
@@ -0,0 +1,35 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "TextFont"; src: url("file://{{ font_regular }}"); font-weight: 400; font-style: normal; }
@font-face { font-family: "TextFont"; src: url("file://{{ font_bold }}"); font-weight: 700; font-style: normal; }
@font-face { font-family: "TextFont"; src: url("file://{{ font_italic }}"); font-weight: 400; font-style: italic; }
@font-face { font-family: "TextFont"; src: url("file://{{ font_bold_italic }}"); font-weight: 700; font-style: italic; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { width: {{ w }}px; height: {{ h }}px; background: {{ bg_color }}; }
.wrap {
width: {{ w - margin * 2 }}px;
min-height: {{ h - margin * 2 }}px;
margin: {{ margin }}px;
font-family: "TextFont", sans-serif;
font-size: {{ size }}px;
line-height: {{ line_height }};
text-align: {{ align }};
color: #000000;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
word-wrap: break-word;
}
p { min-height: 1em; }
</style></head>
<body>
<div class="wrap">
{% for paragraph in paragraphs %}
<p>
{% if not paragraph %}&nbsp;{% endif %}
{% for run in paragraph %}<span style="{% if run.bold %}font-weight:700;{% endif %}{% if run.italic %}font-style:italic;{% endif %}{% if run.underline %}text-decoration:underline;{% endif %}{% if run.color %}color:{{ run.color }};{% endif %}{% if run.bg %}background:{{ run.bg }};{% endif %}">{{ run.text }}</span>{% endfor %}
</p>
{% endfor %}
</div>
</body></html>
+34 -17
View File
@@ -60,6 +60,21 @@ def _format_age(as_of: float) -> str:
return f"{round(delta / 86400)}d ago" 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, def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image: is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's """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) cfg = db.get(BatteryWidgetConfig, widget.id)
mode = cfg.mode if cfg else "detailed" mode = cfg.mode if cfg else "detailed"
palette_rgb = frame.palette_rgb 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) img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
cx = cx0 + cw // 2 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, draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font,
panel_style.battery_fill_color(percent, palette_rgb)) panel_style.battery_fill_color(percent, palette_rgb))
if mode == "detailed": small_font_size = max(11, pct_font_size // 3)
lines = [] small_font = panel_style.font_regular(small_font_size)
estimate_s = battery_estimate_s(frame, db) y = pct_y + pct_font_size + 12
if estimate_s is not None: for line in lines:
lines.append(_format_estimate(estimate_s)) if y + small_font_size > cy0 + ch - 4:
if frame.battery_as_of: break
lines.append(f"Reported {_format_age(frame.battery_as_of)}") lbbox = draw.textbbox((0, 0), line, font=small_font)
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font)
small_font_size = max(11, pct_font_size // 3) y += small_font_size + 6
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 return img
+14
View File
@@ -19,6 +19,8 @@ from __future__ import annotations
from PIL import Image from PIL import Image
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from zoneinfo import ZoneInfo
from ..calendar_render import _build from ..calendar_render import _build
from ..db import widget_locked from ..db import widget_locked
from ..models import CalendarWidgetConfig, Frame, Widget 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) 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 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( return _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h, 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, timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
+20 -3
View File
@@ -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 several couldn't be composed this cycle; it falls back to a small
placeholder instead, the same resilience calendar mode's old photo-inlay placeholder instead, the same resilience calendar mode's old photo-inlay
already had (see routers/device.py's `except HTTPException: pass` around 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 from __future__ import annotations
@@ -18,7 +34,7 @@ from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours from .. import photo_queue, quiet_hours
from ..db import widget_locked 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 ..models import Frame, PhotoWidgetConfig, Widget
from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets
from ._shared import placeholder_image 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: except HTTPException as e:
return placeholder_image(target_w, target_h, ["Photos widget", str(e.detail)[:40]]) 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: def _advance(db: Session, frame: Frame, widget: Widget) -> None:
+9 -1
View File
@@ -32,4 +32,12 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
if not cfg.image: if not cfg.image:
return placeholder_image(target_w, target_h, ["Static image widget", "not configured yet"]) return placeholder_image(target_w, target_h, ["Static image widget", "not configured yet"])
source = Image.open(io.BytesIO(cfg.image)).convert("RGB") 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
+9 -1
View File
@@ -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.""" signature regardless of which ones actually care."""
tasks = get_or_refresh_tasks_for_widget(db, frame, widget) tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
cfg = db.get(TaskWidgetConfig, widget.id) 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 = {} ACTIONS: dict = {}
+22 -2
View File
@@ -224,6 +224,26 @@ def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.I
return img 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, def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image: is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's """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) cfg = db.get(TextWidgetConfig, widget.id)
if cfg is None or not has_text(cfg.content): if cfg is None or not has_text(cfg.content):
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"]) 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: 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 import io
target_w, target_h = logical_render_size(orientation) 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) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG") quantized.convert("RGB").save(buf, format="PNG")
+11 -2
View File
@@ -15,7 +15,7 @@ from PIL import Image
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..image_pipeline import compose_into 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 ..routers.common import get_or_refresh_whiteboard_for_widget
from ._shared import placeholder_image 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 # letterbox, never cropped: unlike a photo, losing part of a
# whiteboard to a crop loses actual content, not just some background # whiteboard to a crop loses actual content, not just some background
# (see the old _render_whiteboard_mode's identical reasoning). # (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: def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
+10
View File
@@ -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 {"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 {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
assert "render_style" in weather_widget_columns # migration 31 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) --- # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
+21 -3
View File
@@ -7,17 +7,19 @@ from __future__ import annotations
import time import time
from app import widgets from PIL import Image
from app import html_render, widgets
from app.models import BatteryWidgetConfig, Frame, Widget 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) frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="battery", x=0, y=0, w=1, h=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()) sort_order=0, created_at=time.time())
db_session.add(widget) db_session.add(widget)
db_session.flush() 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() db_session.commit()
return frame, widget return frame, widget
@@ -81,3 +83,19 @@ def test_no_button_actions():
advance/back/check.""" advance/back/check."""
assert widgets.battery.ACTIONS == {} assert widgets.battery.ACTIONS == {}
assert widgets.battery.ACTION_LABELS == {} 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"
+45
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import time import time
import pytest
from app import widgets from app import widgets
from app.db import widget_locked from app.db import widget_locked
from app.models import CalendarWidgetConfig, Frame, Widget 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) _stub_fetches(monkeypatch)
img = widgets.calendar.render(db_session, frame, widget, 300, 192) img = widgets.calendar.render(db_session, frame, widget, 300, 192)
assert img.size == (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)
+29
View File
@@ -109,6 +109,35 @@ def test_advance_action_is_a_no_op_when_unconfigured(db_session):
assert cfg.current_asset_id == "" 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): def test_render_does_not_advance_when_locked(db_session, monkeypatch):
frame, widget = _make_widget(db_session) frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object()) monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
+15
View File
@@ -72,3 +72,18 @@ def test_no_button_actions():
"""A fixed uploaded image -- nothing to advance/back/check.""" """A fixed uploaded image -- nothing to advance/back/check."""
assert widgets.static_image.ACTIONS == {} assert widgets.static_image.ACTIONS == {}
assert widgets.static_image.ACTION_LABELS == {} 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"
+24
View File
@@ -129,3 +129,27 @@ def test_no_button_actions():
weather -- nothing to advance/back/force.""" weather -- nothing to advance/back/force."""
assert widgets.tasks.ACTIONS == {} assert widgets.tasks.ACTIONS == {}
assert widgets.tasks.ACTION_LABELS == {} 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"
+21
View File
@@ -121,3 +121,24 @@ def test_no_button_actions():
"""Fixed authored text -- nothing to advance/back/check.""" """Fixed authored text -- nothing to advance/back/check."""
assert widgets.text.ACTIONS == {} assert widgets.text.ACTIONS == {}
assert widgets.text.ACTION_LABELS == {} 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"
+20 -2
View File
@@ -16,13 +16,14 @@ from app import widgets
from app.models import Frame, Widget, WhiteboardWidgetConfig 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) frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time()) sort_order=0, created_at=time.time())
db_session.add(widget) db_session.add(widget)
db_session.flush() 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() db_session.commit()
return frame, widget 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 """No real "next"/"back" concept for a static board -- both physical
buttons mean the same thing for a whiteboard widget.""" buttons mean the same thing for a whiteboard widget."""
assert set(widgets.whiteboard.ACTIONS) == {"check_now"} 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"