"""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/_draw_color_bar 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 .image_pipeline import ( DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, _transpose_and_pack, draw_text, logical_render_size, ) from .weather import weather_category 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) # 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 _draw_color_bar. 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 _draw_color_bar(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int, colors: list[tuple[int, int, int]], radius: int) -> None: """One rounded bar for a single-source event, or that same overall footprint split into equal-width side-by-side segments -- one per contributing calendar -- for a deduplicated shared event (see _event_colors/calendar_feed.merge_events). Splitting rather than e.g. concentric rings keeps every color equally "thick and bold" at a glance, the same design goal a single pinned color already has.""" if len(colors) == 1: draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=colors[0]) return seg_w = (x1 - x0) / len(colors) for i, color in enumerate(colors): seg_x0 = round(x0 + i * seg_w) seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0) draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color) 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") # ImageFont.load_default() (used for everything else in this module -- # see the module docstring) has no 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) -> 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 titles), not meant as a general rich-text layout engine.""" 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) cursor += run_w else: draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining), text_font) 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_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None: """A simple puffy-cloud silhouette (three overlapping lobes + a base) with a clean outline -- drawn as one black pass slightly larger than the shapes, then the same shapes again in white on top. Overlapping ellipses each drawn with their own `outline=` would leave visible seams where they cross; this double-draw trick sidesteps that entirely regardless of how the lobes overlap.""" stroke = 2 lobes = [ (cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6), (cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25), (cx, cy - r * 0.35, cx + r, cy + r * 0.6), ] base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5) for x0, y0, x1, y1 in lobes: draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=FG) draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG) for x0, y0, x1, y1 in lobes: draw.ellipse([x0, y0, x1, y1], fill=BG) draw.rectangle(base, fill=BG) def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None: """A small hand-drawn glyph for one weather category -- no custom font/icon asset, same hand-primitives-only approach the rest of this module uses (colored rectangles for owner indicators, density dots for month view).""" if category == "clear": # Kept within a ~1.1r visual radius overall (rays included) to # match _draw_cloud's own footprint -- _draw_weather_row lays # icons out assuming each one stays roughly within icon_r of its # center, and the first entry in a row sits flush against the # region's own left margin, so any icon that draws wider than # that pokes out past it with nothing to visually connect to. draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG) for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)): draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)], fill=FG, width=3) return cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3 if category == "partly_cloudy": draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG) _draw_cloud(draw, cx, cloud_cy, r) if category == "fog": for i in range(3): y = cy + r * 0.5 + i * (r * 0.45) draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2) elif category == "rain": for dx in (-0.6, 0, 0.6): x = cx + dx * r draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2) elif category == "snow": for dx in (-0.6, 0, 0.6): x, y = cx + dx * r, cloud_cy + r * 0.9 draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG) elif category == "thunderstorm": x, y = cx, cloud_cy + r * 0.5 draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)], fill=FG, width=2) def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int, entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str, show_labels: bool = True) -> int: """Draws one or more cities' weather side by side starting at (x0, y0), stopping once another entry wouldn't fit within max_w (narrow views like week columns just end up showing fewer cities -- same graceful-degradation approach month view takes with density dots). Returns the row height consumed (0 if there was nothing to draw, so callers can skip reserving space entirely).""" if not entries: return 0 unit_suffix = "F" if units == "fahrenheit" else "C" row_h = icon_r * 2 + 8 x = x0 drew_any = False for entry in entries: temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}" label = f"{entry['label']} {temps}" if show_labels else temps entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18) if drew_any and x + entry_w > x0 + max_w: break cx, cy = x + icon_r, y0 + icon_r _draw_weather_icon(draw, cx, cy, icon_r, entry["category"]) draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font) x += entry_w drew_any = True return row_h + 6 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/rule above it already does.""" 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 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) 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 colors = _event_colors(event, owners_seen, palette_rgb) _draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3) 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) -> None: """A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box glyph + due date (if any) + summary per outstanding task, same header/rule/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.""" x0, y0, w, h = region text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN text_w = w - MARGIN * 2 draw_text(img, (text_x0, text_y0), "Tasks", title_font) y = text_y0 + title_font.size + 12 draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) y += 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, MUTED) return for i, task in enumerate(tasks): if i >= max_rows: draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED) break box = body_font.size - 6 box_y = y + (row_h - box) // 2 - 5 draw.rectangle([text_x0, box_y, text_x0 + box, box_y + box], outline=FG, width=2) text_x = text_x0 + box + 10 due_str = _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, MUTED) 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 - box - 10 - 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") -> Image.Image: img = Image.new("RGB", (target_w, target_h), BG) draw = ImageDraw.Draw(img) 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, (0, 0, target_w, target_h), 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") -> 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 = Image.new("RGB", (target_w, target_h), BG) draw = ImageDraw.Draw(img) 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 = target_h // 2 owners_seen: list[str] = [] for i in range(2): section_y0 = i * section_h if i > 0: draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE) _draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz, (0, section_y0, target_w, 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) -> 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 = 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: 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 = 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(days): section_y0 = i * section_h if i > 0: 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, (0, section_y0, target_w, section_h), title_font, body_font, owners_seen, palette_rgb, weather_cities, weather_font, weather_units) return img 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(days): day = week_first_day + timedelta(days=col) x0 = MARGIN + col * col_w if col > 0: 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, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font) 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 # 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) row_h = chip_font.size + 10 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: draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED) break colors = _event_colors(event, owners_seen, palette_rgb) _draw_color_bar(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) -> 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.""" img = Image.new("RGB", (target_w, target_h), BG) draw = ImageDraw.Draw(img) 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 = (target_w - MARGIN * 2) // 7 header_h = 28 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, (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 = 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 text_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, text_color) 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, 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, 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) -> 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) elif effective_view == "today_tomorrow": img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, weather_units) 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) 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) else: 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 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, 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) -> bytes: """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").""" 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, 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) -> 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) 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) 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) -> 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 = Image.new("RGB", (target_w, target_h), BG) draw = ImageDraw.Draw(img) title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)] title_font = ImageFont.load_default(size=title_size) body_font = ImageFont.load_default(size=body_size) _draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font) return img def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None, manage: dict | None = None) -> bytes: """Renders the tasks widget full-panel to the panel's packed format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every other renderer honors.""" target_w, target_h = logical_render_size(orientation) img = _build_tasks(tasks, target_w, target_h) 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) -> 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) img = _build_tasks(tasks, target_w, target_h) 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()