Files
espresso_frame/docs/widgets.md
T
Thomas Faour 14c47aa2a0
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 2m8s
Build and push server image / deploy (push) Successful in 59s
Let a tasks widget merge multiple task lists, checkbox+color like calendar
Tasks widgets could only ever point at one CalDAV task list (a radio-
button picker, owner-only). Now they merge any number of included task
lists across every linked user, same checkbox-inclusion + optional
pinned-color shape a calendar widget already has for its calendars --
FrameTaskList mirrors FrameCalendar exactly, down to the same owner-
adds/anyone-mutes permission split (api_widget_task_list_select/
api_widget_task_list_color). Reused calendar_render._event_colors/
_draw_color_bar as-is for the per-task color bar -- a task dict's
owner_display_name/color_index is exactly that function's single-
source fallback shape.

Also added an opt-in "show tasks completed in the last 24 hours"
toggle (TaskWidgetConfig.show_completed): caldav_client.fetch_tasks
now accepts a completed_since cutoff and returns completed VTODOs
(with their completion time) instead of silently dropping them, and
_draw_tasks gives a completed task a filled checkbox + muted text
instead of the normal empty-box/due-date row.

Migration 18 splits the single-source TaskWidgetConfig columns
(added by 17, splitting tasks out of the calendar widget in the first
place) into frame_task_lists, carrying forward each widget's existing
single source as its first included list -- same shape migration 9
used carrying forward frame_calendars' old single opt-in.

Verified live in the browser (desktop + mobile): the new "Included
task lists" + "Recently completed" dialog sections, the show_completed
toggle actually persisting through a real HTTP round-trip, and no
regression in the calendar widget's own "Included calendars" dialog.
Full suite (192 tests, including new merge_tasks/config_save/migration
coverage) passes.
2026-07-25 03:39:26 +00:00

8.6 KiB

Widget system

A frame's panel isn't one fixed "mode" anymore -- it holds N independently placed/sized widgets (photos/calendar/whiteboard/tasks), like arranging icons on an Android home screen. A frame can hold several widgets of the same type (e.g. two photo widgets pointed at different Immich albums side by side). This replaced an earlier design where Frame.mode picked exactly one full-panel renderer; that column (and the other now-dead per-mode Frame columns it left behind -- album_id, calendar_*, whiteboard_*, etc.) is still physically present but unused, pending a final cleanup migration (see "Known gaps" below).

The device-facing contract is unchanged by any of this: GET /frame/image, POST /frame/advance, POST /frame/back are the same frozen paths firmware has always called (see docs/architecture.md) -- what changed is entirely server-side, in how those endpoints decide what to render and what a button press does.

Data model

  • Widget (server/app/models.py): id, frame_id, widget_type ("photos" | "calendar" | "whiteboard" | "tasks"), x/y/w/h (grid cells), sort_order. Widgets never overlap (enforced server-side in routers/api_widgets.py, re-validated regardless of what the client already checked) -- that's what keeps compositing simple: no z-order, no blending, just N independent regions pasted onto one shared canvas.
  • Per-type 1:1 extension tables -- PhotoWidgetConfig, CalendarWidgetConfig, WhiteboardWidgetConfig, TaskWidgetConfig, each keyed by widget_id with ondelete="CASCADE" -- rather than one wide table with every type's mostly-irrelevant columns. PhotoWidgetConfig mirrors app/photo_queue.py's attribute names exactly, so that module's advance/back/queue logic ports across widget instances unchanged. TaskWidgetConfig used to be a handful of tasks_* columns bolted onto CalendarWidgetConfig (a week-view-only, single-list task list); split into its own widget type (migration 17) so a task list can be placed and sized independent of any calendar's view/footprint, then (migration 18) given the same multi-source shape a calendar widget already has.
  • FrameCalendar/FrameTaskList are keyed by widget_id (not frame_id) since a frame can now have more than one independent calendar/tasks widget, each with its own included set. Identical shape and permission model (owner-added, anyone-linked-can-mute, see "Per-widget config UI" below) -- FrameTaskList just has no "ics" calendar_key variant, since a plain ICS subscription has no VTODO (task) collection.
  • FrameButtonAction (id, frame_id, button ["next"|"back"], widget_id, action, sort_order) -- see "Button actions" below.

Placement: a grid, not freeform pixels

app/grid.py is pure grid math, no I/O. The grid is GRID_LONG=8 x GRID_SHORT=5 cells, defined relative to the panel's long/short axis (not "landscape" specifically) so it stays valid across image_pipeline.logical_render_size(orientation)'s genuine width/height swap for portrait -- landscape orientations are 8 cols x 5 rows, portrait are 5 cols x 8 rows, same cell size either way. Changing a frame's orientation invalidates its existing layout (an 8x5 arrangement isn't valid on a 5x8 grid) -- the server resets to one full-panel widget on an orientation change rather than trying to remap coordinates.

