Calendar mode polish batch + Today & Tomorrow view
Responds to post-launch feedback on calendar mode: configurable week-start day for week/month views, crisper non-antialiased text (threshold-masked instead of drawn straight, so Floyd-Steinberg dithering doesn't speckle glyph edges), a color-coded/proportionally filled battery icon on the manage overlay, word-wrapped placeholder text so "Calendar isn't set up yet" no longer clips in portrait, photo inlay support extended from agenda-only to every view, and a fix so manage-overlay face labels reposition correctly when a photo inlay is active (they previously assumed the photo filled the whole canvas). Also adds a fourth calendar view, "Today & Tomorrow" -- a two-day agenda that reuses the same per-day row-layout helper the single-day agenda view already has.
This commit is contained in:
+163
-77
@@ -22,11 +22,14 @@ from .image_pipeline import (
|
||||
_quantize,
|
||||
_transpose_and_pack,
|
||||
compose_into,
|
||||
draw_text,
|
||||
logical_render_size,
|
||||
)
|
||||
|
||||
CALENDAR_VIEWS = ["agenda", "week", "month"]
|
||||
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "week": "Week", "month": "Month"}
|
||||
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)
|
||||
@@ -86,7 +89,9 @@ def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.Ima
|
||||
"""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."""
|
||||
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 = "..."
|
||||
@@ -100,27 +105,84 @@ def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.Ima
|
||||
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))
|
||||
|
||||
|
||||
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]) -> None:
|
||||
"""Draws one day's header + 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."""
|
||||
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
|
||||
|
||||
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
|
||||
color = _owner_color(event["owner_display_name"], owners_seen)
|
||||
draw.rectangle([text_x0, y + 3, text_x0 + 6, y + row_h - 8], fill=color)
|
||||
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
||||
line = f"{time_str} {event['summary']}"
|
||||
draw_text(img, (text_x0 + 16, y), _truncate_to_width(draw, line, body_font, text_w - 16), body_font)
|
||||
y += row_h
|
||||
|
||||
|
||||
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
||||
photo_inlay: Image.Image | None) -> Image.Image:
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
img = Image.new("RGB", (logical_w, logical_h), BG)
|
||||
|
||||
text_x0 = MARGIN
|
||||
text_w = logical_w - MARGIN * 2
|
||||
if photo_inlay is not None:
|
||||
# Long axis split: landscape splits left/right, portrait top/bottom.
|
||||
if logical_w >= logical_h:
|
||||
photo_w = logical_w // 2
|
||||
photo = compose_into(photo_inlay, None, photo_w, logical_h, "crop_fill")
|
||||
img.paste(photo, (0, 0))
|
||||
text_x0 = photo_w + MARGIN
|
||||
text_w = logical_w - photo_w - MARGIN * 2
|
||||
else:
|
||||
photo_h = logical_h // 2
|
||||
photo = compose_into(photo_inlay, None, logical_w, photo_h, "crop_fill")
|
||||
img.paste(photo, (0, 0))
|
||||
|
||||
_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...")
|
||||
@@ -128,109 +190,128 @@ def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz:
|
||||
title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24)
|
||||
body_font = ImageFont.load_default(size=22)
|
||||
|
||||
text_y0 = MARGIN if photo_inlay is None or logical_w >= logical_h else logical_h // 2 + MARGIN
|
||||
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||
header = day.strftime("%A, %B ") + str(day.day)
|
||||
draw.text((text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), fill=FG, font=title_font)
|
||||
y = text_y0 + title_font.size + 12
|
||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
||||
y += 12
|
||||
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
owners_seen: list[str] = []
|
||||
row_h = body_font.size + 14
|
||||
max_rows = max(0, (logical_h - MARGIN - y) // row_h)
|
||||
|
||||
if not day_events:
|
||||
draw.text((text_x0, y), "Nothing scheduled", fill=MUTED, font=body_font)
|
||||
for i, event in enumerate(day_events):
|
||||
if i >= max_rows:
|
||||
draw.text((text_x0, y), f"+{len(day_events) - max_rows} more", fill=MUTED, font=body_font)
|
||||
break
|
||||
color = _owner_color(event["owner_display_name"], owners_seen)
|
||||
draw.rectangle([text_x0, y + 3, text_x0 + 6, y + row_h - 8], fill=color)
|
||||
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
||||
line = f"{time_str} {event['summary']}"
|
||||
draw.text((text_x0 + 16, y), _truncate_to_width(draw, line, body_font, text_w - 16), fill=FG, font=body_font)
|
||||
y += row_h
|
||||
_draw_agenda_day(img, draw, day, events, tz, (cx0, cy0, cw, ch), title_font, body_font, owners_seen)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo) -> Image.Image:
|
||||
def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
||||
photo_inlay: Image.Image | None) -> 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)
|
||||
|
||||
header_font = ImageFont.load_default(size=18)
|
||||
chip_font = ImageFont.load_default(size=14)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
||||
photo_inlay: Image.Image | None, week_start: int) -> 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)
|
||||
|
||||
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)
|
||||
|
||||
today = datetime.now(tz).date()
|
||||
week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=browse_offset)
|
||||
col_w = (logical_w - MARGIN * 2) // 7
|
||||
days_since_start = (today.weekday() - week_start) % 7
|
||||
week_first_day = today - timedelta(days=days_since_start) + timedelta(weeks=browse_offset)
|
||||
col_w = (cw - MARGIN * 2) // 7
|
||||
header_h = 44
|
||||
owners_seen: list[str] = []
|
||||
|
||||
for col in range(7):
|
||||
day = week_start + timedelta(days=col)
|
||||
x0 = MARGIN + col * col_w
|
||||
day = week_first_day + timedelta(days=col)
|
||||
x0 = cx0 + MARGIN + col * col_w
|
||||
if col > 0:
|
||||
draw.line([(x0, MARGIN), (x0, logical_h - MARGIN)], fill=RULE)
|
||||
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((x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), fill=FG, font=header_font)
|
||||
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
|
||||
|
||||
y = MARGIN + header_h
|
||||
y = cy0 + MARGIN + header_h
|
||||
row_h = chip_font.size + 10
|
||||
max_rows = max(0, (logical_h - MARGIN - y) // row_h)
|
||||
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((x0 + 6, y), f"+{len(day_events) - max_rows}", fill=MUTED, font=chip_font)
|
||||
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
|
||||
break
|
||||
color = _owner_color(event["owner_display_name"], owners_seen)
|
||||
draw.rectangle([x0 + 4, y + 2, x0 + 8, y + row_h - 6], fill=color)
|
||||
text = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
|
||||
draw.text((x0 + 14, y), _truncate_to_width(draw, text, chip_font, col_w - 18), fill=FG, font=chip_font)
|
||||
draw_text(img, (x0 + 14, y), _truncate_to_width(draw, text, chip_font, col_w - 18), chip_font)
|
||||
y += row_h
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo) -> Image.Image:
|
||||
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
||||
photo_inlay: Image.Image | None, week_start: int) -> 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)
|
||||
day_font = ImageFont.load_default(size=18)
|
||||
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=0).monthdatescalendar(target_month.year, target_month.month))
|
||||
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
|
||||
|
||||
col_w = (logical_w - MARGIN * 2) // 7
|
||||
col_w = (cw - MARGIN * 2) // 7
|
||||
header_h = 28
|
||||
grid_top = MARGIN + header_h
|
||||
row_h = (logical_h - MARGIN - grid_top) // len(weeks)
|
||||
grid_top = cy0 + MARGIN + header_h
|
||||
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
|
||||
|
||||
for col, name in enumerate(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]):
|
||||
draw.text((MARGIN + col * col_w + 6, MARGIN), name, fill=MUTED, font=header_font)
|
||||
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 = 4
|
||||
for row, week in enumerate(weeks):
|
||||
for col, day in enumerate(week):
|
||||
x0 = MARGIN + col * col_w
|
||||
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
|
||||
color = FG if in_month else MUTED
|
||||
if day == today:
|
||||
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
|
||||
draw.text((x0 + 6, y0 + 4), str(day.day), fill=color, font=day_font)
|
||||
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, color)
|
||||
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
dot_x = x0 + 8
|
||||
@@ -240,39 +321,44 @@ def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: Z
|
||||
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=color)
|
||||
dot_x += dot_r * 2 + 4
|
||||
if len(day_events) > 4:
|
||||
draw.text((dot_x, dot_y - 4), f"+{len(day_events) - 4}", fill=MUTED, font=header_font)
|
||||
draw_text(img, (dot_x, dot_y - 4), f"+{len(day_events) - 4}", header_font, MUTED)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
_BUILDERS = {"agenda": _build_agenda, "week": _build_week, "month": _build_month}
|
||||
_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) -> Image.Image:
|
||||
photo_inlay: Image.Image | None, fetch_summary: str, week_start: int) -> Image.Image:
|
||||
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
||||
builder = _BUILDERS.get(view, _build_agenda)
|
||||
if builder is _build_agenda:
|
||||
if view == "agenda":
|
||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay)
|
||||
elif view == "today_tomorrow":
|
||||
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay)
|
||||
elif view == "week":
|
||||
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start)
|
||||
elif view == "month":
|
||||
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start)
|
||||
else:
|
||||
img = builder(events, browse_offset, orientation, tz)
|
||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay)
|
||||
|
||||
if fetch_summary:
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = ImageFont.load_default(size=14)
|
||||
logical_w, logical_h = img.size
|
||||
draw.text((MARGIN, logical_h - MARGIN - font.size), fetch_summary, fill=MUTED, font=font)
|
||||
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) -> bytes:
|
||||
fetch_summary: str = "", manage: dict | None = None, week_start: 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."""
|
||||
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary)
|
||||
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start)
|
||||
img = _apply_manage_overlay(img, manage)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
@@ -280,11 +366,11 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
|
||||
|
||||
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) -> bytes:
|
||||
fetch_summary: str = "", manage: dict | None = None, week_start: 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)
|
||||
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start)
|
||||
img = _apply_manage_overlay(img, manage)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
buf = io.BytesIO()
|
||||
|
||||
Reference in New Issue
Block a user