Widget system Phase 3: calendar widgets become size-aware
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 1m58s

calendar_render.py's _build_* functions now take a real target box
and pick font sizes/margins from three discrete size tiers (nearest
pixel-area fit) instead of always laying out at full panel size and
resizing after the fact -- a calendar widget placed smaller than the
full panel gets an actually-legible layout instead of shrunk text.
Month view falls back to agenda below the smallest tier, where 7
columns can no longer stay readable.

The old photo-inlay split (inlay_region/_content_region/_paste_inlay)
is deleted along with it -- arbitrary widget placement already
subsumes what a fixed half-panel split did, and every call site has
passed photo_inlay=None since the Phase 2 cutover.

Also adds HTTP-level test coverage for GET .../preview/calendar,
which had none before this -- it's what caught a stale photo_inlay
kwarg left over from the _build signature change that would have
TypeError'd on every request.
This commit is contained in:
2026-07-24 09:38:10 -04:00
parent 37bd657299
commit 99069ba5fe
7 changed files with 288 additions and 170 deletions
+144 -136
View File
@@ -27,7 +27,6 @@ from .image_pipeline import (
_apply_manage_overlay,
_quantize,
_transpose_and_pack,
compose_into,
draw_text,
logical_render_size,
)
@@ -328,39 +327,35 @@ def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.Ima
return text[:lo] + ellipsis if lo else ellipsis
# --- Photo inlay region, shared by every view --------------------------
def inlay_region(orientation: str) -> tuple[int, int, int, int]:
"""The photo-inlay's (x0, y0, w, h) within the full logical canvas --
the long-axis half (left in landscape, top in portrait), same
proportion for every view so switching views doesn't reflow the
photo. Also used by routers/common.py's build_manage_content to
correctly reposition manage-overlay face labels when a photo inlay
is active (they'd otherwise be computed as if the photo filled the
whole panel)."""
logical_w, logical_h = logical_render_size(orientation)
if logical_w >= logical_h:
return 0, 0, logical_w // 2, logical_h
return 0, 0, logical_w, logical_h // 2
# --- Size tiers ---------------------------------------------------------
#
# A calendar widget can now be placed at any grid footprint (see
# app/grid.py), not just the full panel -- these three discrete tiers
# (chosen by nearest-fit against the target box's pixel area) drive font
# sizes/margins instead of continuously scaling a layout that was tuned
# by eye for the full ~800x480 panel, which would risk ugly proportions
# at odd in-between sizes. Area-based (not width/height-based) so the
# same footprint tiers the same regardless of landscape/portrait target
# box shape.
_TIER_LARGE_AREA = 280_000 # near/at a full 800x480 panel (384,000px^2)
_TIER_MEDIUM_AREA = 120_000 # roughly a half-panel split
def _content_region(orientation: str, has_inlay: bool) -> tuple[int, int, int, int]:
"""The remaining (x0, y0, w, h) a view's own content (list/grid)
draws into -- the whole canvas normally, or whatever inlay_region()
didn't claim."""
logical_w, logical_h = logical_render_size(orientation)
if not has_inlay:
return 0, 0, logical_w, logical_h
ix0, iy0, iw, ih = inlay_region(orientation)
if logical_w >= logical_h:
return iw, 0, logical_w - iw, logical_h
return 0, ih, logical_w, logical_h - ih
def _size_tier(target_w: int, target_h: int) -> str:
area = target_w * target_h
if area >= _TIER_LARGE_AREA:
return "large"
if area >= _TIER_MEDIUM_AREA:
return "medium"
return "small"
def _paste_inlay(img: Image.Image, photo_inlay: Image.Image, orientation: str) -> None:
x0, y0, w, h = inlay_region(orientation)
photo = compose_into(photo_inlay, None, w, h, "crop_fill")
img.paste(photo, (x0, y0))
def _month_view_fits(target_w: int, target_h: int) -> bool:
"""Month view needs real width to keep 7 columns' day numbers and
density dots legible -- below the "small" size tier that stops being
true, so _build falls back to agenda view instead of drawing an
unreadable grid."""
return _size_tier(target_w, target_h) != "small"
# --- Weather strip, agenda/today & tomorrow/week views only (never
@@ -577,70 +572,75 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
y += row_h
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None,
# Per-tier (title, body, weather) font sizes -- "Wednesday, July 22" at
# full size doesn't fit a narrow column, and a narrower box is exactly
# when a smaller font (rather than truncating to "Wednesday...") keeps
# the header actually informative.
_AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16, 13)}
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:
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
if photo_inlay is not None:
_paste_inlay(img, photo_inlay, orientation)
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
# Smaller title when the inlay halves the available width -- "Wednesday,
# July 22" at full size doesn't fit ~360px, and a *narrower* column is
# exactly when a smaller font (rather than truncating to "Wednesday...")
# keeps it actually informative.
title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24)
body_font = ImageFont.load_default(size=22)
weather_font = ImageFont.load_default(size=20 if photo_inlay is None else 16)
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
weather_font = ImageFont.load_default(size=weather_size)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
owners_seen: list[str] = []
_draw_agenda_day(img, draw, day, events, tz, (cx0, cy0, cw, ch), title_font, body_font, owners_seen,
_draw_agenda_day(img, draw, day, events, tz, (0, 0, target_w, target_h), title_font, body_font, owners_seen,
palette_rgb, weather_cities, weather_font, weather_units)
return img
def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None,
_TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (15, 12, 10)}
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:
"""Two _draw_agenda_day sections stacked vertically within the content
region (below each other rather than side-by-side -- narrower than
tall doesn't leave enough width per day for the event-row text once
an inlay's already claimed half the canvas). browse_offset shifts
the whole two-day window together, same "days" unit _build_agenda
already uses, so NEXT/BACK behaves identically across both views."""
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
if photo_inlay is not None:
_paste_inlay(img, photo_inlay, orientation)
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
"""Two _draw_agenda_day sections stacked vertically (below each other
rather than side-by-side -- narrower than tall doesn't leave enough
width per day for the event-row text at smaller sizes). browse_offset
shifts the whole two-day window together, same "days" unit
_build_agenda already uses, so NEXT/BACK behaves identically across
both views."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
title_font = ImageFont.load_default(size=26 if photo_inlay is None else 20)
body_font = ImageFont.load_default(size=18 if photo_inlay is None else 15)
weather_font = ImageFont.load_default(size=16 if photo_inlay is None else 13)
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
weather_font = ImageFont.load_default(size=weather_size)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = ch // 2
section_h = target_h // 2
owners_seen: list[str] = []
for i in range(2):
section_y0 = cy0 + i * section_h
section_y0 = i * section_h
if i > 0:
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
(0, section_y0, target_w, section_h), title_font, body_font, owners_seen,
palette_rgb, weather_cities, weather_font, weather_units)
return img
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None,
# Vertical layout's base (title, body, weather) sizes, before the
# per-day-count reduction below -- same three tiers as every other view.
_WEEK_VERTICAL_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (16, 12, 10)}
# Horizontal layout's (header, chip, weather) sizes.
_WEEK_HORIZONTAL_FONTS = {"large": (18, 14, 12), "medium": (14, 12, 10), "small": (11, 10, 8)}
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", tasks: list[dict] | None = None,
start_offset: int = 0) -> Image.Image:
@@ -658,12 +658,9 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
otherwise "start of the week" doesn't mean much for an arbitrary day
count, so it instead starts `start_offset` days from today (0 =
today, see routers/api_frames.py's api_config_save)."""
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
if photo_inlay is not None:
_paste_inlay(img, photo_inlay, orientation)
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
tier = _size_tier(target_w, target_h)
today = datetime.now(tz).date()
if days == 7:
@@ -675,40 +672,42 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
owners_seen: list[str] = []
if layout == "vertical":
title_font = ImageFont.load_default(size=max(14, 26 - days) if photo_inlay is None else max(11, 20 - days))
body_font = ImageFont.load_default(size=max(11, 18 - days) if photo_inlay is None else max(9, 15 - days))
weather_font = ImageFont.load_default(size=max(9, 16 - days) if photo_inlay is None else max(8, 13 - days))
section_h = ch // days
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
title_font = ImageFont.load_default(size=max(14, title_base - days))
body_font = ImageFont.load_default(size=max(11, body_base - days))
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
section_h = target_h // days
for i in range(day_count):
section_y0 = cy0 + i * section_h
section_y0 = i * section_h
if i > 0:
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
day = week_first_day + timedelta(days=i)
_draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h),
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
title_font, body_font, owners_seen, palette_rgb,
weather_cities, weather_font, weather_units)
if tasks is not None:
section_y0 = cy0 + day_count * section_h
section_y0 = day_count * section_h
if day_count > 0:
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
_draw_tasks(img, draw, (cx0, section_y0, cw, section_h), tasks, title_font, body_font)
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
_draw_tasks(img, draw, (0, section_y0, target_w, section_h), tasks, title_font, body_font)
return img
header_font = ImageFont.load_default(size=18 if photo_inlay is None else 14)
chip_font = ImageFont.load_default(size=14 if photo_inlay is None else 12)
weather_font = ImageFont.load_default(size=12 if photo_inlay is None else 10)
col_w = (cw - MARGIN * 2) // days
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
header_font = ImageFont.load_default(size=header_size)
chip_font = ImageFont.load_default(size=chip_size)
weather_font = ImageFont.load_default(size=weather_size)
col_w = (target_w - MARGIN * 2) // days
header_h = 44
for col in range(day_count):
day = week_first_day + timedelta(days=col)
x0 = cx0 + MARGIN + col * col_w
x0 = MARGIN + col * col_w
if col > 0:
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
draw_text(img, (x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
y = cy0 + MARGIN + header_h
y = MARGIN + header_h
# Columns are narrow, so only what actually fits gets drawn (see
# _draw_weather_row) -- typically one city, no label (the column
# itself makes which day it's for obvious; a city name wouldn't fit
@@ -718,7 +717,7 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
icon_r=8, font=weather_font, units=weather_units, show_labels=False)
row_h = chip_font.size + 10
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
max_rows = max(0, (target_h - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz)
for i, event in enumerate(day_events):
if i >= max_rows:
@@ -738,47 +737,51 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
if tasks is not None:
col = day_count
x0 = cx0 + MARGIN + col * col_w
x0 = MARGIN + col * col_w
if col > 0:
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
_draw_tasks(img, draw, (x0, cy0, col_w, ch), tasks, header_font, chip_font, margin=6)
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
_draw_tasks(img, draw, (x0, 0, col_w, target_h), tasks, header_font, chip_font, margin=6)
return img
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None) -> Image.Image:
# Only "large"/"medium" in practice -- _build falls back to agenda view
# below the "small" tier (see _month_view_fits) -- but keyed defensively
# by tier rather than a bare bool so a future tier addition can't
# silently fall through to a KeyError here.
_MONTH_FONTS = {"large": (16, 18), "medium": (12, 13), "small": (12, 13)}
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:
"""Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
if photo_inlay is not None:
_paste_inlay(img, photo_inlay, orientation)
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
header_font = ImageFont.load_default(size=16 if photo_inlay is None else 12)
day_font = ImageFont.load_default(size=18 if photo_inlay is None else 13)
header_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)]
header_font = ImageFont.load_default(size=header_size)
day_font = ImageFont.load_default(size=day_size)
today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
col_w = (cw - MARGIN * 2) // 7
col_w = (target_w - MARGIN * 2) // 7
header_h = 28
grid_top = cy0 + MARGIN + header_h
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
grid_top = MARGIN + header_h
row_h = (target_h - MARGIN - grid_top) // len(weeks)
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
for col, name in enumerate(day_names):
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font, MUTED)
draw_text(img, (MARGIN + col * col_w + 6, MARGIN), name[:3], header_font, MUTED)
owners_seen: list[str] = []
dot_r = 6
for row, week in enumerate(weeks):
for col, day in enumerate(week):
x0 = cx0 + MARGIN + col * col_w
x0 = MARGIN + col * col_w
y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month
@@ -808,23 +811,26 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "
"month": _build_month}
def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str,
photo_inlay: Image.Image | None, fetch_summary: str, week_start: int,
palette_rgb: list | None = None,
def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
fetch_summary: str, 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", tasks: list[dict] | None = None,
week_start_offset: int = 0) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
if view == "agenda":
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
effective_view = view
if view == "month" and not _month_view_fits(target_w, target_h):
effective_view = "agenda"
if effective_view == "agenda":
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
elif view == "today_tomorrow":
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
elif effective_view == "today_tomorrow":
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
elif view == "week":
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb,
elif effective_view == "week":
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
elif view == "month":
elif effective_view == "month":
# Never given weather or tasks -- no room for either at typical
# month-cell size, same reasoning that already keeps this view
# to density dots instead of literal event text (see
@@ -832,33 +838,34 @@ def _build(events: list[dict], view: str, browse_offset: int, orientation: str,
# through, though -- that's a different concern (legibility of
# individual events) than needing a whole extra strip/slot of
# content.
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb)
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
else:
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
if fetch_summary:
font = ImageFont.load_default(size=14)
logical_w, logical_h = img.size
draw_text(img, (MARGIN, logical_h - MARGIN - font.size), fetch_summary, font, MUTED)
font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11)
draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED)
return img
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None,
palette_rgb: list | None, timezone: str,
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
"""Renders one of CALENDAR_VIEWS to the panel's packed format. Always
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every
other renderer honors. weather_cities is routers/common.py's
get_or_refresh_weather() cache, or None/[] to omit the weather strip
entirely (also always omitted for view == "month"). tasks is
get_or_refresh_tasks()'s cache, or None to omit the task list
entirely -- only ever drawn for view == "week", see _build_week."""
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed
format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
invariant every other renderer honors. weather_cities is
routers/common.py's get_or_refresh_weather() cache, or None/[] to
omit the weather strip entirely (also always omitted for view ==
"month"). tasks is get_or_refresh_tasks()'s cache, or None to omit
the task list entirely -- only ever drawn for view == "week", see
_build_week."""
target_w, target_h = logical_render_size(orientation)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
@@ -866,7 +873,7 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None,
palette_rgb: list | None, timezone: str,
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
@@ -874,7 +881,8 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
"""Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame."""
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
target_w, target_h = logical_render_size(orientation)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
+7 -7
View File
@@ -39,13 +39,13 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
computed here won't match what's actually on screen.
`region` is (x0, y0, w, h): where in the logical canvas the photo
actually landed, if not the whole thing -- e.g. calendar mode's
agenda photo-inlay only occupies half the panel (see
calendar_render.inlay_region), and without this a label would be
placed as if the photo filled the entire canvas, landing well off
where the inlaid photo actually is. None (the default) means the
photo fills the whole logical canvas, matching every other caller
(photos mode always renders full-panel).
actually landed, if not the whole thing -- e.g. a photo widget placed
in one corner of the panel rather than full-screen (see
routers/common.py's build_manage_content, which passes each photo
widget's own placement rect) -- without this a label would be placed
as if the photo filled the entire canvas, landing well off where the
widget actually is. None (the default) means the photo fills the
whole logical canvas.
The placement math matches render_frame()'s own composition step
exactly (see image_pipeline._placement_transform, shared so the two
+5 -5
View File
@@ -388,11 +388,11 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
(paste, enhance once, overlay once, quantize once, pack once) from
"compose one photo" to "paste N already-rendered regions, then run
the same single shared pipeline over the result." Not a
restructuring: the calendar photo-inlay feature has always pasted a
second, independently-composed image onto the canvas before
`_enhance`/`_quantize` ran exactly once over the whole thing (see
calendar_render.py's _paste_inlay) -- this just generalizes that from
a fixed 1-2 region split to an arbitrary list.
restructuring: the calendar mode's old photo-inlay feature already
pasted a second, independently-composed image onto the canvas before
`_enhance`/`_quantize` ran exactly once over the whole thing -- this
just generalizes that from a fixed 1-2 region split to an arbitrary
list.
Each region is (rect, image): rect is (x, y, w, h) in *logical*
(pre-rotation) canvas space -- the same space logical_render_size(
+1 -1
View File
@@ -601,7 +601,7 @@ def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session
)
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, photo_inlay=None, fetch_summary=summary,
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, tasks=tasks,
+9 -15
View File
@@ -2,15 +2,12 @@
widget's own region -- the widget-system analogue of routers/device.py's
old _render_calendar_mode/_advance_calendar_mode/_back_calendar_mode.
Full-panel-only in this phase: calendar_render.py's layout math (font
sizes, margins, row heights) is still tuned for a full ~800x480 canvas,
not derived from an arbitrary target box -- see calendar_render._build.
render() below builds at the frame's own logical_render_size(orientation)
and resizes to whatever target box it's actually asked for, which is
correct today (the only calendar widget that exists pre-Phase-4a is the
single auto-migrated full-panel one) but not yet a real "small calendar
widget" layout -- that's a later phase's job (see the project's plan
file), not a shortcut being silently taken here.
render() builds directly at whatever target box it's asked for --
calendar_render.py's layout math picks from discrete size tiers (see
its own _size_tier) rather than always laying out at full panel size and
resizing after the fact, so a small placed calendar widget gets an
actually-legible small-size layout instead of a shrunk-down full-size
one.
"Photo inlay" (calendar mode's old half-and-half photo split) has no
widget-system equivalent -- place an independent photo widget alongside
@@ -55,16 +52,13 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
if (cfg.view == "week" and cfg.tasks_enabled) else None
)
img = _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, orientation=frame.orientation,
timezone=frame.timezone, photo_inlay=None, fetch_summary=fetch_summary, week_start=cfg.week_start,
return _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
week_days=cfg.week_days, week_layout=cfg.week_layout, tasks=tasks,
week_start_offset=cfg.week_start_offset,
)
if img.size != (target_w, target_h):
img = img.resize((target_w, target_h), Image.LANCZOS)
return img
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
@@ -0,0 +1,60 @@
"""GET /api/frames/{id}/preview/calendar -- the Calendar tab's live
render preview. No prior coverage existed for this endpoint; added
after a Phase 3 refactor (calendar_render.py's size-tier rewrite, see
the widget-system plan) left a stale photo_inlay=None kwarg here that
would have TypeError'd on the very next request -- nothing in the
existing suite actually called this endpoint to catch it."""
from __future__ import annotations
import time
from app.models import CalendarWidgetConfig, Frame, FrameCalendar, Widget
from .conftest import csrf_headers
def _configure_calendar_widget(db_session) -> Widget:
client_frame = db_session.get(Frame, 1)
widget = Widget(frame_id=client_frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id, view="agenda"))
db_session.commit()
return widget
def test_preview_calendar_requires_a_calendar_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/api/frames/1/preview/calendar")
assert resp.status_code == 400
assert "widget" in resp.json()["detail"].lower()
def test_preview_calendar_requires_an_included_calendar(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_configure_calendar_widget(db_session)
resp = client.get("/api/frames/1/preview/calendar")
assert resp.status_code == 400
assert "calendar" in resp.json()["detail"].lower()
def test_preview_calendar_renders_a_png(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_configure_calendar_widget(db_session)
alice = db_session.get(Frame, 1).owner
alice.calendar_ics_url = "http://example.invalid/alice.ics"
db_session.add(FrameCalendar(frame_id=1, user_id=alice.id, calendar_key="ics",
calendar_label="My calendar", included=True))
db_session.commit()
monkeypatch.setattr(
"app.routers.api_frames.get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""),
)
resp = client.get("/api/frames/1/preview/calendar", headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert resp.content[:8] == b"\x89PNG\r\n\x1a\n"
+62 -6
View File
@@ -43,12 +43,11 @@ def test_render_produces_a_correctly_sized_image(db_session, monkeypatch):
assert img.mode == "RGB"
def test_render_resizes_when_target_differs_from_full_panel(db_session, monkeypatch):
"""Phase-1 calendar widgets are still full-panel-layout internally
(see app/widgets/calendar.py's own module docstring) -- resizing to
fit whatever target box is asked for keeps render_panel's contract
(exact target_w x target_h) satisfied even before real small-widget
layout support lands."""
def test_render_produces_a_correctly_sized_image_below_full_panel(db_session, monkeypatch):
"""A calendar widget placed smaller than the full panel must still
come back at exactly the target box -- render_panel's contract --
now laid out directly at that size (see calendar_render._size_tier)
rather than built full-size and resized down after the fact."""
frame, widget = _make_widget(db_session, view="week")
_stub_fetches(monkeypatch)
@@ -56,6 +55,37 @@ def test_render_resizes_when_target_differs_from_full_panel(db_session, monkeypa
assert img.size == (250, 150)
def test_render_at_minimum_grid_footprint_for_every_view(db_session, monkeypatch):
"""grid.MIN_FOOTPRINT["calendar"] is (3, 2) cells -- on an 8x5 grid
against a full 800x480 panel that's a 300x192 box, the smallest a
calendar widget can actually be placed at. Every view (including
month, which falls back to agenda below the "small" size tier -- see
calendar_render._month_view_fits) must still render at exactly that
size without error."""
from app.calendar_render import CALENDAR_VIEWS
_stub_fetches(monkeypatch, events=[
{"summary": "Standup", "start": "2026-08-01T09:00:00+00:00", "end": "2026-08-01T09:15:00+00:00",
"all_day": False, "sources": [{"owner_display_name": "Alice", "color_index": None}]},
])
for view in CALENDAR_VIEWS:
frame, widget = _make_widget(db_session, view=view)
img = widgets.calendar.render(db_session, frame, widget, 300, 192)
assert img.size == (300, 192), view
def test_render_at_representative_footprints(db_session, monkeypatch):
"""A handful of footprints spanning all three size tiers (see
calendar_render._size_tier) -- small (below half-panel), medium
(roughly half-panel, the old photo-inlay's proportions), and large
(full panel) -- render without error at exactly the requested size."""
_stub_fetches(monkeypatch)
for target_w, target_h in [(300, 192), (400, 480), (800, 480)]:
frame, widget = _make_widget(db_session, view="agenda")
img = widgets.calendar.render(db_session, frame, widget, target_w, target_h)
assert img.size == (target_w, target_h)
def test_weather_only_fetched_when_enabled(db_session, monkeypatch):
frame, widget = _make_widget(db_session, view="agenda", weather_enabled=False)
calls = []
@@ -125,3 +155,29 @@ def test_button_triggered_render_does_not_reset_browse_offset(db_session, monkey
widgets.calendar.render(db_session, frame, widget, 400, 300, is_normal_wake=False)
assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 1
# --- calendar_render's size tiers ---
def test_size_tier_thresholds():
from app.calendar_render import _size_tier
assert _size_tier(800, 480) == "large" # full panel
assert _size_tier(400, 480) == "medium" # old photo-inlay's half-panel split
assert _size_tier(300, 192) == "small" # grid.MIN_FOOTPRINT["calendar"]'s 3x2 cells
def test_month_view_falls_back_to_agenda_layout_below_small_tier(db_session, monkeypatch):
"""Month view needs real column width to stay legible -- at the
minimum calendar footprint, render() should still succeed (producing
an agenda-shaped render instead of an unreadable grid), not error or
silently draw garbage."""
from app.calendar_render import _month_view_fits
assert not _month_view_fits(300, 192)
assert _month_view_fits(800, 480)
frame, widget = _make_widget(db_session, view="month")
_stub_fetches(monkeypatch)
img = widgets.calendar.render(db_session, frame, widget, 300, 192)
assert img.size == (300, 192)