Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
889 lines
44 KiB
Python
889 lines
44 KiB
Python
"""Renders calendar frame mode's three views (agenda/week/month), and the
|
|
separate standalone tasks widget (see models.TaskWidgetConfig -- a task
|
|
list used to be a calendar-widget-only week-view slot, split out into
|
|
its own widget type so it isn't tied to a calendar's view/footprint),
|
|
into the panel's packed format, following image_pipeline.
|
|
render_placeholder's own precedent: build an RGB canvas with
|
|
ImageDraw/ImageFont, then the same _quantize/_transpose_and_pack every
|
|
other renderer ends on.
|
|
|
|
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
|
|
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
|
|
"color_index"}, ...]} -- more than one entry in "sources" means
|
|
merge_events collapsed several calendars' identical (same title/time)
|
|
events into one, see _event_colors/panel_style.draw_color_chip below.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import calendar as calendar_module
|
|
import io
|
|
import re
|
|
from datetime import date, datetime, timedelta
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from . import panel_style
|
|
from .image_pipeline import (
|
|
DEFAULT_PALETTE_RGB,
|
|
EPD_HEIGHT,
|
|
EPD_WIDTH,
|
|
_apply_manage_overlay,
|
|
_quantize,
|
|
_transpose_and_pack,
|
|
draw_text,
|
|
logical_render_size,
|
|
)
|
|
from .weather import weather_category
|
|
from .weather_render import draw_weather_row
|
|
|
|
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
|
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
|
|
"week": "Week", "month": "Month"}
|
|
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
|
|
|
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
|
|
# re-tuned). BG/FG are this module's own plain black/white -- checkbox
|
|
# outlines, month-view grid hairlines -- not a text-emphasis concern (no
|
|
# MUTED gray here anymore -- see panel_style's module docstring for why:
|
|
# a mid-gray fill has no close palette match and dithers into speckle
|
|
# once the whole canvas is quantized. Secondary text now reads through
|
|
# size/weight alone, always exact black).
|
|
MARGIN = panel_style.CONTENT_MARGIN
|
|
BG = (255, 255, 255)
|
|
FG = (0, 0, 0)
|
|
# Structural dividers/grid lines (between stacked day sections, week
|
|
# columns, month cells) stay a plain black rule -- gray dithers away to
|
|
# near-invisible once quantized to the 6-color e-ink palette. Headers
|
|
# no longer use this: see panel_style.draw_header_bar/theme_color.
|
|
RULE = (0, 0, 0)
|
|
|
|
# Fallback for any event whose calendar has no manually pinned color
|
|
# (event["color_index"] is None): cycled per distinct owner_display_name
|
|
# so a merged multi-person calendar can still visually tell whose event
|
|
# is whose -- the panel's own non-black/white ink colors, skipping
|
|
# black/white (index 0/1 in DEFAULT_PALETTE_RGB) since those are already
|
|
# the page's text/background.
|
|
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
|
|
|
|
|
|
def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) -> list[tuple[int, int, int]]:
|
|
"""One color per contributing calendar (event["sources"] -- see
|
|
calendar_feed.merge_events, which collapses events sharing the exact
|
|
same title/time across different calendars into one entry with
|
|
several sources, e.g. a shared family event synced onto more than
|
|
one person's calendar). Usually just one color; more than one is
|
|
what tells the "same event, more than one calendar" case apart from
|
|
an ordinary single-calendar event at render time -- see
|
|
panel_style.draw_color_chip. Each source's own manually pinned color
|
|
(FrameCalendar.color_index -- see routers/api_widgets.py's
|
|
api_widget_calendar_color) resolves against whichever palette this frame
|
|
actually renders with, so a pinned "Blue" stays this frame's actual
|
|
blue; a source with no color pinned falls back to the old
|
|
auto-cycle-by-owner-name behavior. owners_seen is shared across every
|
|
event/source in a render so that cycle stays consistent view-wide."""
|
|
sources = event.get("sources") or [
|
|
{"owner_display_name": event.get("owner_display_name"), "color_index": event.get("color_index")}
|
|
]
|
|
colors = []
|
|
for source in sources:
|
|
color_index = source.get("color_index")
|
|
if color_index is not None:
|
|
palette = palette_rgb or DEFAULT_PALETTE_RGB
|
|
colors.append(tuple(palette[color_index]))
|
|
continue
|
|
owner_display_name = source.get("owner_display_name")
|
|
if owner_display_name not in owners_seen:
|
|
owners_seen.append(owner_display_name)
|
|
colors.append(OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)])
|
|
return colors
|
|
|
|
|
|
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
|
|
"""Parses event["start"] and, for timed events, converts to `tz` --
|
|
calendar_feed.py stores whatever timezone each source event carried
|
|
(often UTC), but display/bucketing needs to happen in the frame's own
|
|
timezone."""
|
|
dt = datetime.fromisoformat(event["start"])
|
|
if event["all_day"]:
|
|
return dt if isinstance(dt, date) and not isinstance(dt, datetime) else dt.date()
|
|
return dt.astimezone(tz)
|
|
|
|
|
|
def _events_on_day(events: list[dict], day: date, tz: ZoneInfo) -> list[dict]:
|
|
on_day = [e for e in events if _local_date(e, tz) == day]
|
|
on_day.sort(key=lambda e: (not e["all_day"], e["start"]))
|
|
return on_day
|
|
|
|
|
|
def _local_date(event: dict, tz: ZoneInfo) -> date:
|
|
start = _event_start(event, tz)
|
|
return start if isinstance(start, date) and not isinstance(start, datetime) else start.date()
|
|
|
|
|
|
def _add_months(d: date, months: int) -> date:
|
|
total = d.month - 1 + months
|
|
year = d.year + total // 12
|
|
month = total % 12 + 1
|
|
day = min(d.day, calendar_module.monthrange(year, month)[1])
|
|
return date(year, month, day)
|
|
|
|
|
|
def _fmt_time(dt: datetime) -> str:
|
|
text = dt.strftime("%I:%M %p").lstrip("0")
|
|
return text if text else "12:00 AM"
|
|
|
|
|
|
def _fmt_task_due(due: str | None) -> str:
|
|
""""2026-07-25" or "2026-07-25T14:00:00+00:00" -> "Jul 25" -- tasks
|
|
only need a compact reminder of when they're due, not the precision
|
|
an event's own start/end time gets."""
|
|
if not due:
|
|
return ""
|
|
try:
|
|
dt = datetime.fromisoformat(due)
|
|
except ValueError:
|
|
return ""
|
|
d = dt.date() if isinstance(dt, datetime) else dt
|
|
return d.strftime("%b %-d")
|
|
|
|
|
|
# Neither Inter (panel_style.font_bold/font_regular, this module's own
|
|
# body/title font -- see MARGIN/BG/FG comment above) nor PIL's bundled
|
|
# default font has emoji glyphs, and PIL/FreeType don't skip an
|
|
# unsupported codepoint, they substitute a ".notdef" tofu box (a visible
|
|
# filled rectangle) -- reads as a rendering glitch, not "emoji not
|
|
# supported". So event titles get drawn with two fonts: the normal
|
|
# text font for everything else, and one of these for actual emoji runs
|
|
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
|
|
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
|
|
#
|
|
# Color (NotoColorEmoji.ttf) is tried first: full-color CBDT bitmap
|
|
# glyphs, which the panel's own Floyd-Steinberg dithering turns into a
|
|
# recognizable (if slightly speckled) color rendering rather than a flat
|
|
# monochrome shape -- confirmed by actually rendering a test agenda row
|
|
# through the real quantizer, not just theorizing about it. Its one
|
|
# real quirk: CBDT stores glyphs at a single embedded bitmap size
|
|
# (_COLOR_EMOJI_NATIVE_SIZE), so every glyph is rasterized once at that
|
|
# size and scaled down to the target row height, unlike normal vector
|
|
# text which draws directly at whatever size is asked for.
|
|
#
|
|
# NotoEmoji.ttf (monochrome, vector) is the fallback for a deployment
|
|
# whose Pillow/FreeType wasn't built with embedded color bitmap support
|
|
# -- confirmed working locally, but that's a build-time detail this
|
|
# project doesn't control everywhere it might run, so a rendering
|
|
# failure falls back instead of showing nothing/crashing.
|
|
_COLOR_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoColorEmoji.ttf"
|
|
_MONO_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoEmoji.ttf"
|
|
_COLOR_EMOJI_NATIVE_SIZE = 109
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _color_emoji_font() -> ImageFont.FreeTypeFont | None:
|
|
try:
|
|
return ImageFont.truetype(str(_COLOR_EMOJI_FONT_PATH), _COLOR_EMOJI_NATIVE_SIZE)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
@lru_cache(maxsize=None)
|
|
def _mono_emoji_font(size: int) -> ImageFont.FreeTypeFont:
|
|
return ImageFont.truetype(str(_MONO_EMOJI_FONT_PATH), size)
|
|
|
|
|
|
@lru_cache(maxsize=512)
|
|
def _emoji_glyph(run_text: str, target_h: int) -> Image.Image:
|
|
"""One emoji run (consecutive emoji collapse into a single run, see
|
|
_split_emoji_runs) as an RGBA image target_h tall, ready to
|
|
alpha-composite onto the canvas. Tries color first, falls back to
|
|
monochrome (rendered directly at target_h, since that font is
|
|
vector) if the color font failed to load or this Pillow/FreeType
|
|
build can't decode its embedded bitmaps. Cached -- the same emoji
|
|
recurs across a household's events, and rasterizing+scaling isn't
|
|
free."""
|
|
color_font = _color_emoji_font()
|
|
if color_font is not None:
|
|
try:
|
|
probe = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
|
raw_w = max(1, round(probe.textlength(run_text, font=color_font)))
|
|
tmp = Image.new("RGBA", (raw_w, _COLOR_EMOJI_NATIVE_SIZE), (255, 255, 255, 0))
|
|
ImageDraw.Draw(tmp).text((0, 0), run_text, font=color_font, embedded_color=True)
|
|
scale = target_h / _COLOR_EMOJI_NATIVE_SIZE
|
|
return tmp.resize((max(1, round(raw_w * scale)), target_h), Image.LANCZOS)
|
|
except Exception:
|
|
pass # this deployment's Pillow can't render embedded color bitmaps -- fall back
|
|
|
|
mono_font = _mono_emoji_font(target_h)
|
|
bbox = mono_font.getbbox(run_text)
|
|
w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1])
|
|
mask = Image.new("L", (w, h), 0)
|
|
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), run_text, fill=255, font=mono_font)
|
|
glyph = Image.new("RGBA", (w, h), (255, 255, 255, 0))
|
|
glyph.paste((0, 0, 0, 255), (0, 0), mask)
|
|
return glyph
|
|
|
|
|
|
# Matches runs of actual emoji base characters (the standard Unicode
|
|
# emoji blocks -- stable ranges even as new individual emoji get added
|
|
# within them, so this doesn't need updating as emoji sets grow).
|
|
_EMOJI_PATTERN = re.compile(
|
|
"["
|
|
"\U0001F1E6-\U0001F1FF" # regional indicator symbols (flag emoji)
|
|
"\U0001F300-\U0001F5FF" # misc symbols & pictographs
|
|
"\U0001F600-\U0001F64F" # emoticons
|
|
"\U0001F680-\U0001F6FF" # transport & map symbols
|
|
"\U0001F900-\U0001F9FF" # supplemental symbols & pictographs
|
|
"\U0001FA70-\U0001FAFF" # symbols & pictographs extended-A
|
|
"\U00002600-\U000026FF" # misc symbols (☀☂☕ etc.)
|
|
"\U00002700-\U000027BF" # dingbats (✂✈✉ etc.)
|
|
"]+"
|
|
)
|
|
_EMOJI_SPLIT_PATTERN = re.compile(f"({_EMOJI_PATTERN.pattern})")
|
|
# Codepoints with no meaningful standalone glyph once color/ligature
|
|
# context is dropped: skin-tone modifiers (this is monochrome -- no
|
|
# color to modify), the variation selector that just requests emoji
|
|
# presentation, and the zero-width joiner used to fuse multiple emoji
|
|
# into one combined glyph. That fusion (e.g. the "family" emoji from
|
|
# four base emoji + 3 ZWJs) needs OpenType ligature substitution
|
|
# (raqm/harfbuzz), which Pillow only does with a specific, non-default
|
|
# build -- not something to depend on. Stripping the ZWJ instead means a
|
|
# ZWJ sequence just draws as its individual base glyphs side by side
|
|
# (four separate people instead of one family glyph) -- a real fallback,
|
|
# not a crash or tofu.
|
|
_EMOJI_MODIFIER_PATTERN = re.compile("[\U0001F3FB-\U0001F3FF\U0000FE0F\U0000200D]")
|
|
|
|
|
|
def _split_emoji_runs(text: str) -> list[tuple[str, bool]]:
|
|
"""text -> [(run, is_emoji), ...], modifier/joiner codepoints
|
|
dropped first (see _EMOJI_MODIFIER_PATTERN). Consecutive emoji
|
|
collapse into one run (_EMOJI_PATTERN's own "+"), consecutive
|
|
plain-text characters into the other."""
|
|
cleaned = _EMOJI_MODIFIER_PATTERN.sub("", text)
|
|
parts = [p for p in _EMOJI_SPLIT_PATTERN.split(cleaned) if p]
|
|
return [(p, bool(_EMOJI_PATTERN.fullmatch(p))) for p in parts]
|
|
|
|
|
|
def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str,
|
|
text_font: ImageFont.ImageFont, max_width: int, fill: tuple[int, int, int] = FG) -> None:
|
|
"""Draws `text` left-to-right, switching between text_font (normal
|
|
characters) and an emoji glyph image (actual emoji runs, per
|
|
_split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a
|
|
tofu box. Truncates with "..." once max_width is exceeded -- unlike
|
|
_truncate_to_width this can't binary-search a single font's metrics
|
|
across mixed fonts/images, so it works run-by-run instead (and can't
|
|
partially truncate an emoji run the way it can a text run -- one
|
|
that doesn't fit just isn't drawn). Fine for the short single-line
|
|
strings this draws (event/task titles), not meant as a general
|
|
rich-text layout engine. `fill` only affects text runs -- emoji
|
|
glyphs are already their own color."""
|
|
x, y = xy
|
|
cursor = x
|
|
# A little taller than text_font's own size so glyphs don't look
|
|
# cramped next to it; the -2 paste offset below roughly centers that
|
|
# against the surrounding text's row -- tuned by eye against a real
|
|
# rendered agenda row, not derived from font metrics.
|
|
emoji_h = text_font.size + 6
|
|
for run_text, is_emoji in _split_emoji_runs(text):
|
|
remaining = max_width - (cursor - x)
|
|
if remaining <= 0:
|
|
break
|
|
if is_emoji:
|
|
glyph = _emoji_glyph(run_text, emoji_h)
|
|
if glyph.width <= remaining:
|
|
img.paste(glyph, (round(cursor), y - 2), glyph)
|
|
cursor += glyph.width
|
|
else:
|
|
break
|
|
else:
|
|
run_w = draw.textlength(run_text, font=text_font)
|
|
if run_w <= remaining:
|
|
draw_text(img, (round(cursor), y), run_text, text_font, fill)
|
|
cursor += run_w
|
|
else:
|
|
draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining),
|
|
text_font, fill)
|
|
break
|
|
|
|
|
|
def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str:
|
|
"""Pixel-width-aware truncation (unlike device.py's char-count
|
|
_truncate, tuned for a fixed firmware font at a fixed size) -- this
|
|
module draws at several different sizes, so truncation has to
|
|
measure the actual font/size in play. Still uses `draw.textlength`
|
|
for measurement (identical metrics to draw_text's own bbox), just
|
|
doesn't paint anything."""
|
|
if draw.textlength(text, font=font) <= max_width:
|
|
return text
|
|
ellipsis = "..."
|
|
lo, hi = 0, len(text)
|
|
while lo < hi:
|
|
mid = (lo + hi + 1) // 2
|
|
if draw.textlength(text[:mid] + ellipsis, font=font) <= max_width:
|
|
lo = mid
|
|
else:
|
|
hi = mid - 1
|
|
return text[:lo] + ellipsis if lo else ellipsis
|
|
|
|
|
|
# --- Size tiers ---------------------------------------------------------
|
|
#
|
|
# A calendar widget can now be placed at any grid footprint (see
|
|
# app/grid.py), not just the full panel -- these three discrete tiers
|
|
# (chosen by nearest-fit against the target box's pixel area) drive font
|
|
# sizes/margins instead of continuously scaling a layout that was tuned
|
|
# by eye for the full ~800x480 panel, which would risk ugly proportions
|
|
# at odd in-between sizes. Area-based (not width/height-based) so the
|
|
# same footprint tiers the same regardless of landscape/portrait target
|
|
# box shape.
|
|
_TIER_LARGE_AREA = 280_000 # near/at a full 800x480 panel (384,000px^2)
|
|
_TIER_MEDIUM_AREA = 120_000 # roughly a half-panel split
|
|
|
|
|
|
def _size_tier(target_w: int, target_h: int) -> str:
|
|
area = target_w * target_h
|
|
if area >= _TIER_LARGE_AREA:
|
|
return "large"
|
|
if area >= _TIER_MEDIUM_AREA:
|
|
return "medium"
|
|
return "small"
|
|
|
|
|
|
def _month_view_fits(target_w: int, target_h: int) -> bool:
|
|
"""Month view needs real width to keep 7 columns' day numbers and
|
|
density dots legible -- below the "small" size tier that stops being
|
|
true, so _build falls back to agenda view instead of drawing an
|
|
unreadable grid."""
|
|
return _size_tier(target_w, target_h) != "small"
|
|
|
|
|
|
# --- Weather strip, agenda/today & tomorrow/week views only (never
|
|
# month -- see _BUILDERS/_build) --------------------------------------
|
|
|
|
def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]:
|
|
"""[{"label", "code", "high", "low", "category"}, ...] for every
|
|
configured city that has a cached forecast for this specific date --
|
|
weather_cities is routers/common.py's get_or_refresh_weather() cache
|
|
shape, [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}]."""
|
|
if not weather_cities:
|
|
return []
|
|
key = day.isoformat()
|
|
entries = []
|
|
for city in weather_cities:
|
|
d = (city.get("days") or {}).get(key)
|
|
if d is None:
|
|
continue
|
|
# Just the city name on-panel ("Portland", not the full
|
|
# disambiguated "Portland, Oregon, United States") -- that fuller
|
|
# form matters for telling apart geocoder candidates when adding
|
|
# a city (see weather.geocode_city), not for a compact display row.
|
|
entries.append({"label": city["label"].split(",")[0].strip(), "high": d["high"], "low": d["low"],
|
|
"category": weather_category(d["code"])})
|
|
return entries
|
|
|
|
|
|
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
|
|
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
|
|
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None, weather_font: ImageFont.ImageFont | None = None,
|
|
weather_units: str = "fahrenheit") -> None:
|
|
"""Draws one day's header + weather strip (if any) + event rows
|
|
within `region` (x0, y0, w, h) -- factored out of _build_agenda so
|
|
the today-and-tomorrow view (_build_today_tomorrow) can stack two of
|
|
these vertically without duplicating the row-layout/truncation
|
|
logic. Weather is drawn above the event list -- eating into the same
|
|
row budget the event count is truncated against, exactly like the
|
|
header bar above it already does."""
|
|
x0, y0, w, h = region
|
|
header_h = title_font.size + 20
|
|
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
|
|
panel_style.theme_color("calendar", palette_rgb))
|
|
text_x0 = x0 + MARGIN
|
|
text_w = w - MARGIN * 2
|
|
header = day.strftime("%A, %B ") + str(day.day)
|
|
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
|
|
_truncate_to_width(draw, header, title_font, text_w), title_font, BG)
|
|
y = y0 + header_h + 12
|
|
|
|
weather_entries = _weather_for_day(weather_cities, day)
|
|
if weather_entries:
|
|
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
|
|
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
|
|
palette_rgb=palette_rgb)
|
|
|
|
day_events = _events_on_day(events, day, tz)
|
|
row_h = body_font.size + 14
|
|
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
|
|
|
if not day_events:
|
|
draw_text(img, (text_x0, y), "Nothing scheduled", body_font)
|
|
for i, event in enumerate(day_events):
|
|
if i >= max_rows:
|
|
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font)
|
|
break
|
|
colors = _event_colors(event, owners_seen, palette_rgb)
|
|
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
|
|
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
|
prefix = f"{time_str} "
|
|
draw_text(img, (text_x0 + 18, y), prefix, body_font)
|
|
prefix_w = draw.textlength(prefix, font=body_font)
|
|
_draw_mixed_line(img, draw, (text_x0 + 18 + prefix_w, y), event["summary"],
|
|
body_font, text_w - 18 - prefix_w)
|
|
y += row_h
|
|
|
|
|
|
def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int],
|
|
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont,
|
|
palette_rgb: list | None = None, title: str = "Tasks") -> None:
|
|
"""A simple checklist filling `region` (x0, y0, w, h) -- a header
|
|
(`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
|
|
default; the only widget type with its own on-panel title, since
|
|
it's the only one where "which list is this" isn't obvious from its
|
|
content the way a calendar/photo/whiteboard's is), then a color chip
|
|
(reusing _event_colors/panel_style.draw_color_chip as-is: a task
|
|
dict's top-level owner_display_name/color_index is exactly
|
|
_event_colors' single-source fallback shape, since caldav_client.
|
|
merge_tasks doesn't cross-list-dedup tasks into a "sources" list the
|
|
way merge_events dedups events) + checkbox glyph + due date (if any)
|
|
+ summary per task, same header/row-cap/truncation shape as
|
|
_draw_agenda_day's event list so the standalone tasks widget (see
|
|
_build_tasks) reads as the same consistent design as everything
|
|
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
|
|
so a task summary with emoji in it renders the same way an event
|
|
title's does.
|
|
|
|
Outstanding tasks get an empty checkbox; completed ones (only ever
|
|
present when TaskWidgetConfig.show_completed is on -- see
|
|
caldav_client.fetch_tasks' completed_since) get a filled checkbox in
|
|
this widget's own Green accent (see panel_style.THEME) -- that fill
|
|
is the "done" signal, no due-date prefix (irrelevant once done) and
|
|
no separate muted text treatment (see module-level MUTED removal
|
|
note above _event_colors)."""
|
|
x0, y0, w, h = region
|
|
header_h = title_font.size + 20
|
|
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
|
|
panel_style.theme_color("tasks", palette_rgb))
|
|
text_x0 = x0 + MARGIN
|
|
text_w = w - MARGIN * 2
|
|
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
|
|
_truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font, BG)
|
|
y = y0 + header_h + 12
|
|
|
|
row_h = body_font.size + 14
|
|
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
|
|
|
if not tasks:
|
|
draw_text(img, (text_x0, y), "Nothing outstanding", body_font)
|
|
return
|
|
owners_seen: list[str] = []
|
|
checkbox_fill = panel_style.theme_color("tasks", palette_rgb)
|
|
for i, task in enumerate(tasks):
|
|
if i >= max_rows:
|
|
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font)
|
|
break
|
|
done = task.get("completed_at") is not None
|
|
colors = _event_colors(task, owners_seen, palette_rgb)
|
|
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
|
|
box = body_font.size - 6
|
|
box_x = text_x0 + 18
|
|
box_y = y + (row_h - box) // 2 - 5
|
|
box_r = min(panel_style.CHIP_RADIUS, box // 2)
|
|
if done:
|
|
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, fill=checkbox_fill)
|
|
else:
|
|
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, outline=FG, width=2)
|
|
text_x = box_x + box + 10
|
|
due_str = None if done else _fmt_task_due(task.get("due"))
|
|
prefix = f"{due_str} " if due_str else ""
|
|
if prefix:
|
|
draw_text(img, (text_x, y), prefix, body_font)
|
|
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
|
|
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
|
|
body_font, text_w - (text_x - text_x0) - prefix_w)
|
|
y += row_h
|
|
|
|
|
|
# Per-tier (title, body, weather) font sizes -- "Wednesday, July 22" at
|
|
# full size doesn't fit a narrow column, and a narrower box is exactly
|
|
# when a smaller font (rather than truncating to "Wednesday...") keeps
|
|
# the header actually informative.
|
|
_AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16, 13)}
|
|
|
|
|
|
def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
|
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
|
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
|
|
img, draw, region = panel_style.card_canvas(target_w, target_h)
|
|
|
|
title_size, body_size, weather_size = (
|
|
panel_style.scaled_size(v, font_scale) for v in _AGENDA_FONTS[_size_tier(target_w, target_h)]
|
|
)
|
|
title_font = panel_style.font_bold(title_size)
|
|
body_font = panel_style.font_regular(body_size)
|
|
weather_font = panel_style.font_regular(weather_size)
|
|
|
|
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
|
owners_seen: list[str] = []
|
|
_draw_agenda_day(img, draw, day, events, tz, region, title_font, body_font, owners_seen,
|
|
palette_rgb, weather_cities, weather_font, weather_units)
|
|
|
|
return img
|
|
|
|
|
|
_TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (15, 12, 10)}
|
|
|
|
|
|
def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
|
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
|
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
|
|
"""Two _draw_agenda_day sections stacked vertically (below each other
|
|
rather than side-by-side -- narrower than tall doesn't leave enough
|
|
width per day for the event-row text at smaller sizes). browse_offset
|
|
shifts the whole two-day window together, same "days" unit
|
|
_build_agenda already uses, so NEXT/BACK behaves identically across
|
|
both views."""
|
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
|
|
|
title_size, body_size, weather_size = (
|
|
panel_style.scaled_size(v, font_scale) for v in _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
|
|
)
|
|
title_font = panel_style.font_bold(title_size)
|
|
body_font = panel_style.font_regular(body_size)
|
|
weather_font = panel_style.font_regular(weather_size)
|
|
|
|
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
|
section_h = ch // 2
|
|
owners_seen: list[str] = []
|
|
for i in range(2):
|
|
section_y0 = cy0 + i * section_h
|
|
if i > 0:
|
|
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
|
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
|
|
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
|
|
palette_rgb, weather_cities, weather_font, weather_units)
|
|
|
|
return img
|
|
|
|
|
|
# Vertical layout's base (title, body, weather) sizes, before the
|
|
# per-day-count reduction below -- same three tiers as every other view.
|
|
_WEEK_VERTICAL_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (16, 12, 10)}
|
|
# Horizontal layout's (header, chip, weather) sizes.
|
|
_WEEK_HORIZONTAL_FONTS = {"large": (18, 14, 12), "medium": (14, 12, 10), "small": (11, 10, 8)}
|
|
|
|
|
|
def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
|
week_start: int, palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
days: int = 7, layout: str = "horizontal",
|
|
start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
|
|
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
|
|
columns (layout="horizontal", the original fixed-at-7 behavior
|
|
generalized) or stacked bands (layout="vertical", reusing
|
|
_draw_agenda_day the same way _build_today_tomorrow does, just for
|
|
an arbitrary day count instead of a hardcoded 2).
|
|
|
|
At the default 7 days, the view anchors to week_start (a fixed
|
|
weekday, "start on the most recent Monday") exactly like before --
|
|
otherwise "start of the week" doesn't mean much for an arbitrary day
|
|
count, so it instead starts `start_offset` days from today (0 =
|
|
today, see routers/api_widgets.py's api_widget_config_save)."""
|
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
|
tier = _size_tier(target_w, target_h)
|
|
|
|
today = datetime.now(tz).date()
|
|
if days == 7:
|
|
days_since_start = (today.weekday() - week_start) % 7
|
|
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
|
|
else:
|
|
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
|
|
owners_seen: list[str] = []
|
|
|
|
if layout == "vertical":
|
|
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
|
|
title_font = panel_style.font_bold(panel_style.scaled_size(max(14, title_base - days), font_scale))
|
|
body_font = panel_style.font_regular(panel_style.scaled_size(max(11, body_base - days), font_scale))
|
|
weather_font = panel_style.font_regular(panel_style.scaled_size(max(9, weather_base - days), font_scale))
|
|
section_h = ch // days
|
|
for i in range(days):
|
|
section_y0 = cy0 + i * section_h
|
|
if i > 0:
|
|
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
|
day = week_first_day + timedelta(days=i)
|
|
_draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h),
|
|
title_font, body_font, owners_seen, palette_rgb,
|
|
weather_cities, weather_font, weather_units)
|
|
return img
|
|
|
|
header_size, chip_size, weather_size = (
|
|
panel_style.scaled_size(v, font_scale) for v in _WEEK_HORIZONTAL_FONTS[tier]
|
|
)
|
|
header_font = panel_style.font_bold(header_size)
|
|
chip_font = panel_style.font_regular(chip_size)
|
|
weather_font = panel_style.font_regular(weather_size)
|
|
col_w = (cw - MARGIN * 2) // days
|
|
header_h = 44
|
|
|
|
for col in range(days):
|
|
day = week_first_day + timedelta(days=col)
|
|
x0 = cx0 + MARGIN + col * col_w
|
|
if col > 0:
|
|
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
|
|
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
|
|
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
|
|
|
|
y = cy0 + MARGIN + header_h
|
|
# Columns are narrow, so only what actually fits gets drawn (see
|
|
# weather_render.draw_weather_row) -- typically one city, no label
|
|
# (the column itself makes which day it's for obvious; a city name
|
|
# wouldn't fit anyway). Never more than that -- this is already
|
|
# the tight view.
|
|
weather_entries = _weather_for_day(weather_cities, day)
|
|
if weather_entries:
|
|
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
|
|
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
|
|
palette_rgb=palette_rgb)
|
|
row_h = chip_font.size + 10
|
|
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
|
|
day_events = _events_on_day(events, day, tz)
|
|
for i, event in enumerate(day_events):
|
|
if i >= max_rows:
|
|
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font)
|
|
break
|
|
colors = _event_colors(event, owners_seen, palette_rgb)
|
|
panel_style.draw_color_chip(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
|
|
if event["all_day"]:
|
|
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
|
|
else:
|
|
prefix = f"{_fmt_time(_event_start(event, tz))[:-3]} "
|
|
draw_text(img, (x0 + 16, y), prefix, chip_font)
|
|
prefix_w = draw.textlength(prefix, font=chip_font)
|
|
_draw_mixed_line(img, draw, (x0 + 16 + prefix_w, y), event["summary"],
|
|
chip_font, col_w - 20 - prefix_w)
|
|
y += row_h
|
|
|
|
return img
|
|
|
|
|
|
# Only "large"/"medium" in practice -- _build falls back to agenda view
|
|
# below the "small" tier (see _month_view_fits) -- but keyed defensively
|
|
# by tier rather than a bare bool so a future tier addition can't
|
|
# silently fall through to a KeyError here.
|
|
_MONTH_FONTS = {"large": (16, 18), "medium": (12, 13), "small": (12, 13)}
|
|
|
|
|
|
def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
|
week_start: int, palette_rgb: list | None = None, font_scale: float = 1.0) -> Image.Image:
|
|
"""Density dots per day, not literal event text -- real text at
|
|
typical month-cell size (~100x70px) is close to unreadable on a
|
|
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.
|
|
"Not in this month" day numbers used to be a muted gray -- now
|
|
de-emphasized by weight instead (Regular vs. Bold), same reasoning
|
|
as everywhere else this module dropped MUTED -- see module-level
|
|
comment above MARGIN/BG/FG."""
|
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
|
|
|
header_size, day_size = (
|
|
panel_style.scaled_size(v, font_scale) for v in _MONTH_FONTS[_size_tier(target_w, target_h)]
|
|
)
|
|
header_font = panel_style.font_bold(header_size)
|
|
day_font_in_month = panel_style.font_bold(day_size)
|
|
day_font_out_of_month = panel_style.font_regular(day_size)
|
|
|
|
today = datetime.now(tz).date()
|
|
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
|
|
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
|
|
|
|
col_w = (cw - MARGIN * 2) // 7
|
|
header_h = 28
|
|
grid_top = cy0 + MARGIN + header_h
|
|
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
|
|
today_accent = panel_style.theme_color("calendar", palette_rgb)
|
|
today_badge_r = min(panel_style.CHIP_RADIUS, 9)
|
|
|
|
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
|
|
for col, name in enumerate(day_names):
|
|
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font)
|
|
|
|
owners_seen: list[str] = []
|
|
dot_r = 6
|
|
for row, week in enumerate(weeks):
|
|
for col, day in enumerate(week):
|
|
x0 = cx0 + MARGIN + col * col_w
|
|
y0 = grid_top + row * row_h
|
|
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
|
|
in_month = day.month == target_month.month
|
|
if day == today:
|
|
# A filled accent badge (this widget's own theme color,
|
|
# see panel_style.THEME) instead of the old bare outline
|
|
# -- an actual "today" indicator, not just an outline
|
|
# easy to miss at ~24px. Sized around the actual digit
|
|
# bbox (not a fixed pixel box) so a bold 2-digit day
|
|
# number ("30") fits as comfortably as a single digit
|
|
# ("3") at every size tier.
|
|
day_str = str(day.day)
|
|
text_x, text_y = x0 + 6, y0 + 4
|
|
dbbox = draw.textbbox((text_x, text_y), day_str, font=day_font_in_month)
|
|
pad = 3
|
|
badge_rect = [dbbox[0] - pad, dbbox[1] - pad, dbbox[2] + pad, dbbox[3] + pad]
|
|
badge_r = min(today_badge_r, (badge_rect[3] - badge_rect[1]) // 2)
|
|
draw.rounded_rectangle(badge_rect, radius=badge_r, fill=today_accent)
|
|
draw_text(img, (text_x, text_y), day_str, day_font_in_month, BG)
|
|
else:
|
|
day_font = day_font_in_month if in_month else day_font_out_of_month
|
|
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font)
|
|
|
|
day_events = _events_on_day(events, day, tz)
|
|
dot_x = x0 + 8
|
|
dot_y = y0 + row_h - dot_r * 2 - 6
|
|
for i, event in enumerate(day_events[:4]):
|
|
# First contributing calendar's color only, even for a
|
|
# deduplicated shared event -- month view is density
|
|
# dots, not a place to also show which calendars a
|
|
# shared event came from (see _event_colors).
|
|
event_color = _event_colors(event, owners_seen, palette_rgb)[0]
|
|
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
|
|
dot_x += dot_r * 2 + 5
|
|
if len(day_events) > 4:
|
|
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font)
|
|
|
|
return img
|
|
|
|
|
|
_BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "week": _build_week,
|
|
"month": _build_month}
|
|
|
|
|
|
def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
|
|
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
week_days: int = 7, week_layout: str = "horizontal",
|
|
week_start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
|
|
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
|
effective_view = view
|
|
if view == "month" and not _month_view_fits(target_w, target_h):
|
|
effective_view = "agenda"
|
|
|
|
if effective_view == "agenda":
|
|
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
|
weather_cities, weather_units, font_scale)
|
|
elif effective_view == "today_tomorrow":
|
|
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
|
weather_cities, weather_units, font_scale)
|
|
elif effective_view == "week":
|
|
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
|
|
weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
|
|
elif effective_view == "month":
|
|
# Never given weather -- no room for it at typical month-cell
|
|
# size, same reasoning that already keeps this view to density
|
|
# dots instead of literal event text (see _build_month's own
|
|
# docstring). Colors are still passed through, though -- that's
|
|
# a different concern (legibility of individual events) than
|
|
# needing a whole extra strip of content.
|
|
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, font_scale)
|
|
else:
|
|
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
|
weather_cities, weather_units, font_scale)
|
|
|
|
if fetch_summary:
|
|
# Drawn as a final overlay onto the already-composited img (not
|
|
# inside any one _build_* branch above), so it offsets by
|
|
# panel_style.GUTTER itself to land inside the same visible
|
|
# margin every builder's own content already respects.
|
|
font = panel_style.font_regular(14 if _size_tier(target_w, target_h) != "small" else 11)
|
|
draw_text(img, (panel_style.GUTTER + MARGIN, target_h - panel_style.GUTTER - MARGIN - font.size),
|
|
fetch_summary, font)
|
|
|
|
return img
|
|
|
|
|
|
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
|
|
palette_rgb: list | None, timezone: str,
|
|
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
week_days: int = 7, week_layout: str = "horizontal",
|
|
week_start_offset: int = 0, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
|
|
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed
|
|
format. Returns exactly panel_w*panel_h/2 bytes (see
|
|
image_pipeline.panel_size), same invariant every other renderer
|
|
honors. weather_cities is routers/common.py's get_or_refresh_weather()
|
|
cache, or None/[] to omit the weather strip entirely (also always
|
|
omitted for view == "month")."""
|
|
target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
|
|
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
return _transpose_and_pack(quantized, orientation)
|
|
|
|
|
|
def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str,
|
|
palette_rgb: list | None, timezone: str,
|
|
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
week_days: int = 7, week_layout: str = "horizontal",
|
|
week_start_offset: int = 0, font_scale: float = 1.0,
|
|
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
|
|
"""Same pipeline as render_calendar, but a normal browser-viewable
|
|
PNG in logical (upright) orientation -- mirrors
|
|
image_pipeline.render_preview_png's relationship to render_frame."""
|
|
target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
|
|
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
buf = io.BytesIO()
|
|
quantized.convert("RGB").save(buf, format="PNG")
|
|
return buf.getvalue()
|
|
|
|
|
|
# --- Standalone tasks widget (split out of the old calendar-widget-only
|
|
# week-view task list -- see models.TaskWidgetConfig) -----------------
|
|
|
|
_TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)}
|
|
|
|
|
|
def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
|
title: str = "Tasks", font_scale: float = 1.0) -> Image.Image:
|
|
"""A tasks widget's entire region is the checklist -- unlike the old
|
|
week-view slot, there's no day columns/header to share space with,
|
|
so this is just _draw_tasks over the whole box."""
|
|
img, draw, region = panel_style.card_canvas(target_w, target_h)
|
|
title_size, body_size = (
|
|
panel_style.scaled_size(v, font_scale) for v in _TASKS_FONTS[_size_tier(target_w, target_h)]
|
|
)
|
|
title_font = panel_style.font_bold(title_size)
|
|
body_font = panel_style.font_regular(body_size)
|
|
_draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title)
|
|
return img
|
|
|
|
|
|
def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
|
|
manage: dict | None = None, title: str = "Tasks",
|
|
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
|
|
"""Renders the tasks widget full-panel to the panel's packed format.
|
|
Returns exactly panel_w*panel_h/2 bytes, same invariant every other
|
|
renderer honors."""
|
|
target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
|
|
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title)
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
return _transpose_and_pack(quantized, orientation)
|
|
|
|
|
|
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
|
|
manage: dict | None = None, title: str = "Tasks", font_scale: float = 1.0,
|
|
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
|
|
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
|
|
in logical (upright) orientation -- mirrors render_calendar_preview_
|
|
png's relationship to render_calendar."""
|
|
target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
|
|
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale)
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
buf = io.BytesIO()
|
|
quantized.convert("RGB").save(buf, format="PNG")
|
|
return buf.getvalue()
|