diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index 3e6c8a1..ea4fe81 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -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) diff --git a/server/app/face_labels.py b/server/app/face_labels.py index 4a670af..c3586e5 100644 --- a/server/app/face_labels.py +++ b/server/app/face_labels.py @@ -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 diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index 272e6a1..96c18ba 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -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( diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index eb1e3e3..69b4bdb 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -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, diff --git a/server/app/widgets/calendar.py b/server/app/widgets/calendar.py index 83b6038..244f48d 100644 --- a/server/app/widgets/calendar.py +++ b/server/app/widgets/calendar.py @@ -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: diff --git a/server/tests/test_calendar_preview_endpoint.py b/server/tests/test_calendar_preview_endpoint.py new file mode 100644 index 0000000..d5c4eae --- /dev/null +++ b/server/tests/test_calendar_preview_endpoint.py @@ -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" diff --git a/server/tests/test_widgets_calendar.py b/server/tests/test_widgets_calendar.py index e0fd64d..4f4d3be 100644 --- a/server/tests/test_widgets_calendar.py +++ b/server/tests/test_widgets_calendar.py @@ -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)