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, _apply_manage_overlay,
_quantize, _quantize,
_transpose_and_pack, _transpose_and_pack,
compose_into,
draw_text, draw_text,
logical_render_size, 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 return text[:lo] + ellipsis if lo else ellipsis
# --- Photo inlay region, shared by every view -------------------------- # --- Size tiers ---------------------------------------------------------
#
def inlay_region(orientation: str) -> tuple[int, int, int, int]: # A calendar widget can now be placed at any grid footprint (see
"""The photo-inlay's (x0, y0, w, h) within the full logical canvas -- # app/grid.py), not just the full panel -- these three discrete tiers
the long-axis half (left in landscape, top in portrait), same # (chosen by nearest-fit against the target box's pixel area) drive font
proportion for every view so switching views doesn't reflow the # sizes/margins instead of continuously scaling a layout that was tuned
photo. Also used by routers/common.py's build_manage_content to # by eye for the full ~800x480 panel, which would risk ugly proportions
correctly reposition manage-overlay face labels when a photo inlay # at odd in-between sizes. Area-based (not width/height-based) so the
is active (they'd otherwise be computed as if the photo filled the # same footprint tiers the same regardless of landscape/portrait target
whole panel).""" # box shape.
logical_w, logical_h = logical_render_size(orientation) _TIER_LARGE_AREA = 280_000 # near/at a full 800x480 panel (384,000px^2)
if logical_w >= logical_h: _TIER_MEDIUM_AREA = 120_000 # roughly a half-panel split
return 0, 0, logical_w // 2, logical_h
return 0, 0, logical_w, logical_h // 2
def _content_region(orientation: str, has_inlay: bool) -> tuple[int, int, int, int]: def _size_tier(target_w: int, target_h: int) -> str:
"""The remaining (x0, y0, w, h) a view's own content (list/grid) area = target_w * target_h
draws into -- the whole canvas normally, or whatever inlay_region() if area >= _TIER_LARGE_AREA:
didn't claim.""" return "large"
logical_w, logical_h = logical_render_size(orientation) if area >= _TIER_MEDIUM_AREA:
if not has_inlay: return "medium"
return 0, 0, logical_w, logical_h return "small"
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 _paste_inlay(img: Image.Image, photo_inlay: Image.Image, orientation: str) -> None: def _month_view_fits(target_w: int, target_h: int) -> bool:
x0, y0, w, h = inlay_region(orientation) """Month view needs real width to keep 7 columns' day numbers and
photo = compose_into(photo_inlay, None, w, h, "crop_fill") density dots legible -- below the "small" size tier that stops being
img.paste(photo, (x0, y0)) 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 # --- 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 y += row_h
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, # Per-tier (title, body, weather) font sizes -- "Wednesday, July 22" at
photo_inlay: Image.Image | None, palette_rgb: list | None = None, # full size doesn't fit a narrow column, and a narrower box is exactly
weather_cities: list[dict] | None = None, # 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: weather_units: str = "fahrenheit") -> Image.Image:
logical_w, logical_h = logical_render_size(orientation) img = Image.new("RGB", (target_w, target_h), BG)
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)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
# Smaller title when the inlay halves the available width -- "Wednesday, title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
# July 22" at full size doesn't fit ~360px, and a *narrower* column is title_font = ImageFont.load_default(size=title_size)
# exactly when a smaller font (rather than truncating to "Wednesday...") body_font = ImageFont.load_default(size=body_size)
# keeps it actually informative. weather_font = ImageFont.load_default(size=weather_size)
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)
day = datetime.now(tz).date() + timedelta(days=browse_offset) day = datetime.now(tz).date() + timedelta(days=browse_offset)
owners_seen: list[str] = [] 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) palette_rgb, weather_cities, weather_font, weather_units)
return img return img
def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, _TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (15, 12, 10)}
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None,
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: weather_units: str = "fahrenheit") -> Image.Image:
"""Two _draw_agenda_day sections stacked vertically within the content """Two _draw_agenda_day sections stacked vertically (below each other
region (below each other rather than side-by-side -- narrower than rather than side-by-side -- narrower than tall doesn't leave enough
tall doesn't leave enough width per day for the event-row text once width per day for the event-row text at smaller sizes). browse_offset
an inlay's already claimed half the canvas). browse_offset shifts shifts the whole two-day window together, same "days" unit
the whole two-day window together, same "days" unit _build_agenda _build_agenda already uses, so NEXT/BACK behaves identically across
already uses, so NEXT/BACK behaves identically across both views.""" both views."""
logical_w, logical_h = logical_render_size(orientation) img = Image.new("RGB", (target_w, target_h), BG)
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)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
title_font = ImageFont.load_default(size=26 if photo_inlay is None else 20) title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
body_font = ImageFont.load_default(size=18 if photo_inlay is None else 15) title_font = ImageFont.load_default(size=title_size)
weather_font = ImageFont.load_default(size=16 if photo_inlay is None else 13) 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) start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = ch // 2 section_h = target_h // 2
owners_seen: list[str] = [] owners_seen: list[str] = []
for i in range(2): for i in range(2):
section_y0 = cy0 + i * section_h section_y0 = i * section_h
if i > 0: 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, _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) palette_rgb, weather_cities, weather_font, weather_units)
return img return img
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, # Vertical layout's base (title, body, weather) sizes, before the
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None, # 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", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None, days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None,
start_offset: int = 0) -> Image.Image: 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 otherwise "start of the week" doesn't mean much for an arbitrary day
count, so it instead starts `start_offset` days from today (0 = count, so it instead starts `start_offset` days from today (0 =
today, see routers/api_frames.py's api_config_save).""" today, see routers/api_frames.py's api_config_save)."""
logical_w, logical_h = logical_render_size(orientation) img = Image.new("RGB", (target_w, target_h), BG)
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)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
tier = _size_tier(target_w, target_h)
today = datetime.now(tz).date() today = datetime.now(tz).date()
if days == 7: if days == 7:
@@ -675,40 +672,42 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
owners_seen: list[str] = [] owners_seen: list[str] = []
if layout == "vertical": if layout == "vertical":
title_font = ImageFont.load_default(size=max(14, 26 - days) if photo_inlay is None else max(11, 20 - days)) title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
body_font = ImageFont.load_default(size=max(11, 18 - days) if photo_inlay is None else max(9, 15 - days)) title_font = ImageFont.load_default(size=max(14, title_base - days))
weather_font = ImageFont.load_default(size=max(9, 16 - days) if photo_inlay is None else max(8, 13 - days)) body_font = ImageFont.load_default(size=max(11, body_base - days))
section_h = ch // days weather_font = ImageFont.load_default(size=max(9, weather_base - days))
section_h = target_h // days
for i in range(day_count): for i in range(day_count):
section_y0 = cy0 + i * section_h section_y0 = i * section_h
if i > 0: 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) 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, title_font, body_font, owners_seen, palette_rgb,
weather_cities, weather_font, weather_units) weather_cities, weather_font, weather_units)
if tasks is not None: if tasks is not None:
section_y0 = cy0 + day_count * section_h section_y0 = day_count * section_h
if day_count > 0: if day_count > 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_tasks(img, draw, (cx0, section_y0, cw, section_h), tasks, title_font, body_font) _draw_tasks(img, draw, (0, section_y0, target_w, section_h), tasks, title_font, body_font)
return img return img
header_font = ImageFont.load_default(size=18 if photo_inlay is None else 14) header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
chip_font = ImageFont.load_default(size=14 if photo_inlay is None else 12) header_font = ImageFont.load_default(size=header_size)
weather_font = ImageFont.load_default(size=12 if photo_inlay is None else 10) chip_font = ImageFont.load_default(size=chip_size)
col_w = (cw - MARGIN * 2) // days weather_font = ImageFont.load_default(size=weather_size)
col_w = (target_w - MARGIN * 2) // days
header_h = 44 header_h = 44
for col in range(day_count): for col in range(day_count):
day = week_first_day + timedelta(days=col) day = week_first_day + timedelta(days=col)
x0 = cx0 + MARGIN + col * col_w x0 = MARGIN + col * col_w
if col > 0: 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')}" 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 # Columns are narrow, so only what actually fits gets drawn (see
# _draw_weather_row) -- typically one city, no label (the column # _draw_weather_row) -- typically one city, no label (the column
# itself makes which day it's for obvious; a city name wouldn't fit # 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, 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) icon_r=8, font=weather_font, units=weather_units, show_labels=False)
row_h = chip_font.size + 10 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) day_events = _events_on_day(events, day, tz)
for i, event in enumerate(day_events): for i, event in enumerate(day_events):
if i >= max_rows: 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: if tasks is not None:
col = day_count col = day_count
x0 = cx0 + MARGIN + col * col_w x0 = MARGIN + col * col_w
if col > 0: if col > 0:
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE) draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
_draw_tasks(img, draw, (x0, cy0, col_w, ch), tasks, header_font, chip_font, margin=6) _draw_tasks(img, draw, (x0, 0, col_w, target_h), tasks, header_font, chip_font, margin=6)
return img return img
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, # Only "large"/"medium" in practice -- _build falls back to agenda view
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None) -> Image.Image: # 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 """Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.""" 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", (target_w, target_h), BG)
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)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
header_font = ImageFont.load_default(size=16 if photo_inlay is None else 12) header_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)]
day_font = ImageFont.load_default(size=18 if photo_inlay is None else 13) header_font = ImageFont.load_default(size=header_size)
day_font = ImageFont.load_default(size=day_size)
today = datetime.now(tz).date() today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset) 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)) 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 header_h = 28
grid_top = cy0 + MARGIN + header_h grid_top = MARGIN + header_h
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks) row_h = (target_h - MARGIN - grid_top) // len(weeks)
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start] day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
for col, name in enumerate(day_names): 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] = [] owners_seen: list[str] = []
dot_r = 6 dot_r = 6
for row, week in enumerate(weeks): for row, week in enumerate(weeks):
for col, day in enumerate(week): for col, day in enumerate(week):
x0 = cx0 + MARGIN + col * col_w x0 = MARGIN + col * col_w
y0 = grid_top + row * row_h y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE) draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month in_month = day.month == target_month.month
@@ -808,23 +811,26 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "
"month": _build_month} "month": _build_month}
def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str, def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
photo_inlay: Image.Image | None, fetch_summary: str, week_start: int, fetch_summary: str, week_start: int, palette_rgb: list | None = None,
palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None, week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None,
week_start_offset: int = 0) -> Image.Image: week_start_offset: int = 0) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC") tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
if view == "agenda": effective_view = view
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb, 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) weather_cities, weather_units)
elif view == "today_tomorrow": elif effective_view == "today_tomorrow":
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay, palette_rgb, img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units) weather_cities, weather_units)
elif view == "week": elif effective_view == "week":
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb, 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) 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 # Never given weather or tasks -- no room for either at typical
# month-cell size, same reasoning that already keeps this view # month-cell size, same reasoning that already keeps this view
# to density dots instead of literal event text (see # 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 # through, though -- that's a different concern (legibility of
# individual events) than needing a whole extra strip/slot of # individual events) than needing a whole extra strip/slot of
# content. # 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: 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) weather_cities, weather_units)
if fetch_summary: if fetch_summary:
font = ImageFont.load_default(size=14) font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11)
logical_w, logical_h = img.size draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED)
draw_text(img, (MARGIN, logical_h - MARGIN - font.size), fetch_summary, font, MUTED)
return img return img
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str, 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, fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal", week_days: int = 7, week_layout: str = "horizontal",
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes: tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
"""Renders one of CALENDAR_VIEWS to the panel's packed format. Always """Renders one of CALENDAR_VIEWS full-panel to the panel's packed
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
other renderer honors. weather_cities is routers/common.py's invariant every other renderer honors. weather_cities is
get_or_refresh_weather() cache, or None/[] to omit the weather strip routers/common.py's get_or_refresh_weather() cache, or None/[] to
entirely (also always omitted for view == "month"). tasks is omit the weather strip entirely (also always omitted for view ==
get_or_refresh_tasks()'s cache, or None to omit the task list "month"). tasks is get_or_refresh_tasks()'s cache, or None to omit
entirely -- only ever drawn for view == "week", see _build_week.""" the task list entirely -- only ever drawn for view == "week", see
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start, _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) palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) 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, 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, fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal", 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 """Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame.""" 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) palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) 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. computed here won't match what's actually on screen.
`region` is (x0, y0, w, h): where in the logical canvas the photo `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 actually landed, if not the whole thing -- e.g. a photo widget placed
agenda photo-inlay only occupies half the panel (see in one corner of the panel rather than full-screen (see
calendar_render.inlay_region), and without this a label would be routers/common.py's build_manage_content, which passes each photo
placed as if the photo filled the entire canvas, landing well off widget's own placement rect) -- without this a label would be placed
where the inlaid photo actually is. None (the default) means the as if the photo filled the entire canvas, landing well off where the
photo fills the whole logical canvas, matching every other caller widget actually is. None (the default) means the photo fills the
(photos mode always renders full-panel). whole logical canvas.
The placement math matches render_frame()'s own composition step The placement math matches render_frame()'s own composition step
exactly (see image_pipeline._placement_transform, shared so the two 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 (paste, enhance once, overlay once, quantize once, pack once) from
"compose one photo" to "paste N already-rendered regions, then run "compose one photo" to "paste N already-rendered regions, then run
the same single shared pipeline over the result." Not a the same single shared pipeline over the result." Not a
restructuring: the calendar photo-inlay feature has always pasted a restructuring: the calendar mode's old photo-inlay feature already
second, independently-composed image onto the canvas before pasted a second, independently-composed image onto the canvas before
`_enhance`/`_quantize` ran exactly once over the whole thing (see `_enhance`/`_quantize` ran exactly once over the whole thing -- this
calendar_render.py's _paste_inlay) -- this just generalizes that from just generalizes that from a fixed 1-2 region split to an arbitrary
a fixed 1-2 region split to an arbitrary list. list.
Each region is (rect, image): rect is (x, y, w, h) in *logical* Each region is (rect, image): rect is (x, y, w, h) in *logical*
(pre-rotation) canvas space -- the same space logical_render_size( (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( png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation, 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, week_start=ccfg.week_start,
weather_cities=weather_cities, weather_units=ccfg.weather_units, weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks, 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 widget's own region -- the widget-system analogue of routers/device.py's
old _render_calendar_mode/_advance_calendar_mode/_back_calendar_mode. old _render_calendar_mode/_advance_calendar_mode/_back_calendar_mode.
Full-panel-only in this phase: calendar_render.py's layout math (font render() builds directly at whatever target box it's asked for --
sizes, margins, row heights) is still tuned for a full ~800x480 canvas, calendar_render.py's layout math picks from discrete size tiers (see
not derived from an arbitrary target box -- see calendar_render._build. its own _size_tier) rather than always laying out at full panel size and
render() below builds at the frame's own logical_render_size(orientation) resizing after the fact, so a small placed calendar widget gets an
and resizes to whatever target box it's actually asked for, which is actually-legible small-size layout instead of a shrunk-down full-size
correct today (the only calendar widget that exists pre-Phase-4a is the one.
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.
"Photo inlay" (calendar mode's old half-and-half photo split) has no "Photo inlay" (calendar mode's old half-and-half photo split) has no
widget-system equivalent -- place an independent photo widget alongside 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 if (cfg.view == "week" and cfg.tasks_enabled) else None
) )
img = _build( return _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, orientation=frame.orientation, events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
timezone=frame.timezone, photo_inlay=None, fetch_summary=fetch_summary, week_start=cfg.week_start, 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, 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_days=cfg.week_days, week_layout=cfg.week_layout, tasks=tasks,
week_start_offset=cfg.week_start_offset, 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: 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" assert img.mode == "RGB"
def test_render_resizes_when_target_differs_from_full_panel(db_session, monkeypatch): def test_render_produces_a_correctly_sized_image_below_full_panel(db_session, monkeypatch):
"""Phase-1 calendar widgets are still full-panel-layout internally """A calendar widget placed smaller than the full panel must still
(see app/widgets/calendar.py's own module docstring) -- resizing to come back at exactly the target box -- render_panel's contract --
fit whatever target box is asked for keeps render_panel's contract now laid out directly at that size (see calendar_render._size_tier)
(exact target_w x target_h) satisfied even before real small-widget rather than built full-size and resized down after the fact."""
layout support lands."""
frame, widget = _make_widget(db_session, view="week") frame, widget = _make_widget(db_session, view="week")
_stub_fetches(monkeypatch) _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) 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): def test_weather_only_fetched_when_enabled(db_session, monkeypatch):
frame, widget = _make_widget(db_session, view="agenda", weather_enabled=False) frame, widget = _make_widget(db_session, view="agenda", weather_enabled=False)
calls = [] 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) widgets.calendar.render(db_session, frame, widget, 400, 300, is_normal_wake=False)
assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 1 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)