"""Renders calendar frame mode's three views (agenda/week/month), and the separate standalone tasks widget (see models.TaskWidgetConfig -- a task list used to be a calendar-widget-only week-view slot, split out into its own widget type so it isn't tied to a calendar's view/footprint), 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", "sources": [{"owner_display_name", "color_index"}, ...]} -- more than one entry in "sources" means merge_events collapsed several calendars' identical (same title/time) events into one, see _event_colors/panel_style.draw_color_chip below. """ from __future__ import annotations import calendar as calendar_module import io import re from datetime import date, datetime, timedelta from functools import lru_cache from pathlib import Path from zoneinfo import ZoneInfo from PIL import Image, ImageDraw, ImageFont from . import panel_style from .image_pipeline import ( DEFAULT_PALETTE_RGB, EPD_HEIGHT, EPD_WIDTH, _apply_manage_overlay, _quantize, _transpose_and_pack, draw_text, logical_render_size, ) from .weather import weather_category from .weather_render import draw_weather_row 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 carries panel_style.CONTENT_MARGIN's value unchanged (not # re-tuned). BG/FG are this module's own plain black/white -- checkbox # outlines, month-view grid hairlines -- not a text-emphasis concern (no # MUTED gray here anymore -- see panel_style's module docstring for why: # a mid-gray fill has no close palette match and dithers into speckle # once the whole canvas is quantized. Secondary text now reads through # size/weight alone, always exact black). MARGIN = panel_style.CONTENT_MARGIN BG = (255, 255, 255) FG = (0, 0, 0) # Structural dividers/grid lines (between stacked day sections, week # columns, month cells) stay a plain black rule -- gray dithers away to # near-invisible once quantized to the 6-color e-ink palette. Headers # no longer use this: see panel_style.draw_header_bar/theme_color. RULE = (0, 0, 0) # Fallback for any event whose calendar has no manually pinned color # (event["color_index"] is None): cycled per distinct owner_display_name # so a merged multi-person calendar can still 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 _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) -> list[tuple[int, int, int]]: """One color per contributing calendar (event["sources"] -- see calendar_feed.merge_events, which collapses events sharing the exact same title/time across different calendars into one entry with several sources, e.g. a shared family event synced onto more than one person's calendar). Usually just one color; more than one is what tells the "same event, more than one calendar" case apart from an ordinary single-calendar event at render time -- see panel_style.draw_color_chip. Each source's own manually pinned color (FrameCalendar.color_index -- see routers/api_widgets.py's api_widget_calendar_color) resolves against whichever palette this frame actually renders with, so a pinned "Blue" stays this frame's actual blue; a source with no color pinned falls back to the old auto-cycle-by-owner-name behavior. owners_seen is shared across every event/source in a render so that cycle stays consistent view-wide.""" sources = event.get("sources") or [ {"owner_display_name": event.get("owner_display_name"), "color_index": event.get("color_index")} ] colors = [] for source in sources: color_index = source.get("color_index") if color_index is not None: palette = palette_rgb or DEFAULT_PALETTE_RGB colors.append(tuple(palette[color_index])) continue owner_display_name = source.get("owner_display_name") if owner_display_name not in owners_seen: owners_seen.append(owner_display_name) colors.append(OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)]) return 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 _fmt_task_due(due: str | None) -> str: """"2026-07-25" or "2026-07-25T14:00:00+00:00" -> "Jul 25" -- tasks only need a compact reminder of when they're due, not the precision an event's own start/end time gets.""" if not due: return "" try: dt = datetime.fromisoformat(due) except ValueError: return "" d = dt.date() if isinstance(dt, datetime) else dt return d.strftime("%b %-d") # Neither Inter (panel_style.font_bold/font_regular, this module's own # body/title font -- see MARGIN/BG/FG comment above) nor PIL's bundled # default font has emoji glyphs, and PIL/FreeType don't skip an # unsupported codepoint, they substitute a ".notdef" tofu box (a visible # filled rectangle) -- reads as a rendering glitch, not "emoji not # supported". So event titles get drawn with two fonts: the normal # text font for everything else, and one of these for actual emoji runs # (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1, # vendored at app/fonts/ (license alongside at app/fonts/OFL.txt). # # Color (NotoColorEmoji.ttf) is tried first: full-color CBDT bitmap # glyphs, which the panel's own Floyd-Steinberg dithering turns into a # recognizable (if slightly speckled) color rendering rather than a flat # monochrome shape -- confirmed by actually rendering a test agenda row # through the real quantizer, not just theorizing about it. Its one # real quirk: CBDT stores glyphs at a single embedded bitmap size # (_COLOR_EMOJI_NATIVE_SIZE), so every glyph is rasterized once at that # size and scaled down to the target row height, unlike normal vector # text which draws directly at whatever size is asked for. # # NotoEmoji.ttf (monochrome, vector) is the fallback for a deployment # whose Pillow/FreeType wasn't built with embedded color bitmap support # -- confirmed working locally, but that's a build-time detail this # project doesn't control everywhere it might run, so a rendering # failure falls back instead of showing nothing/crashing. _COLOR_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoColorEmoji.ttf" _MONO_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoEmoji.ttf" _COLOR_EMOJI_NATIVE_SIZE = 109 @lru_cache(maxsize=1) def _color_emoji_font() -> ImageFont.FreeTypeFont | None: try: return ImageFont.truetype(str(_COLOR_EMOJI_FONT_PATH), _COLOR_EMOJI_NATIVE_SIZE) except Exception: return None @lru_cache(maxsize=None) def _mono_emoji_font(size: int) -> ImageFont.FreeTypeFont: return ImageFont.truetype(str(_MONO_EMOJI_FONT_PATH), size) @lru_cache(maxsize=512) def _emoji_glyph(run_text: str, target_h: int) -> Image.Image: """One emoji run (consecutive emoji collapse into a single run, see _split_emoji_runs) as an RGBA image target_h tall, ready to alpha-composite onto the canvas. Tries color first, falls back to monochrome (rendered directly at target_h, since that font is vector) if the color font failed to load or this Pillow/FreeType build can't decode its embedded bitmaps. Cached -- the same emoji recurs across a household's events, and rasterizing+scaling isn't free.""" color_font = _color_emoji_font() if color_font is not None: try: probe = ImageDraw.Draw(Image.new("RGBA", (1, 1))) raw_w = max(1, round(probe.textlength(run_text, font=color_font))) tmp = Image.new("RGBA", (raw_w, _COLOR_EMOJI_NATIVE_SIZE), (255, 255, 255, 0)) ImageDraw.Draw(tmp).text((0, 0), run_text, font=color_font, embedded_color=True) scale = target_h / _COLOR_EMOJI_NATIVE_SIZE return tmp.resize((max(1, round(raw_w * scale)), target_h), Image.LANCZOS) except Exception: pass # this deployment's Pillow can't render embedded color bitmaps -- fall back mono_font = _mono_emoji_font(target_h) bbox = mono_font.getbbox(run_text) w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1]) mask = Image.new("L", (w, h), 0) ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), run_text, fill=255, font=mono_font) glyph = Image.new("RGBA", (w, h), (255, 255, 255, 0)) glyph.paste((0, 0, 0, 255), (0, 0), mask) return glyph # Matches runs of actual emoji base characters (the standard Unicode # emoji blocks -- stable ranges even as new individual emoji get added # within them, so this doesn't need updating as emoji sets grow). _EMOJI_PATTERN = re.compile( "[" "\U0001F1E6-\U0001F1FF" # regional indicator symbols (flag emoji) "\U0001F300-\U0001F5FF" # misc symbols & pictographs "\U0001F600-\U0001F64F" # emoticons "\U0001F680-\U0001F6FF" # transport & map symbols "\U0001F900-\U0001F9FF" # supplemental symbols & pictographs "\U0001FA70-\U0001FAFF" # symbols & pictographs extended-A "\U00002600-\U000026FF" # misc symbols (☀☂☕ etc.) "\U00002700-\U000027BF" # dingbats (✂✈✉ etc.) "]+" ) _EMOJI_SPLIT_PATTERN = re.compile(f"({_EMOJI_PATTERN.pattern})") # Codepoints with no meaningful standalone glyph once color/ligature # context is dropped: skin-tone modifiers (this is monochrome -- no # color to modify), the variation selector that just requests emoji # presentation, and the zero-width joiner used to fuse multiple emoji # into one combined glyph. That fusion (e.g. the "family" emoji from # four base emoji + 3 ZWJs) needs OpenType ligature substitution # (raqm/harfbuzz), which Pillow only does with a specific, non-default # build -- not something to depend on. Stripping the ZWJ instead means a # ZWJ sequence just draws as its individual base glyphs side by side # (four separate people instead of one family glyph) -- a real fallback, # not a crash or tofu. _EMOJI_MODIFIER_PATTERN = re.compile("[\U0001F3FB-\U0001F3FF\U0000FE0F\U0000200D]") def _split_emoji_runs(text: str) -> list[tuple[str, bool]]: """text -> [(run, is_emoji), ...], modifier/joiner codepoints dropped first (see _EMOJI_MODIFIER_PATTERN). Consecutive emoji collapse into one run (_EMOJI_PATTERN's own "+"), consecutive plain-text characters into the other.""" cleaned = _EMOJI_MODIFIER_PATTERN.sub("", text) parts = [p for p in _EMOJI_SPLIT_PATTERN.split(cleaned) if p] return [(p, bool(_EMOJI_PATTERN.fullmatch(p))) for p in parts] def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, text_font: ImageFont.ImageFont, max_width: int, fill: tuple[int, int, int] = FG) -> None: """Draws `text` left-to-right, switching between text_font (normal characters) and an emoji glyph image (actual emoji runs, per _split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a tofu box. Truncates with "..." once max_width is exceeded -- unlike _truncate_to_width this can't binary-search a single font's metrics across mixed fonts/images, so it works run-by-run instead (and can't partially truncate an emoji run the way it can a text run -- one that doesn't fit just isn't drawn). Fine for the short single-line strings this draws (event/task titles), not meant as a general rich-text layout engine. `fill` only affects text runs -- emoji glyphs are already their own color.""" x, y = xy cursor = x # A little taller than text_font's own size so glyphs don't look # cramped next to it; the -2 paste offset below roughly centers that # against the surrounding text's row -- tuned by eye against a real # rendered agenda row, not derived from font metrics. emoji_h = text_font.size + 6 for run_text, is_emoji in _split_emoji_runs(text): remaining = max_width - (cursor - x) if remaining <= 0: break if is_emoji: glyph = _emoji_glyph(run_text, emoji_h) if glyph.width <= remaining: img.paste(glyph, (round(cursor), y - 2), glyph) cursor += glyph.width else: break else: run_w = draw.textlength(run_text, font=text_font) if run_w <= remaining: draw_text(img, (round(cursor), y), run_text, text_font, fill) cursor += run_w else: draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining), text_font, fill) break 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 # --- 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 _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 _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 # month -- see _BUILDERS/_build) -------------------------------------- def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]: """[{"label", "code", "high", "low", "category"}, ...] for every configured city that has a cached forecast for this specific date -- weather_cities is routers/common.py's get_or_refresh_weather() cache shape, [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}].""" if not weather_cities: return [] key = day.isoformat() entries = [] for city in weather_cities: d = (city.get("days") or {}).get(key) if d is None: continue # Just the city name on-panel ("Portland", not the full # disambiguated "Portland, Oregon, United States") -- that fuller # form matters for telling apart geocoder candidates when adding # a city (see weather.geocode_city), not for a compact display row. entries.append({"label": city["label"].split(",")[0].strip(), "high": d["high"], "low": d["low"], "category": weather_category(d["code"])}) return entries 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], palette_rgb: list | None = None, weather_cities: list[dict] | None = None, weather_font: ImageFont.ImageFont | None = None, weather_units: str = "fahrenheit") -> None: """Draws one day's header + weather strip (if any) + 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. Weather is drawn above the event list -- eating into the same row budget the event count is truncated against, exactly like the header bar above it already does.""" x0, y0, w, h = region header_h = title_font.size + 20 panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h, panel_style.theme_color("calendar", palette_rgb)) text_x0 = x0 + MARGIN text_w = w - MARGIN * 2 header = day.strftime("%A, %B ") + str(day.day) draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2), _truncate_to_width(draw, header, title_font, text_w), title_font, BG) y = y0 + header_h + 12 weather_entries = _weather_for_day(weather_cities, day) if weather_entries: y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries, icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units, palette_rgb=palette_rgb) 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) 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) break colors = _event_colors(event, owners_seen, palette_rgb) panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors) time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz)) prefix = f"{time_str} " draw_text(img, (text_x0 + 18, y), prefix, body_font) prefix_w = draw.textlength(prefix, font=body_font) _draw_mixed_line(img, draw, (text_x0 + 18 + prefix_w, y), event["summary"], body_font, text_w - 18 - prefix_w) y += row_h def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int], tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont, palette_rgb: list | None = None, title: str = "Tasks") -> None: """A simple checklist filling `region` (x0, y0, w, h) -- a header (`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks" default; the only widget type with its own on-panel title, since it's the only one where "which list is this" isn't obvious from its content the way a calendar/photo/whiteboard's is), then a color chip (reusing _event_colors/panel_style.draw_color_chip as-is: a task dict's top-level owner_display_name/color_index is exactly _event_colors' single-source fallback shape, since caldav_client. merge_tasks doesn't cross-list-dedup tasks into a "sources" list the way merge_events dedups events) + checkbox glyph + due date (if any) + summary per task, same header/row-cap/truncation shape as _draw_agenda_day's event list so the standalone tasks widget (see _build_tasks) reads as the same consistent design as everything else on-panel, not a bolted-together look. Reuses _draw_mixed_line so a task summary with emoji in it renders the same way an event title's does. Outstanding tasks get an empty checkbox; completed ones (only ever present when TaskWidgetConfig.show_completed is on -- see caldav_client.fetch_tasks' completed_since) get a filled checkbox in this widget's own Green accent (see panel_style.THEME) -- that fill is the "done" signal, no due-date prefix (irrelevant once done) and no separate muted text treatment (see module-level MUTED removal note above _event_colors).""" x0, y0, w, h = region header_h = title_font.size + 20 panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h, panel_style.theme_color("tasks", palette_rgb)) text_x0 = x0 + MARGIN text_w = w - MARGIN * 2 draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2), _truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font, BG) y = y0 + header_h + 12 row_h = body_font.size + 14 max_rows = max(0, (y0 + h - MARGIN - y) // row_h) if not tasks: draw_text(img, (text_x0, y), "Nothing outstanding", body_font) return owners_seen: list[str] = [] checkbox_fill = panel_style.theme_color("tasks", palette_rgb) for i, task in enumerate(tasks): if i >= max_rows: draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font) break done = task.get("completed_at") is not None colors = _event_colors(task, owners_seen, palette_rgb) panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors) box = body_font.size - 6 box_x = text_x0 + 18 box_y = y + (row_h - box) // 2 - 5 box_r = min(panel_style.CHIP_RADIUS, box // 2) if done: draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, fill=checkbox_fill) else: draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, outline=FG, width=2) text_x = box_x + box + 10 due_str = None if done else _fmt_task_due(task.get("due")) prefix = f"{due_str} " if due_str else "" if prefix: draw_text(img, (text_x, y), prefix, body_font) prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0 _draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"], body_font, text_w - (text_x - text_x0) - prefix_w) y += row_h # 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", font_scale: float = 1.0) -> Image.Image: img, draw, region = panel_style.card_canvas(target_w, target_h) title_size, body_size, weather_size = ( panel_style.scaled_size(v, font_scale) for v in _AGENDA_FONTS[_size_tier(target_w, target_h)] ) title_font = panel_style.font_bold(title_size) body_font = panel_style.font_regular(body_size) weather_font = panel_style.font_regular(weather_size) day = datetime.now(tz).date() + timedelta(days=browse_offset) owners_seen: list[str] = [] _draw_agenda_day(img, draw, day, events, tz, region, title_font, body_font, owners_seen, palette_rgb, weather_cities, weather_font, weather_units) return img _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", font_scale: float = 1.0) -> Image.Image: """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, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) title_size, body_size, weather_size = ( panel_style.scaled_size(v, font_scale) for v in _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)] ) title_font = panel_style.font_bold(title_size) body_font = panel_style.font_regular(body_size) weather_font = panel_style.font_regular(weather_size) 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, palette_rgb, weather_cities, weather_font, weather_units) return img # 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", start_offset: int = 0, font_scale: float = 1.0) -> Image.Image: """`days` (2-10, see routers/api_widgets.py's clamp) side-by-side columns (layout="horizontal", the original fixed-at-7 behavior generalized) or stacked bands (layout="vertical", reusing _draw_agenda_day the same way _build_today_tomorrow does, just for an arbitrary day count instead of a hardcoded 2). At the default 7 days, the view anchors to week_start (a fixed weekday, "start on the most recent Monday") exactly like before -- 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_widgets.py's api_widget_config_save).""" img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) tier = _size_tier(target_w, target_h) today = datetime.now(tz).date() if days == 7: days_since_start = (today.weekday() - week_start) % 7 week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset) else: week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset) owners_seen: list[str] = [] if layout == "vertical": title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier] title_font = panel_style.font_bold(panel_style.scaled_size(max(14, title_base - days), font_scale)) body_font = panel_style.font_regular(panel_style.scaled_size(max(11, body_base - days), font_scale)) weather_font = panel_style.font_regular(panel_style.scaled_size(max(9, weather_base - days), font_scale)) section_h = ch // days for i in range(days): section_y0 = cy0 + i * section_h if i > 0: draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - 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), title_font, body_font, owners_seen, palette_rgb, weather_cities, weather_font, weather_units) return img header_size, chip_size, weather_size = ( panel_style.scaled_size(v, font_scale) for v in _WEEK_HORIZONTAL_FONTS[tier] ) header_font = panel_style.font_bold(header_size) chip_font = panel_style.font_regular(chip_size) weather_font = panel_style.font_regular(weather_size) col_w = (cw - MARGIN * 2) // days header_h = 44 for col in range(days): 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 # Columns are narrow, so only what actually fits gets drawn (see # weather_render.draw_weather_row) -- typically one city, no label # (the column itself makes which day it's for obvious; a city name # wouldn't fit anyway). Never more than that -- this is already # the tight view. weather_entries = _weather_for_day(weather_cities, day) if 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, palette_rgb=palette_rgb) 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) break colors = _event_colors(event, owners_seen, palette_rgb) panel_style.draw_color_chip(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2) if event["all_day"]: _draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20) else: prefix = f"{_fmt_time(_event_start(event, tz))[:-3]} " draw_text(img, (x0 + 16, y), prefix, chip_font) prefix_w = draw.textlength(prefix, font=chip_font) _draw_mixed_line(img, draw, (x0 + 16 + prefix_w, y), event["summary"], chip_font, col_w - 20 - prefix_w) y += row_h return img # 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, font_scale: float = 1.0) -> 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. "Not in this month" day numbers used to be a muted gray -- now de-emphasized by weight instead (Regular vs. Bold), same reasoning as everywhere else this module dropped MUTED -- see module-level comment above MARGIN/BG/FG.""" img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) header_size, day_size = ( panel_style.scaled_size(v, font_scale) for v in _MONTH_FONTS[_size_tier(target_w, target_h)] ) header_font = panel_style.font_bold(header_size) day_font_in_month = panel_style.font_bold(day_size) day_font_out_of_month = panel_style.font_regular(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 header_h = 28 grid_top = cy0 + MARGIN + header_h row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks) today_accent = panel_style.theme_color("calendar", palette_rgb) today_badge_r = min(panel_style.CHIP_RADIUS, 9) 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) 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 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 if day == today: # A filled accent badge (this widget's own theme color, # see panel_style.THEME) instead of the old bare outline # -- an actual "today" indicator, not just an outline # easy to miss at ~24px. Sized around the actual digit # bbox (not a fixed pixel box) so a bold 2-digit day # number ("30") fits as comfortably as a single digit # ("3") at every size tier. day_str = str(day.day) text_x, text_y = x0 + 6, y0 + 4 dbbox = draw.textbbox((text_x, text_y), day_str, font=day_font_in_month) pad = 3 badge_rect = [dbbox[0] - pad, dbbox[1] - pad, dbbox[2] + pad, dbbox[3] + pad] badge_r = min(today_badge_r, (badge_rect[3] - badge_rect[1]) // 2) draw.rounded_rectangle(badge_rect, radius=badge_r, fill=today_accent) draw_text(img, (text_x, text_y), day_str, day_font_in_month, BG) else: day_font = day_font_in_month if in_month else day_font_out_of_month draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font) day_events = _events_on_day(events, day, tz) dot_x = x0 + 8 dot_y = y0 + row_h - dot_r * 2 - 6 for i, event in enumerate(day_events[:4]): # First contributing calendar's color only, even for a # deduplicated shared event -- month view is density # dots, not a place to also show which calendars a # shared event came from (see _event_colors). event_color = _event_colors(event, owners_seen, palette_rgb)[0] draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color) dot_x += dot_r * 2 + 5 if len(day_events) > 4: draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font) 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, 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", week_start_offset: int = 0, font_scale: float = 1.0) -> Image.Image: tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC") 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, font_scale) elif effective_view == "today_tomorrow": img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, weather_units, font_scale) 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, week_start_offset, font_scale) elif effective_view == "month": # Never given weather -- no room for it at typical month-cell # size, same reasoning that already keeps this view to density # dots instead of literal event text (see _build_month's own # docstring). Colors are still passed through, though -- that's # a different concern (legibility of individual events) than # needing a whole extra strip of content. img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, font_scale) else: img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, weather_units, font_scale) if fetch_summary: # Drawn as a final overlay onto the already-composited img (not # inside any one _build_* branch above), so it offsets by # panel_style.GUTTER itself to land inside the same visible # margin every builder's own content already respects. font = panel_style.font_regular(14 if _size_tier(target_w, target_h) != "small" else 11) draw_text(img, (panel_style.GUTTER + MARGIN, target_h - panel_style.GUTTER - MARGIN - font.size), fetch_summary, font) return img def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str, 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", week_start_offset: int = 0, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes: """Renders one of CALENDAR_VIEWS full-panel to the panel's packed format. Returns exactly panel_w*panel_h/2 bytes (see image_pipeline.panel_size), 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").""" target_w, target_h = logical_render_size(orientation, panel_w, panel_h) 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, week_start_offset) 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, 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", week_start_offset: int = 0, font_scale: float = 1.0, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> 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.""" target_w, target_h = logical_render_size(orientation, panel_w, panel_h) 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, week_start_offset, font_scale) 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() # --- Standalone tasks widget (split out of the old calendar-widget-only # week-view task list -- see models.TaskWidgetConfig) ----------------- _TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)} def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None, title: str = "Tasks", font_scale: float = 1.0) -> Image.Image: """A tasks widget's entire region is the checklist -- unlike the old week-view slot, there's no day columns/header to share space with, so this is just _draw_tasks over the whole box.""" img, draw, region = panel_style.card_canvas(target_w, target_h) title_size, body_size = ( panel_style.scaled_size(v, font_scale) for v in _TASKS_FONTS[_size_tier(target_w, target_h)] ) title_font = panel_style.font_bold(title_size) body_font = panel_style.font_regular(body_size) _draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title) return img def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None, manage: dict | None = None, title: str = "Tasks", panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes: """Renders the tasks widget full-panel to the panel's packed format. Returns exactly panel_w*panel_h/2 bytes, same invariant every other renderer honors.""" target_w, target_h = logical_render_size(orientation, panel_w, panel_h) img = _build_tasks(tasks, target_w, target_h, palette_rgb, title) img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) return _transpose_and_pack(quantized, orientation) def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None, manage: dict | None = None, title: str = "Tasks", font_scale: float = 1.0, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes: """Same pipeline as render_tasks, but a normal browser-viewable PNG in logical (upright) orientation -- mirrors render_calendar_preview_ png's relationship to render_calendar.""" target_w, target_h = logical_render_size(orientation, panel_w, panel_h) img = _build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale) 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()