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,
|
_quantize,
|
||||||
_transpose_and_pack,
|
_transpose_and_pack,
|
||||||
compose_into,
|
compose_into,
|
||||||
|
draw_text,
|
||||||
logical_render_size,
|
logical_render_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
CALENDAR_VIEWS = ["agenda", "week", "month"]
|
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
||||||
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "week": "Week", "month": "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
|
MARGIN = 20
|
||||||
BG = (255, 255, 255)
|
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
|
"""Pixel-width-aware truncation (unlike device.py's char-count
|
||||||
_truncate, tuned for a fixed firmware font at a fixed size) -- this
|
_truncate, tuned for a fixed firmware font at a fixed size) -- this
|
||||||
module draws at several different sizes, so truncation has to
|
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:
|
if draw.textlength(text, font=font) <= max_width:
|
||||||
return text
|
return text
|
||||||
ellipsis = "..."
|
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
|
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,
|
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
||||||
photo_inlay: Image.Image | None) -> Image.Image:
|
photo_inlay: Image.Image | None) -> Image.Image:
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
img = Image.new("RGB", (logical_w, logical_h), BG)
|
img = Image.new("RGB", (logical_w, logical_h), BG)
|
||||||
|
|
||||||
text_x0 = MARGIN
|
|
||||||
text_w = logical_w - MARGIN * 2
|
|
||||||
if photo_inlay is not None:
|
if photo_inlay is not None:
|
||||||
# Long axis split: landscape splits left/right, portrait top/bottom.
|
_paste_inlay(img, photo_inlay, orientation)
|
||||||
if logical_w >= logical_h:
|
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
|
||||||
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))
|
|
||||||
|
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# Smaller title when the inlay halves the available width -- "Wednesday,
|
# Smaller title when the inlay halves the available width -- "Wednesday,
|
||||||
# July 22" at full size doesn't fit ~360px, and a *narrower* column is
|
# July 22" at full size doesn't fit ~360px, and a *narrower* column is
|
||||||
# exactly when a smaller font (rather than truncating to "Wednesday...")
|
# 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)
|
title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24)
|
||||||
body_font = ImageFont.load_default(size=22)
|
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)
|
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] = []
|
owners_seen: list[str] = []
|
||||||
row_h = body_font.size + 14
|
_draw_agenda_day(img, draw, day, events, tz, (cx0, cy0, cw, ch), title_font, body_font, owners_seen)
|
||||||
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
|
|
||||||
|
|
||||||
return img
|
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)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
img = Image.new("RGB", (logical_w, logical_h), BG)
|
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)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
header_font = ImageFont.load_default(size=18)
|
title_font = ImageFont.load_default(size=26 if photo_inlay is None else 20)
|
||||||
chip_font = ImageFont.load_default(size=14)
|
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()
|
today = datetime.now(tz).date()
|
||||||
week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=browse_offset)
|
days_since_start = (today.weekday() - week_start) % 7
|
||||||
col_w = (logical_w - MARGIN * 2) // 7
|
week_first_day = today - timedelta(days=days_since_start) + timedelta(weeks=browse_offset)
|
||||||
|
col_w = (cw - MARGIN * 2) // 7
|
||||||
header_h = 44
|
header_h = 44
|
||||||
owners_seen: list[str] = []
|
owners_seen: list[str] = []
|
||||||
|
|
||||||
for col in range(7):
|
for col in range(7):
|
||||||
day = week_start + timedelta(days=col)
|
day = week_first_day + timedelta(days=col)
|
||||||
x0 = MARGIN + col * col_w
|
x0 = cx0 + MARGIN + col * col_w
|
||||||
if col > 0:
|
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')}"
|
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
|
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)
|
day_events = _events_on_day(events, day, tz)
|
||||||
for i, event in enumerate(day_events):
|
for i, event in enumerate(day_events):
|
||||||
if i >= max_rows:
|
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
|
break
|
||||||
color = _owner_color(event["owner_display_name"], owners_seen)
|
color = _owner_color(event["owner_display_name"], owners_seen)
|
||||||
draw.rectangle([x0 + 4, y + 2, x0 + 8, y + row_h - 6], fill=color)
|
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']}"
|
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
|
y += row_h
|
||||||
|
|
||||||
return img
|
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
|
"""Density dots per day, not literal event text -- real text at
|
||||||
typical month-cell size (~100x70px) is close to unreadable on a
|
typical month-cell size (~100x70px) is close to unreadable on a
|
||||||
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
|
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
img = Image.new("RGB", (logical_w, logical_h), BG)
|
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)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
header_font = ImageFont.load_default(size=16)
|
header_font = ImageFont.load_default(size=16 if photo_inlay is None else 12)
|
||||||
day_font = ImageFont.load_default(size=18)
|
day_font = ImageFont.load_default(size=18 if photo_inlay is None else 13)
|
||||||
|
|
||||||
today = datetime.now(tz).date()
|
today = datetime.now(tz).date()
|
||||||
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
|
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
|
header_h = 28
|
||||||
grid_top = MARGIN + header_h
|
grid_top = cy0 + MARGIN + header_h
|
||||||
row_h = (logical_h - MARGIN - grid_top) // len(weeks)
|
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
|
||||||
|
|
||||||
for col, name in enumerate(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]):
|
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
|
||||||
draw.text((MARGIN + col * col_w + 6, MARGIN), name, fill=MUTED, font=header_font)
|
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] = []
|
owners_seen: list[str] = []
|
||||||
dot_r = 4
|
dot_r = 4
|
||||||
for row, week in enumerate(weeks):
|
for row, week in enumerate(weeks):
|
||||||
for col, day in enumerate(week):
|
for col, day in enumerate(week):
|
||||||
x0 = MARGIN + col * col_w
|
x0 = cx0 + MARGIN + col * col_w
|
||||||
y0 = grid_top + row * row_h
|
y0 = grid_top + row * row_h
|
||||||
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
|
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
|
||||||
in_month = day.month == target_month.month
|
in_month = day.month == target_month.month
|
||||||
color = FG if in_month else MUTED
|
color = FG if in_month else MUTED
|
||||||
if day == today:
|
if day == today:
|
||||||
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
|
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)
|
day_events = _events_on_day(events, day, tz)
|
||||||
dot_x = x0 + 8
|
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)
|
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=color)
|
||||||
dot_x += dot_r * 2 + 4
|
dot_x += dot_r * 2 + 4
|
||||||
if len(day_events) > 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
|
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,
|
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")
|
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
||||||
builder = _BUILDERS.get(view, _build_agenda)
|
if view == "agenda":
|
||||||
if builder is _build_agenda:
|
|
||||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay)
|
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:
|
else:
|
||||||
img = builder(events, browse_offset, orientation, tz)
|
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay)
|
||||||
|
|
||||||
if fetch_summary:
|
if fetch_summary:
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
font = ImageFont.load_default(size=14)
|
font = ImageFont.load_default(size=14)
|
||||||
logical_w, logical_h = img.size
|
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
|
return img
|
||||||
|
|
||||||
|
|
||||||
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
|
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,
|
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
|
"""Renders one of CALENDAR_VIEWS to the panel's packed format. Always
|
||||||
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every
|
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every
|
||||||
other renderer honors."""
|
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)
|
img = _apply_manage_overlay(img, manage)
|
||||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
return _transpose_and_pack(quantized, orientation)
|
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,
|
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,
|
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
|
"""Same pipeline as render_calendar, but a normal browser-viewable
|
||||||
PNG in logical (upright) orientation -- mirrors
|
PNG in logical (upright) orientation -- mirrors
|
||||||
image_pipeline.render_preview_png's relationship to render_frame."""
|
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)
|
img = _apply_manage_overlay(img, manage)
|
||||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ MAX_LABELED_FACES = 6
|
|||||||
|
|
||||||
|
|
||||||
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
|
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
|
||||||
orientation: str = "landscape") -> list[dict]:
|
orientation: str = "landscape", region: tuple[int, int, int, int] | None = None) -> list[dict]:
|
||||||
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
|
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
|
||||||
logical (pre-rotation) frame space at each named face's bottom-center
|
logical (pre-rotation) frame space at each named face's bottom-center
|
||||||
point -- manage_overlay.compose() draws these directly onto the
|
point -- manage_overlay.compose() draws these directly onto the
|
||||||
@@ -38,6 +38,15 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
|
|||||||
match the settings that were active then -- otherwise the placement
|
match the settings that were active then -- otherwise the placement
|
||||||
computed here won't match what's actually on screen.
|
computed here won't match what's actually on screen.
|
||||||
|
|
||||||
|
`region` is (x0, y0, w, h): where in the logical canvas the photo
|
||||||
|
actually landed, if not the whole thing -- e.g. calendar mode's
|
||||||
|
agenda photo-inlay only occupies half the panel (see
|
||||||
|
calendar_render.inlay_region), and without this a label would be
|
||||||
|
placed as if the photo filled the entire canvas, landing well off
|
||||||
|
where the inlaid photo actually is. None (the default) means the
|
||||||
|
photo fills the whole logical canvas, matching every other caller
|
||||||
|
(photos mode always renders full-panel).
|
||||||
|
|
||||||
The placement math matches render_frame()'s own composition step
|
The placement math matches render_frame()'s own composition step
|
||||||
exactly (see image_pipeline._placement_transform, shared so the two
|
exactly (see image_pipeline._placement_transform, shared so the two
|
||||||
can't drift apart).
|
can't drift apart).
|
||||||
@@ -46,11 +55,16 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
|
|||||||
if not named:
|
if not named:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
if region is None:
|
||||||
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
|
region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h
|
||||||
|
else:
|
||||||
|
region_x0, region_y0, target_w, target_h = region
|
||||||
|
|
||||||
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
|
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
|
||||||
|
|
||||||
scale_x, scale_y, offset_x, offset_y = _placement_transform(
|
scale_x, scale_y, offset_x, offset_y = _placement_transform(
|
||||||
fitted.width, fitted.height, logical_w, logical_h, display_mode, faces
|
fitted.width, fitted.height, target_w, target_h, display_mode, faces
|
||||||
)
|
)
|
||||||
|
|
||||||
labels = []
|
labels = []
|
||||||
@@ -65,12 +79,16 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
|
|||||||
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
|
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
|
||||||
bottom_y = face["boundingBoxY2"] * img_scale_y
|
bottom_y = face["boundingBoxY2"] * img_scale_y
|
||||||
|
|
||||||
frame_x = center_x * scale_x + offset_x
|
# Relative to the region's own origin first (matches
|
||||||
frame_y = bottom_y * scale_y + offset_y
|
# _placement_transform's target_w/target_h space), then shifted
|
||||||
|
# into full-canvas coordinates.
|
||||||
|
region_x = center_x * scale_x + offset_x
|
||||||
|
region_y = bottom_y * scale_y + offset_y
|
||||||
|
|
||||||
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
|
if not (0 <= region_x <= target_w and 0 <= region_y <= target_h):
|
||||||
continue # this face got cropped out of the final frame entirely
|
continue # this face got cropped out of the region entirely
|
||||||
|
|
||||||
labels.append({"name": face["person"]["name"], "x": int(frame_x), "y": int(frame_y)})
|
labels.append({"name": face["person"]["name"],
|
||||||
|
"x": int(region_x + region_x0), "y": int(region_y + region_y0)})
|
||||||
|
|
||||||
return labels
|
return labels
|
||||||
|
|||||||
@@ -4,11 +4,34 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from PIL import Image, ImageEnhance, ImageOps
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
||||||
|
|
||||||
EPD_WIDTH = 800
|
EPD_WIDTH = 800
|
||||||
EPD_HEIGHT = 480
|
EPD_HEIGHT = 480
|
||||||
|
|
||||||
|
# PIL's TrueType rendering antialiases by default (graduated gray edge
|
||||||
|
# pixels). Those survive straight into _quantize's Floyd-Steinberg
|
||||||
|
# dithering, which -- confirmed visually -- turns them into scattered
|
||||||
|
# colored speckles along every glyph edge once forced onto the panel's 6
|
||||||
|
# colors, since a mid-gray input has no close palette match and the
|
||||||
|
# diffused error bounces between whichever colors are nearest. Drawing
|
||||||
|
# through a thresholded bilevel mask instead keeps every edge pure
|
||||||
|
# black/white, which _quantize then reproduces exactly (both are already
|
||||||
|
# palette colors, nothing to dither). Shared by every module that draws
|
||||||
|
# text before quantization (this file's render_placeholder,
|
||||||
|
# calendar_render.py, manage_overlay.py).
|
||||||
|
_TEXT_MASK_THRESHOLD = 110
|
||||||
|
|
||||||
|
|
||||||
|
def draw_text(img: Image.Image, xy: tuple[int, int], text: str, font: ImageFont.ImageFont,
|
||||||
|
fill: tuple[int, int, int] = (0, 0, 0)) -> None:
|
||||||
|
bbox = font.getbbox(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]), text, fill=255, font=font)
|
||||||
|
mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0)
|
||||||
|
img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask)
|
||||||
|
|
||||||
# How each orientation maps the logically-composed image onto the native
|
# How each orientation maps the logically-composed image onto the native
|
||||||
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
|
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
|
||||||
# crop ratio matches how the frame actually hangs) and rotate into native
|
# crop ratio matches how the frame actually hangs) and rotate into native
|
||||||
@@ -387,14 +410,14 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|||||||
`manage`, same as render_frame's -- lets the manage button still work
|
`manage`, same as render_frame's -- lets the manage button still work
|
||||||
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
||||||
yet."""
|
yet."""
|
||||||
from PIL import ImageDraw, ImageFont
|
margin = 24
|
||||||
|
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img) # measurement only (textbbox/textlength) -- painting goes through draw_text
|
||||||
|
|
||||||
title_font = ImageFont.load_default(size=34)
|
title_font = ImageFont.load_default(size=34)
|
||||||
body_font = ImageFont.load_default(size=24)
|
body_font = ImageFont.load_default(size=24)
|
||||||
|
max_text_w = logical_w - margin * 2
|
||||||
|
|
||||||
qr_img = None
|
qr_img = None
|
||||||
if qr_url:
|
if qr_url:
|
||||||
@@ -409,19 +432,39 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|||||||
scale = max(1, target // raw.width)
|
scale = max(1, target // raw.width)
|
||||||
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
||||||
|
|
||||||
|
# Word-wrap each input line to the panel's actual width (portrait is
|
||||||
|
# much narrower than landscape -- a line written assuming ~800px
|
||||||
|
# would otherwise run straight off the edge) before laying anything
|
||||||
|
# out, so wrapped sub-lines count toward the vertical centering below.
|
||||||
|
def wrap(text: str, font) -> list[str]:
|
||||||
|
words = text.split()
|
||||||
|
if not words:
|
||||||
|
return [text]
|
||||||
|
out, current = [], words[0]
|
||||||
|
for word in words[1:]:
|
||||||
|
candidate = f"{current} {word}"
|
||||||
|
if draw.textlength(candidate, font=font) <= max_text_w:
|
||||||
|
current = candidate
|
||||||
|
else:
|
||||||
|
out.append(current)
|
||||||
|
current = word
|
||||||
|
out.append(current)
|
||||||
|
return out
|
||||||
|
|
||||||
# Vertical layout: text block, then QR under it, centered as a group.
|
# Vertical layout: text block, then QR under it, centered as a group.
|
||||||
line_heights = []
|
line_heights = []
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
font = title_font if i == 0 else body_font
|
font = title_font if i == 0 else body_font
|
||||||
bbox = draw.textbbox((0, 0), line, font=font)
|
for sub_line in wrap(line, font):
|
||||||
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
|
bbox = draw.textbbox((0, 0), sub_line, font=font)
|
||||||
|
line_heights.append((sub_line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
|
||||||
gap = 14
|
gap = 14
|
||||||
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
|
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
|
||||||
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
|
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
|
||||||
y = max(20, (logical_h - total_h) // 2)
|
y = max(20, (logical_h - total_h) // 2)
|
||||||
|
|
||||||
for line, font, w, h in line_heights:
|
for line, font, w, h in line_heights:
|
||||||
draw.text(((logical_w - w) // 2, y), line, fill=(0, 0, 0), font=font)
|
draw_text(img, ((logical_w - w) // 2, y), line, font)
|
||||||
y += h + gap
|
y += h + gap
|
||||||
|
|
||||||
if qr_img:
|
if qr_img:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from .image_pipeline import DEFAULT_PALETTE_RGB, draw_text
|
||||||
|
|
||||||
PADDING = 16
|
PADDING = 16
|
||||||
QR_TEXT_GAP = 8
|
QR_TEXT_GAP = 8
|
||||||
LINE_GAP = 4
|
LINE_GAP = 4
|
||||||
@@ -62,13 +64,13 @@ def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.Image
|
|||||||
return w, h
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
def _draw_centered_lines(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
|
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
|
||||||
center_x: int, top: int) -> None:
|
center_x: int, top: int) -> None:
|
||||||
y = top
|
y = top
|
||||||
for line in lines:
|
for line in lines:
|
||||||
bbox = draw.textbbox((0, 0), line, font=font)
|
bbox = draw.textbbox((0, 0), line, font=font)
|
||||||
w = bbox[2] - bbox[0]
|
w = bbox[2] - bbox[0]
|
||||||
draw.text((center_x - w // 2, y), line, fill=(0, 0, 0), font=font)
|
draw_text(img, (center_x - w // 2, y), line, font)
|
||||||
y += (bbox[3] - bbox[1]) + LINE_GAP
|
y += (bbox[3] - bbox[1]) + LINE_GAP
|
||||||
|
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
|
|||||||
center_x = x0 + w // 2
|
center_x = x0 + w // 2
|
||||||
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
|
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
|
||||||
if caption:
|
if caption:
|
||||||
_draw_centered_lines(draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
|
_draw_centered_lines(img, draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
|
||||||
return x0, y0, w, h
|
return x0, y0, w, h
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +108,7 @@ def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str]
|
|||||||
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
||||||
|
|
||||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
||||||
_draw_centered_lines(draw, lines, font, x0 + w // 2, y0 + PADDING)
|
_draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING)
|
||||||
|
|
||||||
|
|
||||||
def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]:
|
def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]:
|
||||||
@@ -121,12 +123,33 @@ def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner:
|
|||||||
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
|
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
|
||||||
|
|
||||||
|
|
||||||
|
# DEFAULT_PALETTE_RGB order is [BLACK, WHITE, YELLOW, RED, BLUE, GREEN]
|
||||||
|
# (see image_pipeline.PANEL_CODES) -- picked by level so the fill itself
|
||||||
|
# carries the "how worried should I be" signal, not just the number next
|
||||||
|
# to it. Thresholds match the low-battery-alert spirit elsewhere in this
|
||||||
|
# project (not tied to a frame's own configured alert threshold, since
|
||||||
|
# this glyph has to make sense with no configuration at all).
|
||||||
|
_BATTERY_LOW = DEFAULT_PALETTE_RGB[3] # red
|
||||||
|
_BATTERY_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
|
||||||
|
_BATTERY_HIGH = DEFAULT_PALETTE_RGB[5] # green
|
||||||
|
|
||||||
|
|
||||||
|
def _battery_fill_color(percent: int) -> tuple[int, int, int]:
|
||||||
|
if percent <= 15:
|
||||||
|
return _BATTERY_LOW
|
||||||
|
if percent <= 40:
|
||||||
|
return _BATTERY_MEDIUM
|
||||||
|
return _BATTERY_HIGH
|
||||||
|
|
||||||
|
|
||||||
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
|
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
|
||||||
anchor_w: int, anchor_h: int) -> None:
|
anchor_w: int, anchor_h: int) -> None:
|
||||||
"""Battery glyph + "NN%" text, right-aligned under the given anchor
|
"""Battery glyph (now actually filled to `percent`, not just a static
|
||||||
box (the manage QR box) -- a sensible default position, not a
|
outline -- easy now that this renders server-side instead of being a
|
||||||
constraint anything else has to route around; move this call site's
|
fixed bitmap firmware drew) + "NN%" text, right-aligned under the
|
||||||
arguments to place it anywhere else instead."""
|
given anchor box (the manage QR box) -- a sensible default position,
|
||||||
|
not a constraint anything else has to route around; move this call
|
||||||
|
site's arguments to place it anywhere else instead."""
|
||||||
font = _font(BODY_FONT_SIZE)
|
font = _font(BODY_FONT_SIZE)
|
||||||
text = f"{percent}%"
|
text = f"{percent}%"
|
||||||
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
|
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
|
||||||
@@ -143,13 +166,18 @@ def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anc
|
|||||||
|
|
||||||
icon_x = x0 + PADDING
|
icon_x = x0 + PADDING
|
||||||
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
|
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
|
||||||
|
inner_x0, inner_y0 = icon_x + BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_STROKE
|
||||||
|
inner_x1, inner_y1 = icon_x + BATTERY_ICON_W - BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_H - BATTERY_ICON_STROKE
|
||||||
|
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (percent / 100))
|
||||||
|
if fill_x1 > inner_x0:
|
||||||
|
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_battery_fill_color(percent))
|
||||||
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
|
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
|
||||||
width=BATTERY_ICON_STROKE)
|
width=BATTERY_ICON_STROKE)
|
||||||
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
|
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
|
||||||
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
|
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
|
||||||
fill=(0, 0, 0))
|
fill=(0, 0, 0))
|
||||||
draw.text((icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
||||||
text, fill=(0, 0, 0), font=font)
|
text, font)
|
||||||
|
|
||||||
|
|
||||||
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
|
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
|
||||||
@@ -174,7 +202,7 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
|
|||||||
y0 = max(0, min(y0, img_h - h))
|
y0 = max(0, min(y0, img_h - h))
|
||||||
|
|
||||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
||||||
draw.text((x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, fill=(0, 0, 0), font=font)
|
draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
|
||||||
|
|
||||||
|
|
||||||
def compose(image: Image.Image, management_url: str, battery_percent: int | None = None,
|
def compose(image: Image.Image, management_url: str, battery_percent: int | None = None,
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ def _migration_7(conn) -> None:
|
|||||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_8(conn) -> None:
|
||||||
|
"""Configurable week-start day for calendar mode's week/month views
|
||||||
|
(0=Monday..6=Sunday, matching Python's date.weekday()/calendar.Calendar
|
||||||
|
convention exactly -- no translation needed at render time). Default 0
|
||||||
|
(Monday) matches calendar_render.py's previous hardcoded behavior, so
|
||||||
|
this is a no-op for every existing frame until changed."""
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -108,6 +118,7 @@ MIGRATIONS = [
|
|||||||
(5, _migration_5),
|
(5, _migration_5),
|
||||||
(6, _migration_6),
|
(6, _migration_6),
|
||||||
(7, _migration_7),
|
(7, _migration_7),
|
||||||
|
(8, _migration_8),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ class Frame(Base):
|
|||||||
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
||||||
# routers/device.py's RENDERERS["calendar"]) --
|
# routers/device.py's RENDERERS["calendar"]) --
|
||||||
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
|
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
|
||||||
|
# 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
|
||||||
|
# which day week/month views start their grid on.
|
||||||
|
calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
# Agenda view only; reuses this frame's existing photos-mode album/
|
# Agenda view only; reuses this frame's existing photos-mode album/
|
||||||
# queue, not a separate photo setup.
|
# queue, not a separate photo setup.
|
||||||
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
|
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ def api_config_save(
|
|||||||
mode: str | None = Form(None),
|
mode: str | None = Form(None),
|
||||||
calendar_view: str | None = Form(None),
|
calendar_view: str | None = Form(None),
|
||||||
calendar_photo_inlay: bool | None = Form(None),
|
calendar_photo_inlay: bool | None = Form(None),
|
||||||
|
calendar_week_start: int | None = Form(None),
|
||||||
frame: Frame = Depends(require_frame_control),
|
frame: Frame = Depends(require_frame_control),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -174,6 +175,8 @@ def api_config_save(
|
|||||||
cfg.calendar_view = new_view
|
cfg.calendar_view = new_view
|
||||||
if calendar_photo_inlay is not None:
|
if calendar_photo_inlay is not None:
|
||||||
cfg.calendar_photo_inlay = calendar_photo_inlay
|
cfg.calendar_photo_inlay = calendar_photo_inlay
|
||||||
|
if calendar_week_start is not None:
|
||||||
|
cfg.calendar_week_start = max(0, min(6, calendar_week_start))
|
||||||
cfg.stats_config_saves += 1
|
cfg.stats_config_saves += 1
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
@@ -436,13 +439,13 @@ def api_calendar_included(
|
|||||||
|
|
||||||
|
|
||||||
def _calendar_photo_inlay(frame: Frame, db: Session):
|
def _calendar_photo_inlay(frame: Frame, db: Session):
|
||||||
"""The agenda view's optional photo-inlay source image, or None if
|
"""The photo-inlay's source image (any view now, not just agenda), or
|
||||||
inlay is off, not agenda view, or the frame's photos-mode album isn't
|
None if inlay is off or the frame's photos-mode album isn't
|
||||||
configured. Shared shape between the live render (routers/device.py's
|
configured. Shared shape between the live render (routers/device.py's
|
||||||
_render_calendar_mode) and this preview endpoint; small enough that
|
_render_calendar_mode) and this preview endpoint; small enough that
|
||||||
duplicating rather than factoring out is fine, since the two call
|
duplicating rather than factoring out is fine, since the two call
|
||||||
sites differ slightly in error handling."""
|
sites differ slightly in error handling."""
|
||||||
if not (frame.calendar_view == "agenda" and frame.calendar_photo_inlay):
|
if not frame.calendar_photo_inlay:
|
||||||
return None
|
return None
|
||||||
url, key = immich_creds(frame)
|
url, key = immich_creds(frame)
|
||||||
if not (url and key and frame.album_id):
|
if not (url and key and frame.album_id):
|
||||||
@@ -477,6 +480,7 @@ def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session
|
|||||||
png = calendar_render.render_calendar_preview_png(
|
png = calendar_render.render_calendar_preview_png(
|
||||||
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
|
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
|
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
|
||||||
|
week_start=frame.calendar_week_start,
|
||||||
)
|
)
|
||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|||||||
@@ -244,15 +244,29 @@ def _format_taken_at(exif: dict) -> str | None:
|
|||||||
def _manage_content_asset_id(frame: Frame) -> str | None:
|
def _manage_content_asset_id(frame: Frame) -> str | None:
|
||||||
"""Whether frame.current_asset_id refers to a photo actually visible
|
"""Whether frame.current_asset_id refers to a photo actually visible
|
||||||
right now, for whichever mode is active -- always true in photos
|
right now, for whichever mode is active -- always true in photos
|
||||||
mode; only true in calendar mode when the agenda view's photo inlay
|
mode; only true in calendar mode when that view's photo inlay is on
|
||||||
is on (otherwise current_asset_id could be stale, left over from
|
(otherwise current_asset_id could be stale, left over from whenever
|
||||||
whenever photos mode last ran, and showing its location/date/share
|
photos mode last ran, and showing its location/date/share info on a
|
||||||
info on a manage overlay over a view with no visible photo at all
|
manage overlay over a view with no visible photo at all would be
|
||||||
would be actively misleading, not just unhelpful)."""
|
actively misleading, not just unhelpful)."""
|
||||||
relevant = frame.mode != "calendar" or (frame.calendar_view == "agenda" and frame.calendar_photo_inlay)
|
relevant = frame.mode != "calendar" or frame.calendar_photo_inlay
|
||||||
return frame.current_asset_id if relevant and frame.current_asset_id else None
|
return frame.current_asset_id if relevant and frame.current_asset_id else None
|
||||||
|
|
||||||
|
|
||||||
|
def _manage_content_region(frame: Frame) -> tuple[int, int, int, int] | None:
|
||||||
|
"""Where the photo behind _manage_content_asset_id actually landed in
|
||||||
|
the logical canvas -- None (the whole canvas) in photos mode, or
|
||||||
|
calendar_render.inlay_region(...) when a calendar view's photo inlay
|
||||||
|
is what's showing. Needed so face labels (and, if ever added, other
|
||||||
|
photo-relative overlay positioning) land on the actual inlaid photo
|
||||||
|
instead of where a full-panel photo would have been."""
|
||||||
|
if frame.mode == "calendar" and frame.calendar_photo_inlay:
|
||||||
|
from ..calendar_render import inlay_region
|
||||||
|
|
||||||
|
return inlay_region(frame.orientation)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||||
"""Gathers everything manage_overlay.compose() needs -- what used to
|
"""Gathers everything manage_overlay.compose() needs -- what used to
|
||||||
be two separate device-facing endpoints (/frame/photo-info,
|
be two separate device-facing endpoints (/frame/photo-info,
|
||||||
@@ -293,7 +307,10 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
|||||||
preview_bytes = client.download_asset_preview(asset_id)
|
preview_bytes = client.download_asset_preview(asset_id)
|
||||||
from ..face_labels import compute_face_labels
|
from ..face_labels import compute_face_labels
|
||||||
|
|
||||||
content["face_labels"] = compute_face_labels(preview_bytes, faces, frame.display_mode, frame.orientation)
|
content["face_labels"] = compute_face_labels(
|
||||||
|
preview_bytes, faces, frame.display_mode, frame.orientation,
|
||||||
|
region=_manage_content_region(frame),
|
||||||
|
)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d
|
|||||||
locked.calendar_browse_offset = 0
|
locked.calendar_browse_offset = 0
|
||||||
browse_offset = locked.calendar_browse_offset
|
browse_offset = locked.calendar_browse_offset
|
||||||
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
||||||
inlay_wanted = locked.calendar_photo_inlay and view == "agenda"
|
week_start = locked.calendar_week_start
|
||||||
|
inlay_wanted = locked.calendar_photo_inlay
|
||||||
|
|
||||||
events, summary = get_or_refresh_calendar_events(db, frame)
|
events, summary = get_or_refresh_calendar_events(db, frame)
|
||||||
|
|
||||||
@@ -169,7 +170,7 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d
|
|||||||
return calendar_render.render_calendar(
|
return calendar_render.render_calendar(
|
||||||
events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
|
events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
|
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
|
||||||
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage,
|
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user