Each widget type has a minimum grid footprint (grid.MIN_FOOTPRINT): photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of size-tier scaling), whiteboard 2x2, tasks 2x2. Enforced both client-side (UX, in the Layout tab's drag/resize canvas -- static/frame_layout.js) and server-side (routers/api_widgets.py) -- the client is never trusted alone.

Rendering: one shared compositor

app/widgets/ is the render/action registry -- one module per widget_type (photos.py, calendar.py, whiteboard.py, tasks.py), each exposing:

  • render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image: an unquantized RGB image exactly target_w x target_h, the widget's content composed into its own region. Never raises for a foreseeable failure (an Immich hiccup, an unconfigured widget) -- falls back to a small placeholder within its own region instead, so one widget having a bad moment doesn't blank the whole panel.
  • ACTIONS: dict[str, Callable] -- named button actions this type supports ("advance"/"back" for photos and calendar, "check_now" for whiteboard). Empty for tasks -- a passive checklist on the same throttled-refresh cadence as weather, nothing to advance/back/force.
  • ACTION_LABELS: dict[str, str] -- human labels for the button- assignment UI.

routers/device.py's _render_widgets loads every Widget row for the frame, maps each one's grid rect to pixels (grid.cell_to_pixels), calls its module's render(), and hands the whole list of (rect, image) regions to image_pipeline.render_panel -- which pastes every region onto one shared canvas, then runs enhance/manage-overlay/quantize/dither/pack once over the composited result. Quantizing the whole canvas together (not each region separately before pasting) is what keeps the 6-color e-ink dithering pattern consistent across a widget boundary instead of a visible seam at the edge.

Calendar widgets pick from discrete size tiers (calendar_render.py's _SIZE_TIERS) for font size/margins/row heights based on their actual grid footprint, rather than continuously scaling constants tuned for a full ~800x480 canvas -- falls back to agenda view if a widget is too small for month view to stay legible.

Button actions

Each physical button (NEXT/BACK) maps to an ordered list of (widget, action) bindings, not a fixed meaning -- e.g. NEXT can be "photo widget A: advance" and "calendar widget B: advance" together, or even a mismatched combination on purpose. On a press, routers/device.py's _run_button_actions runs every assigned action for that button in order (each in its own widget_locked span -- never nested, since the underlying per-frame lock isn't reentrant), catching and logging any single action's failure without blocking the rest, then re-renders and returns the whole composed panel once at the end regardless of which actions succeeded.

The web UI for this is the "Button assignments" card on a frame's Configuration tab (static/frame_config.js, GET/PUT /api/frames/{id}/buttons) -- add/remove/reorder, autosaved. Two widgets of the same type would otherwise both just say "Photos" in the assignment dropdowns; the UI disambiguates using each widget's grid position (e.g. "Photos 1 (left)" / "Photos 2 (right)"), the same way you'd tell them apart by eye on the Layout canvas.

A newly-created widget (including the one auto-migrated from a frame's old mode on upgrade) gets a sensible default binding reproducing its old button behavior -- see migration.py's _default_button_actions.

Per-widget config UI

Each widget has a gear-icon button on the Layout canvas that opens a <dialog> with that widget's own settings (album, calendar/task-list inclusion, whiteboard source, etc.) -- not a per-frame tab, since a frame can now have several widgets of the same type with independent settings. The dialog HTML is injected server-rendered (routers/frame_pages.py's widget_dialog, dispatching on widget.widget_type); its JS is a top-level, always-loaded file (static/widget_dialog_*.js) exposing init<Type>Dialog()/close<Type>Dialog(), since dynamically-injected HTML can't carry executable <script> tags. While a dialog is open, window.FRAME_API is repointed at that widget's own API base (/api/frames/{id}/widgets/{widget_id}) and restored on close; window.FRAME_BASE_API stays pointed at the frame-level base throughout for the always-present header/status-bar JS.

Known gaps (Phase 6, not yet done)

The original 8-phase rollout plan's last phase is still open:

  • Legacy per-mode Frame columns (mode, album_id, current_asset_id, all calendar_*, all whiteboard_*, etc.) are still physically present in the schema but no longer read or written anywhere -- they need a dedicated final migration to drop them. Left in place deliberately through the widget-system rollout (a much larger blast radius cutover than this project's usual same-migration-drop convention) but there's no reason to keep carrying them now that every phase has shipped.
  • server/README.md still describes photos/calendar/whiteboard as per-frame "modes" in several places rather than widgets -- needs a pass once the column drop above is safely deployed.
  • Whiteboard rendering is tagged (alpha) in the UI -- not fully reliable yet, treat it as experimental if extending it.