Build and push server image / build-and-push (push) Successful in 53s
calendar_week_start's fixed-weekday anchor ("start on the most recent
Monday") stops making sense once the view isn't a literal calendar
week, so a non-7-day week view now starts calendar_week_start_offset
days from today instead (0 = starts today, negative/positive = past/
future) -- calendar_week_start still governs at the default 7 days,
unchanged.
Also hides the Calendar tab's week-only fields (days to show, layout,
start offset) unless View is actually set to Week, and further hides
the new start-offset field specifically when Days to show is 7 (where
it has no effect). "Week starts on" stays visible for Month too, since
it actually still applies there.
884 lines
43 KiB
Python
884 lines
43 KiB
Python
"""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", "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,
|
|
compose_into,
|
|
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_frames.py's
|
|
api_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
|
|
|
|
|
|
# --- 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))
|
|
|
|
|
|
# --- 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,
|
|
margin: int = MARGIN) -> 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 "week view, one slot replaced by tasks instead of a day"
|
|
layout (see _build_week) reads as one consistent design rather than
|
|
two different widgets bolted together. Reuses _draw_mixed_line so a
|
|
task summary with emoji in it renders the same way an event
|
|
title's does.
|
|
|
|
`margin` defaults to the module-wide MARGIN (vertical layout's
|
|
stacked bands are as wide as the whole content region, same as
|
|
_draw_agenda_day's own sections) but a narrow horizontal-layout
|
|
column passes a much smaller one -- MARGIN on both sides of an
|
|
already-cramped ~150px week column left almost nothing for the
|
|
title text itself."""
|
|
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
|
|
|
|
|
|
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None,
|
|
weather_units: str = "fahrenheit") -> Image.Image:
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
img = Image.new("RGB", (logical_w, logical_h), BG)
|
|
if photo_inlay is not None:
|
|
_paste_inlay(img, photo_inlay, orientation)
|
|
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Smaller title when the inlay halves the available width -- "Wednesday,
|
|
# July 22" at full size doesn't fit ~360px, and a *narrower* column is
|
|
# exactly when a smaller font (rather than truncating to "Wednesday...")
|
|
# keeps it actually informative.
|
|
title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24)
|
|
body_font = ImageFont.load_default(size=22)
|
|
weather_font = ImageFont.load_default(size=20 if photo_inlay is None else 16)
|
|
|
|
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,
|
|
palette_rgb, weather_cities, weather_font, weather_units)
|
|
|
|
return img
|
|
|
|
|
|
def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None,
|
|
weather_units: str = "fahrenheit") -> Image.Image:
|
|
"""Two _draw_agenda_day sections stacked vertically within the content
|
|
region (below each other rather than side-by-side -- narrower than
|
|
tall doesn't leave enough width per day for the event-row text once
|
|
an inlay's already claimed half the canvas). browse_offset shifts
|
|
the whole two-day window together, same "days" unit _build_agenda
|
|
already uses, so NEXT/BACK behaves identically across both views."""
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
img = Image.new("RGB", (logical_w, logical_h), BG)
|
|
if photo_inlay is not None:
|
|
_paste_inlay(img, photo_inlay, orientation)
|
|
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
title_font = ImageFont.load_default(size=26 if photo_inlay is None else 20)
|
|
body_font = ImageFont.load_default(size=18 if photo_inlay is None else 15)
|
|
weather_font = ImageFont.load_default(size=16 if photo_inlay is None else 13)
|
|
|
|
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
|
|
|
|
|
|
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None,
|
|
start_offset: int = 0) -> Image.Image:
|
|
"""`days` (2-10, see routers/api_frames.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). `tasks` (see
|
|
routers/common.py's get_or_refresh_tasks), if not None, takes the
|
|
LAST slot instead of adding an extra one -- "N days" always means N
|
|
slots total, whether they're all days or N-1 days plus a task list.
|
|
|
|
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_frames.py's api_config_save)."""
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
img = Image.new("RGB", (logical_w, logical_h), BG)
|
|
if photo_inlay is not None:
|
|
_paste_inlay(img, photo_inlay, orientation)
|
|
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
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)
|
|
day_count = days - 1 if tasks is not None else days
|
|
owners_seen: list[str] = []
|
|
|
|
if layout == "vertical":
|
|
title_font = ImageFont.load_default(size=max(14, 26 - days) if photo_inlay is None else max(11, 20 - days))
|
|
body_font = ImageFont.load_default(size=max(11, 18 - days) if photo_inlay is None else max(9, 15 - days))
|
|
weather_font = ImageFont.load_default(size=max(9, 16 - days) if photo_inlay is None else max(8, 13 - days))
|
|
section_h = ch // days
|
|
for i in range(day_count):
|
|
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)
|
|
if tasks is not None:
|
|
section_y0 = cy0 + day_count * section_h
|
|
if day_count > 0:
|
|
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
|
_draw_tasks(img, draw, (cx0, section_y0, cw, section_h), tasks, title_font, body_font)
|
|
return img
|
|
|
|
header_font = ImageFont.load_default(size=18 if photo_inlay is None else 14)
|
|
chip_font = ImageFont.load_default(size=14 if photo_inlay is None else 12)
|
|
weather_font = ImageFont.load_default(size=12 if photo_inlay is None else 10)
|
|
col_w = (cw - MARGIN * 2) // days
|
|
header_h = 44
|
|
|
|
for col in range(day_count):
|
|
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
|
|
# _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, (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
|
|
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
|
|
|
|
if tasks is not None:
|
|
col = day_count
|
|
x0 = cx0 + MARGIN + col * col_w
|
|
if col > 0:
|
|
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
|
|
_draw_tasks(img, draw, (x0, cy0, col_w, ch), tasks, header_font, chip_font, margin=6)
|
|
|
|
return img
|
|
|
|
|
|
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None) -> Image.Image:
|
|
"""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 = 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
|
|
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, orientation: str, timezone: str,
|
|
photo_inlay: Image.Image | None, fetch_summary: str, week_start: int,
|
|
palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None,
|
|
week_start_offset: int = 0) -> Image.Image:
|
|
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
|
if view == "agenda":
|
|
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
|
weather_cities, weather_units)
|
|
elif view == "today_tomorrow":
|
|
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
|
weather_cities, weather_units)
|
|
elif view == "week":
|
|
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb,
|
|
weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
|
elif view == "month":
|
|
# Never given weather or tasks -- no room for either at typical
|
|
# month-cell size, same reasoning that already keeps this view
|
|
# to density dots instead of literal event text (see
|
|
# _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/slot of
|
|
# content.
|
|
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb)
|
|
else:
|
|
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
|
weather_cities, weather_units)
|
|
|
|
if fetch_summary:
|
|
font = ImageFont.load_default(size=14)
|
|
logical_w, logical_h = img.size
|
|
draw_text(img, (MARGIN, logical_h - MARGIN - font.size), fetch_summary, font, MUTED)
|
|
|
|
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,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
week_days: int = 7, week_layout: str = "horizontal",
|
|
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
|
|
"""Renders one of CALENDAR_VIEWS to the panel's packed format. Always
|
|
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every
|
|
other renderer honors. weather_cities is routers/common.py's
|
|
get_or_refresh_weather() cache, or None/[] to omit the weather strip
|
|
entirely (also always omitted for view == "month"). tasks is
|
|
get_or_refresh_tasks()'s cache, or None to omit the task list
|
|
entirely -- only ever drawn for view == "week", see _build_week."""
|
|
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
|
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
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,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
week_days: int = 7, week_layout: str = "horizontal",
|
|
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
|
|
"""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,
|
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
buf = io.BytesIO()
|
|
quantized.convert("RGB").save(buf, format="PNG")
|
|
return buf.getvalue()
|