|
|
|
@@ -27,7 +27,6 @@ from .image_pipeline import (
|
|
|
|
|
_apply_manage_overlay,
|
|
|
|
|
_quantize,
|
|
|
|
|
_transpose_and_pack,
|
|
|
|
|
compose_into,
|
|
|
|
|
draw_text,
|
|
|
|
|
logical_render_size,
|
|
|
|
|
)
|
|
|
|
@@ -328,39 +327,35 @@ 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
|
|
|
|
|
# --- 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 _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 _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 _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 _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
|
|
|
|
@@ -577,70 +572,75 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
|
|
|
|
|
y += row_h
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
|
|
|
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
|
|
|
|
|
weather_cities: list[dict] | None = None,
|
|
|
|
|
# Per-tier (title, body, weather) font sizes -- "Wednesday, July 22" at
|
|
|
|
|
# full size doesn't fit a narrow column, and a narrower box is exactly
|
|
|
|
|
# when a smaller font (rather than truncating to "Wednesday...") keeps
|
|
|
|
|
# the header actually informative.
|
|
|
|
|
_AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16, 13)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
|
|
|
|
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
|
|
|
|
weather_units: str = "fahrenheit") -> Image.Image:
|
|
|
|
|
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)
|
|
|
|
|
img = Image.new("RGB", (target_w, target_h), BG)
|
|
|
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
|
|
|
|
|
|
# Smaller title when the inlay halves the available width -- "Wednesday,
|
|
|
|
|
# July 22" at full size doesn't fit ~360px, and a *narrower* column is
|
|
|
|
|
# exactly when a smaller font (rather than truncating to "Wednesday...")
|
|
|
|
|
# keeps it actually informative.
|
|
|
|
|
title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24)
|
|
|
|
|
body_font = ImageFont.load_default(size=22)
|
|
|
|
|
weather_font = ImageFont.load_default(size=20 if photo_inlay is None else 16)
|
|
|
|
|
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
|
|
|
|
|
title_font = ImageFont.load_default(size=title_size)
|
|
|
|
|
body_font = ImageFont.load_default(size=body_size)
|
|
|
|
|
weather_font = ImageFont.load_default(size=weather_size)
|
|
|
|
|
|
|
|
|
|
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
|
|
|
|
owners_seen: list[str] = []
|
|
|
|
|
_draw_agenda_day(img, draw, day, events, tz, (cx0, cy0, cw, ch), title_font, body_font, owners_seen,
|
|
|
|
|
_draw_agenda_day(img, draw, day, events, tz, (0, 0, target_w, target_h), title_font, body_font, owners_seen,
|
|
|
|
|
palette_rgb, weather_cities, weather_font, weather_units)
|
|
|
|
|
|
|
|
|
|
return img
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
|
|
|
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
|
|
|
|
|
weather_cities: list[dict] | None = None,
|
|
|
|
|
_TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (15, 12, 10)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
|
|
|
|
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
|
|
|
|
weather_units: str = "fahrenheit") -> Image.Image:
|
|
|
|
|
"""Two _draw_agenda_day sections stacked vertically 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)
|
|
|
|
|
"""Two _draw_agenda_day sections stacked vertically (below each other
|
|
|
|
|
rather than side-by-side -- narrower than tall doesn't leave enough
|
|
|
|
|
width per day for the event-row text at smaller sizes). browse_offset
|
|
|
|
|
shifts the whole two-day window together, same "days" unit
|
|
|
|
|
_build_agenda already uses, so NEXT/BACK behaves identically across
|
|
|
|
|
both views."""
|
|
|
|
|
img = Image.new("RGB", (target_w, target_h), BG)
|
|
|
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
|
|
|
|
|
|
title_font = ImageFont.load_default(size=26 if photo_inlay is None else 20)
|
|
|
|
|
body_font = ImageFont.load_default(size=18 if photo_inlay is None else 15)
|
|
|
|
|
weather_font = ImageFont.load_default(size=16 if photo_inlay is None else 13)
|
|
|
|
|
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
|
|
|
|
|
title_font = ImageFont.load_default(size=title_size)
|
|
|
|
|
body_font = ImageFont.load_default(size=body_size)
|
|
|
|
|
weather_font = ImageFont.load_default(size=weather_size)
|
|
|
|
|
|
|
|
|
|
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
|
|
|
|
section_h = ch // 2
|
|
|
|
|
section_h = target_h // 2
|
|
|
|
|
owners_seen: list[str] = []
|
|
|
|
|
for i in range(2):
|
|
|
|
|
section_y0 = cy0 + i * section_h
|
|
|
|
|
section_y0 = i * section_h
|
|
|
|
|
if i > 0:
|
|
|
|
|
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
|
|
|
|
draw.line([(MARGIN, section_y0), (target_w - 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,
|
|
|
|
|
(0, section_y0, target_w, section_h), title_font, body_font, owners_seen,
|
|
|
|
|
palette_rgb, weather_cities, weather_font, weather_units)
|
|
|
|
|
|
|
|
|
|
return img
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
|
|
|
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None,
|
|
|
|
|
# Vertical layout's base (title, body, weather) sizes, before the
|
|
|
|
|
# per-day-count reduction below -- same three tiers as every other view.
|
|
|
|
|
_WEEK_VERTICAL_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (16, 12, 10)}
|
|
|
|
|
# Horizontal layout's (header, chip, weather) sizes.
|
|
|
|
|
_WEEK_HORIZONTAL_FONTS = {"large": (18, 14, 12), "medium": (14, 12, 10), "small": (11, 10, 8)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
|
|
|
|
week_start: int, palette_rgb: list | None = None,
|
|
|
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
|
|
|
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None,
|
|
|
|
|
start_offset: int = 0) -> Image.Image:
|
|
|
|
@@ -658,12 +658,9 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
|
|
|
|
|
otherwise "start of the week" doesn't mean much for an arbitrary day
|
|
|
|
|
count, so it instead starts `start_offset` days from today (0 =
|
|
|
|
|
today, see routers/api_frames.py's api_config_save)."""
|
|
|
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
|
|
|
img = Image.new("RGB", (logical_w, logical_h), BG)
|
|
|
|
|
if photo_inlay is not None:
|
|
|
|
|
_paste_inlay(img, photo_inlay, orientation)
|
|
|
|
|
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
|
|
|
|
|
img = Image.new("RGB", (target_w, target_h), BG)
|
|
|
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
|
tier = _size_tier(target_w, target_h)
|
|
|
|
|
|
|
|
|
|
today = datetime.now(tz).date()
|
|
|
|
|
if days == 7:
|
|
|
|
@@ -675,40 +672,42 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
|
|
|
|
|
owners_seen: list[str] = []
|
|
|
|
|
|
|
|
|
|
if layout == "vertical":
|
|
|
|
|
title_font = ImageFont.load_default(size=max(14, 26 - days) if photo_inlay is None else max(11, 20 - days))
|
|
|
|
|
body_font = ImageFont.load_default(size=max(11, 18 - days) if photo_inlay is None else max(9, 15 - days))
|
|
|
|
|
weather_font = ImageFont.load_default(size=max(9, 16 - days) if photo_inlay is None else max(8, 13 - days))
|
|
|
|
|
section_h = ch // days
|
|
|
|
|
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
|
|
|
|
|
title_font = ImageFont.load_default(size=max(14, title_base - days))
|
|
|
|
|
body_font = ImageFont.load_default(size=max(11, body_base - days))
|
|
|
|
|
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
|
|
|
|
|
section_h = target_h // days
|
|
|
|
|
for i in range(day_count):
|
|
|
|
|
section_y0 = cy0 + i * section_h
|
|
|
|
|
section_y0 = i * section_h
|
|
|
|
|
if i > 0:
|
|
|
|
|
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
|
|
|
|
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
|
|
|
|
|
day = week_first_day + timedelta(days=i)
|
|
|
|
|
_draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h),
|
|
|
|
|
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
|
|
|
|
|
title_font, body_font, owners_seen, palette_rgb,
|
|
|
|
|
weather_cities, weather_font, weather_units)
|
|
|
|
|
if tasks is not None:
|
|
|
|
|
section_y0 = cy0 + day_count * section_h
|
|
|
|
|
section_y0 = day_count * section_h
|
|
|
|
|
if day_count > 0:
|
|
|
|
|
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
|
|
|
|
_draw_tasks(img, draw, (cx0, section_y0, cw, section_h), tasks, title_font, body_font)
|
|
|
|
|
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
|
|
|
|
|
_draw_tasks(img, draw, (0, section_y0, target_w, section_h), tasks, title_font, body_font)
|
|
|
|
|
return img
|
|
|
|
|
|
|
|
|
|
header_font = ImageFont.load_default(size=18 if photo_inlay is None else 14)
|
|
|
|
|
chip_font = ImageFont.load_default(size=14 if photo_inlay is None else 12)
|
|
|
|
|
weather_font = ImageFont.load_default(size=12 if photo_inlay is None else 10)
|
|
|
|
|
col_w = (cw - MARGIN * 2) // days
|
|
|
|
|
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
|
|
|
|
|
header_font = ImageFont.load_default(size=header_size)
|
|
|
|
|
chip_font = ImageFont.load_default(size=chip_size)
|
|
|
|
|
weather_font = ImageFont.load_default(size=weather_size)
|
|
|
|
|
col_w = (target_w - MARGIN * 2) // days
|
|
|
|
|
header_h = 44
|
|
|
|
|
|
|
|
|
|
for col in range(day_count):
|
|
|
|
|
day = week_first_day + timedelta(days=col)
|
|
|
|
|
x0 = cx0 + MARGIN + col * col_w
|
|
|
|
|
x0 = MARGIN + col * col_w
|
|
|
|
|
if col > 0:
|
|
|
|
|
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
|
|
|
|
|
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
|
|
|
|
|
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
|
|
|
|
|
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
|
|
|
|
|
draw_text(img, (x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
|
|
|
|
|
|
|
|
|
|
y = cy0 + MARGIN + header_h
|
|
|
|
|
y = MARGIN + header_h
|
|
|
|
|
# Columns are narrow, so only what actually fits gets drawn (see
|
|
|
|
|
# _draw_weather_row) -- typically one city, no label (the column
|
|
|
|
|
# itself makes which day it's for obvious; a city name wouldn't fit
|
|
|
|
@@ -718,7 +717,7 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
|
|
|
|
|
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
|
|
|
|
|
icon_r=8, font=weather_font, units=weather_units, show_labels=False)
|
|
|
|
|
row_h = chip_font.size + 10
|
|
|
|
|
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
|
|
|
|
|
max_rows = max(0, (target_h - MARGIN - y) // row_h)
|
|
|
|
|
day_events = _events_on_day(events, day, tz)
|
|
|
|
|
for i, event in enumerate(day_events):
|
|
|
|
|
if i >= max_rows:
|
|
|
|
@@ -738,47 +737,51 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
|
|
|
|
|
|
|
|
|
|
if tasks is not None:
|
|
|
|
|
col = day_count
|
|
|
|
|
x0 = cx0 + MARGIN + col * col_w
|
|
|
|
|
x0 = MARGIN + col * col_w
|
|
|
|
|
if col > 0:
|
|
|
|
|
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
|
|
|
|
|
_draw_tasks(img, draw, (x0, cy0, col_w, ch), tasks, header_font, chip_font, margin=6)
|
|
|
|
|
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
|
|
|
|
|
_draw_tasks(img, draw, (x0, 0, col_w, target_h), tasks, header_font, chip_font, margin=6)
|
|
|
|
|
|
|
|
|
|
return img
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
|
|
|
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None) -> Image.Image:
|
|
|
|
|
# Only "large"/"medium" in practice -- _build falls back to agenda view
|
|
|
|
|
# below the "small" tier (see _month_view_fits) -- but keyed defensively
|
|
|
|
|
# by tier rather than a bare bool so a future tier addition can't
|
|
|
|
|
# silently fall through to a KeyError here.
|
|
|
|
|
_MONTH_FONTS = {"large": (16, 18), "medium": (12, 13), "small": (12, 13)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
|
|
|
|
week_start: int, palette_rgb: list | None = None) -> Image.Image:
|
|
|
|
|
"""Density dots per day, not literal event text -- real text at
|
|
|
|
|
typical month-cell size (~100x70px) is close to unreadable on a
|
|
|
|
|
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
|
|
|
|
|
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)
|
|
|
|
|
img = Image.new("RGB", (target_w, target_h), BG)
|
|
|
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
|
|
|
|
|
|
header_font = ImageFont.load_default(size=16 if photo_inlay is None else 12)
|
|
|
|
|
day_font = ImageFont.load_default(size=18 if photo_inlay is None else 13)
|
|
|
|
|
header_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)]
|
|
|
|
|
header_font = ImageFont.load_default(size=header_size)
|
|
|
|
|
day_font = ImageFont.load_default(size=day_size)
|
|
|
|
|
|
|
|
|
|
today = datetime.now(tz).date()
|
|
|
|
|
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
|
|
|
|
|
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
|
|
|
|
|
|
|
|
|
|
col_w = (cw - MARGIN * 2) // 7
|
|
|
|
|
col_w = (target_w - MARGIN * 2) // 7
|
|
|
|
|
header_h = 28
|
|
|
|
|
grid_top = cy0 + MARGIN + header_h
|
|
|
|
|
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
|
|
|
|
|
grid_top = MARGIN + header_h
|
|
|
|
|
row_h = (target_h - MARGIN - grid_top) // len(weeks)
|
|
|
|
|
|
|
|
|
|
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
|
|
|
|
|
for col, name in enumerate(day_names):
|
|
|
|
|
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font, MUTED)
|
|
|
|
|
draw_text(img, (MARGIN + col * col_w + 6, MARGIN), name[:3], header_font, MUTED)
|
|
|
|
|
|
|
|
|
|
owners_seen: list[str] = []
|
|
|
|
|
dot_r = 6
|
|
|
|
|
for row, week in enumerate(weeks):
|
|
|
|
|
for col, day in enumerate(week):
|
|
|
|
|
x0 = cx0 + MARGIN + col * col_w
|
|
|
|
|
x0 = MARGIN + col * col_w
|
|
|
|
|
y0 = grid_top + row * row_h
|
|
|
|
|
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
|
|
|
|
|
in_month = day.month == target_month.month
|
|
|
|
@@ -808,23 +811,26 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "
|
|
|
|
|
"month": _build_month}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str,
|
|
|
|
|
photo_inlay: Image.Image | None, fetch_summary: str, week_start: int,
|
|
|
|
|
palette_rgb: list | None = None,
|
|
|
|
|
def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
|
|
|
|
|
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
|
|
|
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
|
|
|
week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None,
|
|
|
|
|
week_start_offset: int = 0) -> Image.Image:
|
|
|
|
|
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
|
|
|
|
if view == "agenda":
|
|
|
|
|
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
|
|
|
|
effective_view = view
|
|
|
|
|
if view == "month" and not _month_view_fits(target_w, target_h):
|
|
|
|
|
effective_view = "agenda"
|
|
|
|
|
|
|
|
|
|
if effective_view == "agenda":
|
|
|
|
|
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
|
|
|
|
weather_cities, weather_units)
|
|
|
|
|
elif view == "today_tomorrow":
|
|
|
|
|
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
|
|
|
|
elif effective_view == "today_tomorrow":
|
|
|
|
|
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
|
|
|
|
weather_cities, weather_units)
|
|
|
|
|
elif view == "week":
|
|
|
|
|
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb,
|
|
|
|
|
elif effective_view == "week":
|
|
|
|
|
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
|
|
|
|
|
weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
|
|
|
|
elif view == "month":
|
|
|
|
|
elif effective_view == "month":
|
|
|
|
|
# Never given weather or tasks -- no room for either at typical
|
|
|
|
|
# month-cell size, same reasoning that already keeps this view
|
|
|
|
|
# to density dots instead of literal event text (see
|
|
|
|
@@ -832,33 +838,34 @@ def _build(events: list[dict], view: str, browse_offset: int, orientation: str,
|
|
|
|
|
# through, though -- that's a different concern (legibility of
|
|
|
|
|
# individual events) than needing a whole extra strip/slot of
|
|
|
|
|
# content.
|
|
|
|
|
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb)
|
|
|
|
|
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
|
|
|
|
|
else:
|
|
|
|
|
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
|
|
|
|
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
|
|
|
|
weather_cities, weather_units)
|
|
|
|
|
|
|
|
|
|
if fetch_summary:
|
|
|
|
|
font = ImageFont.load_default(size=14)
|
|
|
|
|
logical_w, logical_h = img.size
|
|
|
|
|
draw_text(img, (MARGIN, logical_h - MARGIN - font.size), fetch_summary, font, MUTED)
|
|
|
|
|
font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11)
|
|
|
|
|
draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED)
|
|
|
|
|
|
|
|
|
|
return img
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
|
|
|
|
|
palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None,
|
|
|
|
|
palette_rgb: list | None, timezone: str,
|
|
|
|
|
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
|
|
|
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
|
|
|
|
week_days: int = 7, week_layout: str = "horizontal",
|
|
|
|
|
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
|
|
|
|
|
"""Renders one of CALENDAR_VIEWS to the panel's packed format. Always
|
|
|
|
|
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every
|
|
|
|
|
other renderer honors. weather_cities is routers/common.py's
|
|
|
|
|
get_or_refresh_weather() cache, or None/[] to omit the weather strip
|
|
|
|
|
entirely (also always omitted for view == "month"). tasks is
|
|
|
|
|
get_or_refresh_tasks()'s cache, or None to omit the task list
|
|
|
|
|
entirely -- only ever drawn for view == "week", see _build_week."""
|
|
|
|
|
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
|
|
|
|
|
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed
|
|
|
|
|
format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
|
|
|
|
|
invariant every other renderer honors. weather_cities is
|
|
|
|
|
routers/common.py's get_or_refresh_weather() cache, or None/[] to
|
|
|
|
|
omit the weather strip entirely (also always omitted for view ==
|
|
|
|
|
"month"). tasks is get_or_refresh_tasks()'s cache, or None to omit
|
|
|
|
|
the task list entirely -- only ever drawn for view == "week", see
|
|
|
|
|
_build_week."""
|
|
|
|
|
target_w, target_h = logical_render_size(orientation)
|
|
|
|
|
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
|
|
|
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
|
|
|
|
img = _apply_manage_overlay(img, manage)
|
|
|
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
|
|
@@ -866,7 +873,7 @@ 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,
|
|
|
|
|
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",
|
|
|
|
@@ -874,7 +881,8 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
|
|
|
|
|
"""Same pipeline as render_calendar, but a normal browser-viewable
|
|
|
|
|
PNG in logical (upright) orientation -- mirrors
|
|
|
|
|
image_pipeline.render_preview_png's relationship to render_frame."""
|
|
|
|
|
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
|
|
|
|
|
target_w, target_h = logical_render_size(orientation)
|
|
|
|
|
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
|
|
|
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
|
|
|
|
|
img = _apply_manage_overlay(img, manage)
|
|
|
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
|
|
|