"""Renders calendar frame mode's three views (agenda/week/month) into the panel's packed format, following image_pipeline.render_placeholder's own precedent: build an RGB canvas with ImageDraw/ImageFont, then the same _quantize/_transpose_and_pack every other renderer ends on. Event dicts here are calendar_feed.py's shape: {"summary", "start", "end" (ISO 8601 strings), "all_day", "owner_display_name"}. """ from __future__ import annotations import calendar as calendar_module import io from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo from PIL import Image, ImageDraw, ImageFont from .image_pipeline import ( DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, _transpose_and_pack, compose_into, draw_text, logical_render_size, ) CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"] CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)", "week": "Week", "month": "Month"} WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] MARGIN = 20 BG = (255, 255, 255) FG = (0, 0, 0) MUTED = (110, 110, 110) # Was a light gray, but that dithers away to near-invisible once quantized # to the 6-color e-ink palette -- black reads as an actual line on-panel. RULE = (0, 0, 0) # Cycled per distinct owner_display_name so a merged multi-person calendar # can visually tell whose event is whose -- the panel's own non-black/ # white ink colors, skipping black/white (index 0/1 in DEFAULT_PALETTE_RGB) # since those are already the page's text/background. OWNER_COLORS = DEFAULT_PALETTE_RGB[2:] def _owner_color(owner_display_name: str, owners_seen: list[str]) -> tuple[int, int, int]: if owner_display_name not in owners_seen: owners_seen.append(owner_display_name) return OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)] def _event_start(event: dict, tz: ZoneInfo) -> datetime | date: """Parses event["start"] and, for timed events, converts to `tz` -- calendar_feed.py stores whatever timezone each source event carried (often UTC), but display/bucketing needs to happen in the frame's own timezone.""" dt = datetime.fromisoformat(event["start"]) if event["all_day"]: return dt if isinstance(dt, date) and not isinstance(dt, datetime) else dt.date() return dt.astimezone(tz) def _events_on_day(events: list[dict], day: date, tz: ZoneInfo) -> list[dict]: on_day = [e for e in events if _local_date(e, tz) == day] on_day.sort(key=lambda e: (not e["all_day"], e["start"])) return on_day def _local_date(event: dict, tz: ZoneInfo) -> date: start = _event_start(event, tz) return start if isinstance(start, date) and not isinstance(start, datetime) else start.date() def _add_months(d: date, months: int) -> date: total = d.month - 1 + months year = d.year + total // 12 month = total % 12 + 1 day = min(d.day, calendar_module.monthrange(year, month)[1]) return date(year, month, day) def _fmt_time(dt: datetime) -> str: text = dt.strftime("%I:%M %p").lstrip("0") return text if text else "12:00 AM" def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str: """Pixel-width-aware truncation (unlike device.py's char-count _truncate, tuned for a fixed firmware font at a fixed size) -- this module draws at several different sizes, so truncation has to measure the actual font/size in play. Still uses `draw.textlength` for measurement (identical metrics to draw_text's own bbox), just doesn't paint anything.""" if draw.textlength(text, font=font) <= max_width: return text ellipsis = "..." lo, hi = 0, len(text) while lo < hi: mid = (lo + hi + 1) // 2 if draw.textlength(text[:mid] + ellipsis, font=font) <= max_width: lo = mid else: hi = mid - 1 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 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 _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 _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo, region: tuple[int, int, int, int], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont, owners_seen: list[str]) -> None: """Draws one day's header + event rows within `region` (x0, y0, w, h) -- factored out of _build_agenda so the today-and-tomorrow view (_build_today_tomorrow) can stack two of these vertically without duplicating the row-layout/truncation logic.""" x0, y0, w, h = region text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN text_w = w - MARGIN * 2 header = day.strftime("%A, %B ") + str(day.day) draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), title_font) y = text_y0 + title_font.size + 12 draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) y += 12 day_events = _events_on_day(events, day, tz) row_h = body_font.size + 14 max_rows = max(0, (y0 + h - MARGIN - y) // row_h) if not day_events: draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED) for i, event in enumerate(day_events): if i >= max_rows: draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED) break color = _owner_color(event["owner_display_name"], owners_seen) draw.rectangle([text_x0, y + 3, text_x0 + 6, y + row_h - 8], fill=color) time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz)) line = f"{time_str} {event['summary']}" draw_text(img, (text_x0 + 16, y), _truncate_to_width(draw, line, body_font, text_w - 16), body_font) y += row_h def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, photo_inlay: Image.Image | None) -> 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) 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) 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) return img def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, photo_inlay: Image.Image | None) -> 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) 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) start_day = datetime.now(tz).date() + timedelta(days=browse_offset) section_h = ch // 2 owners_seen: list[str] = [] for i in range(2): section_y0 = cy0 + i * section_h if i > 0: draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - 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) return img def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, photo_inlay: Image.Image | None, week_start: int) -> 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) draw = ImageDraw.Draw(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) today = datetime.now(tz).date() days_since_start = (today.weekday() - week_start) % 7 week_first_day = today - timedelta(days=days_since_start) + timedelta(weeks=browse_offset) col_w = (cw - MARGIN * 2) // 7 header_h = 44 owners_seen: list[str] = [] for col in range(7): day = week_first_day + timedelta(days=col) x0 = cx0 + MARGIN + col * col_w if col > 0: draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - 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) y = cy0 + MARGIN + header_h row_h = chip_font.size + 10 max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h) day_events = _events_on_day(events, day, tz) for i, event in enumerate(day_events): if i >= max_rows: draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED) break color = _owner_color(event["owner_display_name"], owners_seen) draw.rectangle([x0 + 4, y + 2, x0 + 8, y + row_h - 6], fill=color) text = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}" draw_text(img, (x0 + 14, y), _truncate_to_width(draw, text, chip_font, col_w - 18), chip_font) y += row_h return img def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, photo_inlay: Image.Image | None, week_start: int) -> 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) 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) 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 header_h = 28 grid_top = cy0 + MARGIN + header_h row_h = (cy0 + ch - 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) owners_seen: list[str] = [] dot_r = 4 for row, week in enumerate(weeks): for col, day in enumerate(week): x0 = cx0 + 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 color = FG if in_month else MUTED if day == today: draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG) draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, color) day_events = _events_on_day(events, day, tz) dot_x = x0 + 8 dot_y = y0 + row_h - 14 for i, event in enumerate(day_events[:4]): color = _owner_color(event["owner_display_name"], owners_seen) draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=color) dot_x += dot_r * 2 + 4 if len(day_events) > 4: draw_text(img, (dot_x, dot_y - 4), f"+{len(day_events) - 4}", header_font, MUTED) return img _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "week": _build_week, "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) -> Image.Image: tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC") if view == "agenda": img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay) elif view == "today_tomorrow": img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay) elif view == "week": img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start) elif view == "month": img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start) else: img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay) 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) 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, fetch_summary: str = "", manage: dict | None = None, week_start: 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.""" img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start) img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) return _transpose_and_pack(quantized, orientation) 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, fetch_summary: str = "", manage: dict | None = None, week_start: int = 0) -> bytes: """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) img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) buf = io.BytesIO() quantized.convert("RGB").save(buf, format="PNG") return buf.getvalue()