Widget system Phase 3: calendar widgets become size-aware
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:
+144
-136
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user