Week view flexibility: configurable day count, layout, and a CalDAV task list
Build and push server image / build-and-push (push) Successful in 52s
Build and push server image / build-and-push (push) Successful in 52s
- Day count (2-10, was fixed at 7) -- 5 days trims the weekend clutter without losing the grid format. - Layout choice: days side by side (original behavior) or stacked vertically as full agenda-style sections (reuses _draw_agenda_day, same approach _build_today_tomorrow already used for a fixed 2 days). - Optional task list (CalDAV VTODO collections only -- a plain ICS subscription doesn't meaningfully have one) that takes the space of one day slot instead of adding an extra one. Same owner-controls- their-own-data permission split as calendar sources: only the calendar's owner can point a frame's task list at it, but anyone linked to the frame can clear it. Browse-offset paging now moves by N days (was hardcoded to weeks), identical to the old behavior when days=7. Changing the day count resets the browse offset, same reasoning as changing views already did.
This commit is contained in:
+130
-22
@@ -140,6 +140,20 @@ def _fmt_time(dt: datetime) -> str:
|
||||
return text if text else "12:00 AM"
|
||||
|
||||
|
||||
def _fmt_task_due(due: str | None) -> str:
|
||||
""""2026-07-25" or "2026-07-25T14:00:00+00:00" -> "Jul 25" -- tasks
|
||||
only need a compact reminder of when they're due, not the precision
|
||||
an event's own start/end time gets."""
|
||||
if not due:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(due)
|
||||
except ValueError:
|
||||
return ""
|
||||
d = dt.date() if isinstance(dt, datetime) else dt
|
||||
return d.strftime("%b %-d")
|
||||
|
||||
|
||||
# ImageFont.load_default() (used for everything else in this module --
|
||||
# see the module docstring) has no emoji glyphs, and PIL/FreeType don't
|
||||
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a
|
||||
@@ -513,6 +527,56 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
||||
y += row_h
|
||||
|
||||
|
||||
def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int],
|
||||
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont,
|
||||
margin: int = MARGIN) -> None:
|
||||
"""A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box
|
||||
glyph + due date (if any) + summary per outstanding task, same
|
||||
header/rule/row-cap/truncation shape as _draw_agenda_day's event
|
||||
list so the "week view, one slot replaced by tasks instead of a day"
|
||||
layout (see _build_week) reads as one consistent design rather than
|
||||
two different widgets bolted together. Reuses _draw_mixed_line so a
|
||||
task summary with emoji in it renders the same way an event
|
||||
title's does.
|
||||
|
||||
`margin` defaults to the module-wide MARGIN (vertical layout's
|
||||
stacked bands are as wide as the whole content region, same as
|
||||
_draw_agenda_day's own sections) but a narrow horizontal-layout
|
||||
column passes a much smaller one -- MARGIN on both sides of an
|
||||
already-cramped ~150px week column left almost nothing for the
|
||||
title text itself."""
|
||||
x0, y0, w, h = region
|
||||
text_x0, text_y0 = x0 + margin, y0 + margin
|
||||
text_w = w - margin * 2
|
||||
draw_text(img, (text_x0, text_y0), "Tasks", title_font)
|
||||
y = text_y0 + title_font.size + 12
|
||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
||||
y += 12
|
||||
|
||||
row_h = body_font.size + 14
|
||||
max_rows = max(0, (y0 + h - margin - y) // row_h)
|
||||
|
||||
if not tasks:
|
||||
draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED)
|
||||
return
|
||||
for i, task in enumerate(tasks):
|
||||
if i >= max_rows:
|
||||
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED)
|
||||
break
|
||||
box = body_font.size - 6
|
||||
box_y = y + (row_h - box) // 2 - 5
|
||||
draw.rectangle([text_x0, box_y, text_x0 + box, box_y + box], outline=FG, width=2)
|
||||
text_x = text_x0 + box + 10
|
||||
due_str = _fmt_task_due(task.get("due"))
|
||||
prefix = f"{due_str} " if due_str else ""
|
||||
if prefix:
|
||||
draw_text(img, (text_x, y), prefix, body_font, MUTED)
|
||||
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
|
||||
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
|
||||
body_font, text_w - box - 10 - prefix_w)
|
||||
y += row_h
|
||||
|
||||
|
||||
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,
|
||||
@@ -577,8 +641,16 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: s
|
||||
|
||||
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,
|
||||
weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit") -> Image.Image:
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None) -> Image.Image:
|
||||
"""`days` (2-10, see routers/api_frames.py's clamp) side-by-side
|
||||
columns (layout="horizontal", the original fixed-at-7 behavior
|
||||
generalized) or stacked bands (layout="vertical", reusing
|
||||
_draw_agenda_day the same way _build_today_tomorrow does, just for
|
||||
an arbitrary day count instead of a hardcoded 2). `tasks` (see
|
||||
routers/common.py's get_or_refresh_tasks), if not None, takes the
|
||||
LAST slot instead of adding an extra one -- "N days" always means N
|
||||
slots total, whether they're all days or N-1 days plus a task list."""
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
img = Image.new("RGB", (logical_w, logical_h), BG)
|
||||
if photo_inlay is not None:
|
||||
@@ -586,18 +658,39 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
|
||||
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
today = datetime.now(tz).date()
|
||||
days_since_start = (today.weekday() - week_start) % 7
|
||||
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
|
||||
day_count = days - 1 if tasks is not None else days
|
||||
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
|
||||
for i in range(day_count):
|
||||
section_y0 = cy0 + i * section_h
|
||||
if i > 0:
|
||||
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
||||
day = week_first_day + timedelta(days=i)
|
||||
_draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h),
|
||||
title_font, body_font, owners_seen, palette_rgb,
|
||||
weather_cities, weather_font, weather_units)
|
||||
if tasks is not None:
|
||||
section_y0 = cy0 + 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)
|
||||
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)
|
||||
|
||||
today = datetime.now(tz).date()
|
||||
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
|
||||
col_w = (cw - MARGIN * 2) // days
|
||||
header_h = 44
|
||||
owners_seen: list[str] = []
|
||||
|
||||
for col in range(7):
|
||||
for col in range(day_count):
|
||||
day = week_first_day + timedelta(days=col)
|
||||
x0 = cx0 + MARGIN + col * col_w
|
||||
if col > 0:
|
||||
@@ -633,6 +726,13 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
|
||||
chip_font, col_w - 20 - prefix_w)
|
||||
y += row_h
|
||||
|
||||
if tasks is not None:
|
||||
col = day_count
|
||||
x0 = cx0 + 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)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
@@ -701,7 +801,8 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "
|
||||
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,
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> Image.Image:
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||
week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None) -> 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,
|
||||
@@ -711,14 +812,15 @@ def _build(events: list[dict], view: str, browse_offset: int, orientation: str,
|
||||
weather_cities, weather_units)
|
||||
elif view == "week":
|
||||
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb,
|
||||
weather_cities, weather_units)
|
||||
weather_cities, weather_units, week_days, week_layout, tasks)
|
||||
elif view == "month":
|
||||
# Never given weather -- no room for it at typical month-cell size,
|
||||
# same reasoning that already keeps this view to density dots
|
||||
# instead of literal event text (see _build_month's own docstring).
|
||||
# Colors are still passed through, though -- that's a different
|
||||
# concern (legibility of individual events) than weather's
|
||||
# (space for a whole extra strip of content).
|
||||
# Never given weather or tasks -- no room for either at typical
|
||||
# month-cell size, same reasoning that already keeps this view
|
||||
# to density dots instead of literal event text (see
|
||||
# _build_month's own docstring). Colors are still passed
|
||||
# through, though -- that's a different concern (legibility of
|
||||
# individual events) than needing a whole extra strip/slot of
|
||||
# content.
|
||||
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb)
|
||||
else:
|
||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
||||
@@ -735,14 +837,18 @@ def _build(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,
|
||||
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> bytes:
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||
week_days: int = 7, week_layout: str = "horizontal",
|
||||
tasks: list[dict] | None = None) -> 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")."""
|
||||
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,
|
||||
palette_rgb, weather_cities, weather_units)
|
||||
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks)
|
||||
img = _apply_manage_overlay(img, manage)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
@@ -751,12 +857,14 @@ 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, week_start: int = 0,
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> bytes:
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||
week_days: int = 7, week_layout: str = "horizontal",
|
||||
tasks: list[dict] | None = None) -> 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, week_start,
|
||||
palette_rgb, weather_cities, weather_units)
|
||||
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks)
|
||||
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