Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.
Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.
API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.
UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.
Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
892 lines
43 KiB
Python
892 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,
|
|
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,
|
|
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
|
|
|
|
|
|
# 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", tasks: list[dict] | None = None,
|
|
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). `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_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)
|
|
day_count = days - 1 if tasks is not None else days
|
|
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(day_count):
|
|
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)
|
|
if tasks is not None:
|
|
section_y0 = day_count * section_h
|
|
if day_count > 0:
|
|
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
|
|
_draw_tasks(img, draw, (0, section_y0, target_w, section_h), tasks, title_font, body_font)
|
|
return img
|
|
|
|
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
|
|
header_font = ImageFont.load_default(size=header_size)
|
|
chip_font = ImageFont.load_default(size=chip_size)
|
|
weather_font = ImageFont.load_default(size=weather_size)
|
|
col_w = (target_w - MARGIN * 2) // days
|
|
header_h = 44
|
|
|
|
for col in range(day_count):
|
|
day = week_first_day + timedelta(days=col)
|
|
x0 = 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
|
|
|
|
if tasks is not None:
|
|
col = day_count
|
|
x0 = MARGIN + col * col_w
|
|
if col > 0:
|
|
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
|
|
_draw_tasks(img, draw, (x0, 0, col_w, target_h), tasks, header_font, chip_font, margin=6)
|
|
|
|
return img
|
|
|
|
|
|
# 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", tasks: list[dict] | None = None,
|
|
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, tasks, week_start_offset)
|
|
elif effective_view == "month":
|
|
# Never given weather or tasks -- no room for either at typical
|
|
# month-cell size, same reasoning that already keeps this view
|
|
# to density dots instead of literal event text (see
|
|
# _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, 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",
|
|
tasks: list[dict] | None = None, 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"). tasks is get_or_refresh_tasks()'s cache, or None to omit
|
|
the task list entirely -- only ever drawn for view == "week", see
|
|
_build_week."""
|
|
target_w, target_h = logical_render_size(orientation)
|
|
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
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",
|
|
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."""
|
|
target_w, target_h = logical_render_size(orientation)
|
|
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
buf = io.BytesIO()
|
|
quantized.convert("RGB").save(buf, format="PNG")
|
|
return buf.getvalue()
|