The first modern-style rollout translated each widget's existing classic layout into HTML/CSS -- same gradient headers, same rounded-shadowed card, prettier chrome around an unchanged composition. This actually redesigns weather (current/daily), calendar (all four views), tasks, and battery: no card/shadow anywhere, a slim accent-colored rule instead of a full gradient banner (and only that rule dithers at the richer accent amplitude now, not the header text sitting on it), and a dominant hero value (temperature/percent) instead of a centered icon+number of equal weight. Padding and type sizes scale as a clamped proportion of widget size instead of fixed pixel values. Text and static/whiteboard are left alone -- text already had zero chrome and its styling is user content, not this system's to redesign; framed_image's card was already minimal. Direction was picked from three divergent mockups reviewed with the maintainer, then verified against the real render pipeline (actual Chromium render, actual ordered dithering, actual theme system) rather than just eyeballed -- that caught a day-section/month-grid divider color (#e2e6ec) that's nowhere near this panel's 6-color palette and was dithering to invisible white; fixed with a real black hairline in the one place (month view) that still needed one.
569 lines
33 KiB
Markdown
569 lines
33 KiB
Markdown
# Widget system
|
|
|
|
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
|
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
|
|
weather/battery), 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"` | `"static"` |
|
|
`"text"` | `"weather"` | `"battery"`), `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.
|
|
Also carries an optional per-widget border (`border_style` -- `"none"`
|
|
| `"solid"` | `"dashed"` | `"dotted"` | `"fancy"`, `border_thickness`,
|
|
`border_color_index`, an index into the frame's palette so a border
|
|
always renders as one of the panel's exact 6 ink colors) directly on
|
|
`Widget` itself rather than a per-type config table, since every
|
|
widget type can have one regardless of `widget_type`. Drawn by
|
|
`image_pipeline.draw_widget_border` onto each widget's own region in
|
|
`routers/device.py`'s `_render_widgets`, before that region is pasted
|
|
onto the shared canvas -- one central integration point instead of
|
|
every `app/widgets/*.py` module needing to know about it. Set via the
|
|
gear-icon dialog's shared "Border" card (`_widget_border_fields.html`,
|
|
included by every `_widget_dialog_*.html` template) and
|
|
`POST .../widgets/{id}/border`, its own endpoint (not folded into
|
|
`api_widget_config_save`) since that endpoint's per-type dispatch is
|
|
keyed on a config row via `widget_locked`, and border fields live on
|
|
`Widget` itself, not any per-type config table.
|
|
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
|
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
|
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
|
|
`BatteryWidgetConfig`, each keyed by `widget_id` with
|
|
`ondelete="CASCADE"` -- rather than one wide table with every type's
|
|
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
|
text (paragraphs of styled runs), never raw HTML -- see
|
|
`server/app/text_content.py`'s module docstring for why that parse
|
|
step is the widget's actual stored-XSS sanitization boundary.
|
|
`PhotoWidgetConfig`
|
|
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
|
advance/back/queue logic ports across widget instances unchanged.
|
|
`PhotoWidgetConfig.locked` (migration 27) freezes `current_asset_id`
|
|
against both the timer-elapsed auto-advance
|
|
(`photo_queue.get_current`) and the advance/back button actions
|
|
(`app/widgets/photos.py`'s `ACTIONS`) until unlocked -- toggled via a
|
|
"Lock this photo" button in the widget's own dialog
|
|
(`POST .../widgets/{id}/lock`), shown as a lock badge on the widget's
|
|
box on the Layout tab canvas.
|
|
`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.
|
|
`WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded
|
|
weather strip (still present and unchanged, `weather_*` columns) out
|
|
into its own placeable widget type (migration 24) -- see "Weather
|
|
widget" below. `BatteryWidgetConfig` (migration 25) is the odd one out
|
|
-- its actual content (`Frame.battery_percent`/`battery_as_of`) isn't
|
|
in this table at all, already existing frame-level state set by
|
|
`routers/device.py`'s `frame_battery` regardless of whether a battery
|
|
widget is even placed; the config row only holds a display-mode
|
|
setting (`"compact"` | `"detailed"`).
|
|
- `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, static image 1x1, text 2x1,
|
|
weather 2x2 (its hourly/daily strips need the room; current/multi_city
|
|
modes would tolerate smaller, but every mode shares one footprint value),
|
|
battery 1x1 (just an icon + a percent, legible even at a single cell,
|
|
like photos/static -- though see `MIN_FOOTPRINT`'s own comment in
|
|
`grid.py` on a mobile-width gear-icon click-target gap at that size,
|
|
already pre-existing for photos/static too). 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`,
|
|
`static_image.py`, `text.py`, `weather.py`, `battery.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 and weather -- both throttled external fetches with a
|
|
forced-refetch action). Empty for tasks, static image, text, and
|
|
battery -- nothing to advance/back/force for a passive checklist, a
|
|
fixed uploaded image, a fixed block of authored text, or a number the
|
|
device itself pushes on every wake.
|
|
- `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.
|
|
|
|
### "Modern" render style (experimental)
|
|
|
|
Every widget type except photos has a `render_style` column (`"classic"`
|
|
default | `"modern"`) that swaps its hand-drawn PIL primitives for an
|
|
HTML/CSS render: a Jinja2 template (`app/templates/widget_html/`) drawn
|
|
through a persistent headless-Chromium browser (`app/html_render.py`,
|
|
Playwright) instead of `ImageDraw` -- gradients, shadows, and soft icon
|
|
shading PIL can't easily do. Calendar's own modern-style builders (all
|
|
four view modes) live in `app/calendar_html_render.py` rather than
|
|
`html_render.py` itself, mirroring `calendar_render.py`'s own separation
|
|
from the simpler widget types.
|
|
|
|
Every modern-style builder runs its own `ordered_dither` (Bayer/ordered,
|
|
not Floyd-Steinberg) before returning, committing the widget to exact
|
|
palette colors *before* compositing -- safe to mix with photo/other
|
|
classic-rendered widgets on the same frame without a Floyd-Steinberg
|
|
seam at the boundary, because ordered dithering has no cross-pixel error
|
|
term the way Floyd-Steinberg's diffusion does (see `html_render.py`'s
|
|
module docstring). No `Frame`-level dithering setting was needed to make
|
|
this work.
|
|
|
|
Not offered for the **photos** widget -- a real photograph isn't a
|
|
synthesized dashboard card, and photos has a different concern instead:
|
|
its own independent palette/dithering strength (`Frame.photo_palette_rgb`
|
|
/ `photo_dither_strength`, a second "Photos configuration" card in
|
|
Advanced Configuration, separate from the main `palette_rgb`/
|
|
`dither_strength` every other widget uses). `widgets/photos.py`'s
|
|
`render()` quantizes itself against these before returning, so a frame
|
|
can tune the rest of its widgets' look (e.g. a calibrated palette for
|
|
modern-style dashboard widgets) independently of what actually looks
|
|
best for real photographs, with no `render_panel` changes needed --
|
|
see that module's own docstring for the one small, accepted edge case
|
|
(a border on a photos widget whose palette genuinely diverges from the
|
|
frame's main one).
|
|
|
|
Playwright/Chromium is a real, heavyweight runtime dependency imported
|
|
lazily only when a widget actually uses modern style. Its browser binary
|
|
is fetched by `start.sh` at container startup rather than baked into the
|
|
image (see `server/Dockerfile`'s own comment) -- a single ~181MB
|
|
`chrome-headless-shell` binary can't be split across Docker layers the
|
|
way this project's pip/npm installs were, and confirmed-failed to push
|
|
to the registry as a build-time layer; cached on the `/data` volume
|
|
(`PLAYWRIGHT_BROWSERS_PATH`) so only the very first boot on a fresh
|
|
volume actually downloads it. Still real-panel-unverified -- treat every
|
|
"modern" style as experimental regardless of deploy status.
|
|
|
|
Per-widget-type notes:
|
|
|
|
- **weather**: `current`/`daily` modes only -- `hourly`/`multi_city`
|
|
always render classic regardless of this setting (see the Weather
|
|
widget section below).
|
|
- **calendar**: all four view modes (agenda/today_tomorrow/week/month)
|
|
have a modern builder -- the only widget type with full modern-style
|
|
coverage from the start, rather than a partial rollout like weather's.
|
|
Month view's "falls back to agenda below a size threshold" behavior
|
|
(`_month_view_fits`) is honored identically in both styles.
|
|
- **battery/text/tasks**: full coverage (both battery modes; text reuses
|
|
its own `_fit()` shrink-to-fit sizing logic, only the drawing differs).
|
|
- **static image/whiteboard**: modern style is the *first* visual chrome
|
|
either widget type has ever had (classic draws the image with zero
|
|
frame/card at all) -- a rounded-corner, shadowed card
|
|
(`framed_image.html.jinja`, shared between the two) wrapping the
|
|
already-composed image. Left alone by the "bold minimal" pass below --
|
|
it never had the reskinned-classic problem the other widgets did.
|
|
|
|
### "Bold minimal": a real redesign, not just a reskin
|
|
|
|
The initial modern-style rollout (above) mostly translated each widget's
|
|
*existing* classic layout into HTML/CSS -- same gradient header banner,
|
|
same rounded-shadowed white card, prettier chrome around an unchanged
|
|
composition. A second pass reworked weather (`current`/`daily`),
|
|
calendar (all four views), tasks, and battery into an actual different
|
|
visual language, picked from several divergent directions rendered
|
|
through the real pipeline and reviewed with the maintainer (not chosen
|
|
unilaterally -- see the "Reverted e-ink quantization attempt"-style
|
|
caution about visual changes needing more than one look). Text and
|
|
static/whiteboard were deliberately left as they were (see their notes
|
|
just above) -- text already had zero chrome and its styling is
|
|
user-authored content, not this system's to redesign; the framed-image
|
|
card was already minimal.
|
|
|
|
What changed, as a consistent language across every redesigned widget:
|
|
|
|
- **No card.** No rounded-corner white box, no drop shadow, no outer
|
|
border -- content sits directly on the shared white canvas. `theme
|
|
["radius"]`/`theme["shadow"]` are now unused by every redesigned
|
|
widget's builder (still resolved, for signature uniformity with
|
|
`resolve_theme`, but nothing reads them) -- a theme's radius/shadow
|
|
fields now only affect the *un*-redesigned modern widgets (static
|
|
image/whiteboard's `framed_image.html.jinja`).
|
|
- **A slim accent rule instead of a gradient banner.** Every widget that
|
|
used to have a colored header bar with white text on it (weather's
|
|
`build_daily`, tasks, calendar's four views) now has a thin (~4-8px)
|
|
accent-colored rounded rule, with the header text as plain ink below
|
|
it instead of white text on top of it -- only that thin rule dithers
|
|
at the theme's richer `accent_amplitude` via `ordered_dither_regions`
|
|
now, not the header text sitting on it, which reads as a legibility
|
|
improvement, not just a visual one (see "Rich accent hues" below).
|
|
- **A dominant hero value, not a centered icon+number of equal weight.**
|
|
Weather's `build_current` and battery's icon+percent used to be drawn
|
|
at roughly the same size, centered as a unit; both now put the numeric
|
|
value (temperature / battery percent) at a clearly dominant size, with
|
|
the icon small and secondary above it -- closer to a phone home-screen
|
|
widget than a dashboard tile.
|
|
- **Padding/type sizes as a proportion of widget size, clamped to a
|
|
floor/ceiling, not a fixed pixel value.** So a 1-2 grid-cell widget
|
|
doesn't get comically large padding relative to its content, and a
|
|
near-full-panel widget doesn't get comically small padding either --
|
|
see `html_render._clamp` and every redesigned `build_*`'s own
|
|
`pad`/size calculations (`base = min(target_w, target_h)`, then a
|
|
fraction of `base` clamped to tuned floor/ceiling values).
|
|
|
|
**A hairline color this palette can't actually render.** Auditing the
|
|
month view's grid during this pass turned up a real, pre-existing bug
|
|
carried forward unnoticed since the very first modern-style rollout:
|
|
`.day-cell`/`.day-section`/`.col` divider borders used a pale gray
|
|
(`#e2e6ec`) -- but `DEFAULT_PALETTE_RGB` has no gray in it at all (black/
|
|
white/yellow/red/blue/green only), so a color that close to white always
|
|
nearest-matches to pure white regardless of Bayer bias, at any amplitude
|
|
-- confirmed by sampling actual rendered pixels, not just eyeballing a
|
|
screenshot. The month grid's week-row dividers now use real solid black
|
|
(`RULE`-equivalent, matching how the *classic* PIL renderer always drew
|
|
them -- see `calendar_render.RULE`); the day-section/week-column dividers
|
|
were simply dropped instead, since the accent rule + spacing at the
|
|
start of the next section/column already read as a clear boundary
|
|
without a line at all once you could actually render one.
|
|
|
|
### Themes for modern-style widgets
|
|
|
|
`Frame.theme` (String, default `"classic"`, one Advanced Configuration
|
|
`<select>`) picks a curated visual preset for every modern-style widget
|
|
on that frame -- font family, corner radius, drop shadow, and an accent
|
|
hue for widgets with a header/accent region. Presets live in
|
|
`app/theme_tokens.py`'s `THEMES` dict; `resolve_theme(theme_name,
|
|
widget_kind, palette_rgb)` turns one into concrete, ready-to-render
|
|
values (`accent_hex`/`accent_hex_dark`, resolved `font_regular`/
|
|
`font_bold` file paths, `radius`, `shadow`, `accent_amplitude`). Inspired
|
|
by [Tesserae](https://github.com/dmellok/tesserae)'s (AGPL-3.0) own
|
|
three-layer CSS custom-property theme system -- this is an original
|
|
reimplementation of that *architecture*, not a copy of its token file
|
|
(see this repo's `CLAUDE.md` on copyleft dependencies).
|
|
|
|
**What a theme actually changes, in practice**: the accent color (now a
|
|
slim rule rather than a full header band -- see "Bold minimal" above) is
|
|
still the most visible change on widgets that have one, but `font_family`
|
|
applies to *every* text element in the widget, not just the header title
|
|
-- day labels, temperatures, task rows, event times, day numbers all
|
|
switch fonts too (e.g. "Moss" is serif, "Ochre" a slab serif), often
|
|
more noticeable than the accent color on text-heavy widgets. `radius`/
|
|
`shadow` only affect static image/whiteboard's card now (every other
|
|
modern-style widget dropped its card in the "bold minimal" pass); text
|
|
never used them (no card from the start) and weather/battery/tasks/
|
|
calendar no longer have a card for them to apply to either.
|
|
|
|
**A theme is purely stylistic, never functional color-coding.** Battery's
|
|
charge-level red/yellow/green, calendar/tasks' per-owner event color
|
|
chips, and text's user-authored inline run colors are status/identity
|
|
signals, not style choices -- no theme may recolor them, and every
|
|
`build_*`/`resolve_theme` call site that touches those stays on its own
|
|
existing logic untouched. Text's own per-widget `font_family` setting
|
|
(a user's explicit content-level choice, same carve-out reasoning) is
|
|
similarly never overridden by a theme -- `build_text` accepts a
|
|
`theme_name` param for signature uniformity with every other modern-
|
|
style builder but deliberately ignores it.
|
|
|
|
**Rich accent hues, not just the 6 exact panel inks.** A theme's
|
|
`accent_hex` can be any arbitrary color (e.g. terracotta, moss, slate) --
|
|
`html_render.ordered_dither_regions(rendered, palette_rgb,
|
|
base_amplitude, accent_regions=[(rect, amplitude), ...])` dithers the
|
|
whole widget at the existing safe default (`ordered_dither`'s tuned 48,
|
|
unchanged, still icon/text-legible) and then *separately* re-dithers
|
|
just the accent rectangle (a header bar's already-computed pixel rect)
|
|
at a theme's higher `accent_amplitude` (~130) and pastes it back. Safe
|
|
to do per-region for the same reason `ordered_dither` itself is safe
|
|
per-widget: ordered (Bayer) dithering has no cross-pixel error term, so
|
|
a region's result depends only on its own pixels. A single higher
|
|
amplitude applied to the *whole* widget instead was tried and rejected --
|
|
it washes out pale content (a weather icon's white cloud body nearly
|
|
vanished in testing); confining the higher amplitude to just the accent
|
|
rect avoids that while still letting the rect approximate a rich hue via
|
|
denser stippling instead of flatly snapping to one nearest ink (what
|
|
happens to a rich hue at the base amplitude).
|
|
|
|
**"classic" is a deliberately no-visual-change default.** Its
|
|
`accent_hex` is `None`, meaning "keep this widget kind's own pre-theme
|
|
look exactly": weather's header was always a fixed blue gradient (now
|
|
`theme_tokens._CLASSIC_WEATHER_GRADIENT`, byte-identical to the old
|
|
module-level `ACCENT_START`/`ACCENT_END` constants this system
|
|
replaced); tasks/calendar's header was always a flat single ink resolved
|
|
through `panel_style.THEME` (still is, just via `resolve_theme` now).
|
|
Widget kinds with no ink of their own (battery/text/static/whiteboard)
|
|
fall back to black, though none of their templates currently have an
|
|
accent-colored surface for it to visibly affect.
|
|
|
|
Which widgets get the richer accent-region treatment: weather's
|
|
`build_daily` (the slim rule, when `city_label` is set), tasks, and
|
|
calendar's four view builders -- each computes its own small accent-rule
|
|
pixel rect (a fixed-height band, not the old full header_h) and passes
|
|
just that to `ordered_dither_regions`. Weather's `build_current` and
|
|
battery have no accent surface at all (no header of any kind -- see
|
|
"Bold minimal" above) and static/whiteboard's shared `build_framed_image`
|
|
is unchanged from the original rollout; all three call plain
|
|
`ordered_dither` with no accent region.
|
|
|
|
## Button actions
|
|
|
|
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
|
|
every widget on the frame that has one -- **at most one binding per
|
|
widget per button** (a widget can't be bound to two different actions on
|
|
the same button). On a press, `routers/device.py`'s `_run_button_actions`
|
|
runs every widget's assigned action for that button (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. Which
|
|
widget's action runs first never matters -- each only touches its own
|
|
state, and the shared re-render happens once, after all of them finish.
|
|
|
|
The UI for this lives in each widget's own gear-icon config dialog (the
|
|
"Button actions" card, `templates/_widget_button_fields.html` +
|
|
`static/widget_dialog_button_actions.js`, `POST
|
|
/api/frames/{id}/widgets/{widget_id}/button-actions`) -- not a frame-level
|
|
tab, since assigning a widget's next/back behavior is naturally part of
|
|
configuring that widget. The card only renders for widget types with a
|
|
non-empty `ACTIONS` (photos, calendar, whiteboard, weather); tasks/
|
|
static/text/battery have nothing to bind so the card is omitted for
|
|
them. An empty selection ("(none)") clears that button's binding for the
|
|
widget.
|
|
|
|
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 `widgets.default_button_actions` (called from
|
|
both `migration.py`'s backfill and `api_widgets.py`'s
|
|
`api_widget_create`), so a widget is never left with nothing bound until
|
|
someone deliberately reassigns it.
|
|
|
|
### Hold-for-global-action
|
|
|
|
Holding NEXT or BACK past a configurable duration (`Frame.hold_duration_ms`,
|
|
minimum 3000ms, set on the Configuration tab) triggers a **global**
|
|
action instead of the per-widget one -- not scoped to any widget, e.g.
|
|
cycling through the user's saved layouts. See `app/global_actions.py`'s
|
|
`GLOBAL_ACTIONS`/`GLOBAL_ACTION_LABELS` registry and
|
|
`routers/device.py`'s `/frame/global-next`/`/frame/global-back` (the
|
|
device calls these instead of `/frame/advance`/`/frame/back` once it
|
|
detects a long press -- see `firmware/main/next_button.c`/`back_button.c`).
|
|
`Frame.next_hold_action`/`back_hold_action` pick which registry entry (if
|
|
any) each button's hold triggers; unset is a silent no-op, same
|
|
convention as an unbound short-press button.
|
|
|
|
## 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.
|
|
|
|
## Saved layouts
|
|
|
|
A user can snapshot a frame's whole widget arrangement -- every widget's
|
|
type/placement/settings, calendar/task sources, and button-action
|
|
bindings -- under a name (`SavedLayout` + `SavedLayoutWidget` +
|
|
`SavedLayoutSource` + `SavedLayoutButtonAction`, `server/app/models.py`),
|
|
then switch back to it later, or apply it to a *different* frame. Saved
|
|
layouts are owned by the **user**, not any one frame -- the same set
|
|
shows up (with a per-frame `compatible` flag) on every frame that user
|
|
controls whose grid matches (`grid.grid_dims(orientation)`'s cols/rows,
|
|
landscape-class 8x5 vs. portrait-class 5x8), not just the frame it was
|
|
captured from.
|
|
|
|
Saving only captures an authored *setting*, never runtime/cache state --
|
|
a photo widget's current queue position, a calendar's fetch cache, a
|
|
whiteboard's rendered-image cache, etc. are deliberately left out (see
|
|
`routers/api_layouts.py`'s `LAYOUT_CONFIG_FIELDS` allowlist per
|
|
`widget_type`), so applying a layout feels like a fresh widget of that
|
|
type with its settings pre-filled, not a resurrection of stale state
|
|
from whenever it was saved. A static-image widget's uploaded bytes are
|
|
the one exception carried through verbatim (`SavedLayoutWidget.image`).
|
|
Saving again under a name the user already has overwrites that layout's
|
|
snapshot in place rather than erroring or creating a duplicate --
|
|
`SavedLayout`'s own docstring.
|
|
|
|
Applying a layout to a frame (`api_layout_apply`, `require_frame_control`)
|
|
deletes every widget currently on that frame and recreates the saved
|
|
arrangement from scratch, remapping calendar/task sources and button
|
|
bindings onto the newly-created widget ids -- same "act unconditionally
|
|
on the server, confirm on the client" posture as the Layout tab's own
|
|
"Clear all". A source whose owning user account no longer exists is
|
|
silently dropped rather than left dangling (config is JSON, not
|
|
FK-checked, so nothing else would catch that).
|
|
|
|
The web UI lives in the Layout tab's "Saved layouts" card
|
|
(`static/saved_layouts.js`, `GET`/`POST /api/frames/{id}/layouts`,
|
|
`PATCH`/`DELETE /api/layouts/{id}`, `POST
|
|
/api/frames/{id}/layouts/{id}/apply`) -- name + Save, then a list of
|
|
saved layouts each with Apply/rename/delete, incompatible ones shown
|
|
greyed-out with a "different orientation" badge rather than hidden.
|
|
|
|
## Weather widget
|
|
|
|
A standalone widget type (`models.WeatherWidgetConfig`, `app/widgets/
|
|
weather.py`) -- distinct from, and unrelated in code to,
|
|
`CalendarWidgetConfig`'s own embedded weather strip (still present,
|
|
still Open-Meteo-only, still working exactly as before). Four display
|
|
modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
|
|
`calendar_view`):
|
|
|
|
- `current` -- one city's current temp + a condition icon.
|
|
- `hourly` -- one city, a row of ticks across the day at a configurable
|
|
interval (`hourly_interval_hours`: 3/4/6/12).
|
|
- `daily` -- one city, a multi-day strip (`daily_days`, 1-14).
|
|
- `multi_city` -- several cities' current-day high/low/icon side by
|
|
side -- the calendar widget's embedded strip, as a standalone
|
|
widget's whole content instead of a strip above an agenda day.
|
|
|
|
**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default
|
|
| `"modern"`, experimental) -- see "Modern render style" above; weather's
|
|
own modern coverage is `current`/`daily` only, `hourly`/`multi_city`
|
|
always render classic regardless of this setting.
|
|
|
|
`current`/`hourly`/`daily` share one configured location
|
|
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
|
|
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
|
|
has its own list (`cities`, add/remove via `POST .../weather-widget-
|
|
cities/add`|`remove` -- named to avoid colliding with the calendar
|
|
widget's own, differently-scoped `weather-cities/add`|`remove` routes,
|
|
which share the same `{widget_id}`-parameterized path shape).
|
|
|
|
**Providers** (`app/weather/`, a dispatch registry over pluggable
|
|
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
|
|
provider` selects which of `app/weather.PROVIDERS` actually fetches --
|
|
`"open_meteo"` (worldwide, no API key), `"nws"` (api.weather.gov, US
|
|
only, no API key, approximates "current" with the first hourly forecast
|
|
period rather than a real station observation), or `"ec"` (Environment
|
|
Canada, api.weather.gc.ca's MSC GeoMet OGC API, Canada only, no API key).
|
|
Every provider function returns already-normalized `{"category": ...}`
|
|
entries (one of `clear`/`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/
|
|
`thunderstorm`) so `app/weather_render.py`'s drawing code never needs to
|
|
know which provider supplied an entry. `geocode_city` (name -> lat/lon)
|
|
always goes through Open-Meteo's free geocoder regardless of which
|
|
provider is chosen to fetch with the result.
|
|
|
|
EC's `citypageweather-realtime` collection is only queryable by bounding
|
|
box (OGC API - Features), not a direct by-coordinate endpoint -- unlike
|
|
Open-Meteo/NWS's simple lat/lon REST, `app/weather/ec.py`'s
|
|
`_nearest_site` widens the box progressively and picks the closest site
|
|
by straight-line distance, rejecting anything beyond 300 km (calibrated
|
|
against a real bug caught in development: an unconditional "nearest
|
|
site, however far" matched a Miami, FL query to a site in Ontario,
|
|
1824 km away, once the box widened enough to cover the whole country).
|
|
|
|
`app/weather_render.py` holds every weather-related drawing primitive:
|
|
`draw_weather_icon`/`draw_weather_row` (extracted out of
|
|
`calendar_render.py`, which still imports `draw_weather_row` for its own
|
|
embedded strip, unchanged) plus this widget's own `build_current`/
|
|
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
|
|
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
|
|
`render_tasks_preview_png` relationship. Icons are hand-drawn (no custom
|
|
font/icon asset), styled after Environment Canada's own icon set
|
|
(pointed sun rays, a puffy cloud, teardrop rain, dendrite snowflakes, a
|
|
zigzag bolt) but filled with the panel's *exact* ink RGB values rather
|
|
than an arbitrary bitmap's anti-aliased colors -- a flat fill that's
|
|
already a palette color quantizes with zero dithering error to diffuse,
|
|
where a fetched/vendored icon's colors (almost never an exact match)
|
|
dither into a visible speckle at these small on-panel sizes (confirmed
|
|
by actually running one through the real quantize pass during
|
|
development). Used for every provider's rendering, not just when EC is
|
|
selected as the provider.
|
|
|
|
## Battery widget
|
|
|
|
The simplest widget type (`models.BatteryWidgetConfig`, `app/widgets/
|
|
battery.py`): shows this frame's own last-reported battery level. Unlike
|
|
every other widget type, there's no live upstream to poll and nothing to
|
|
cache -- the content is `Frame.battery_percent`/`battery_as_of`, set by
|
|
`routers/device.py`'s `frame_battery` on every device wake-on-battery
|
|
report, which already existed for the Device panel's own history chart
|
|
regardless of whether a battery widget is placed anywhere. The widget's
|
|
own config is just a display mode: `"compact"` (icon + percent) or
|
|
`"detailed"` (default, adds `routers/common.py`'s existing
|
|
`battery_estimate_s` time-remaining estimate and the last report's age).
|
|
`render()` falls back to a "No reports yet" placeholder for a frame that
|
|
has never reported (never run on battery, or not yet claimed by a
|
|
device) rather than showing a stale or fabricated number. The battery
|
|
icon fill color (red/yellow/green by percent) uses the same exact-panel-
|
|
ink-RGB approach as the weather icons above and `manage_overlay.py`'s own
|
|
battery glyph on the "scan to manage" overlay -- a separate, unrelated
|
|
piece of code with its own fixed small size, not shared with this
|
|
widget, but drawing from the same thresholds/colors so a battery glyph
|
|
reads the same wherever one shows up on a panel.
|
|
|
|
## 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.
|