Widget system Phase 1: per-type render/action modules
New app/widgets/ package (photos.py, calendar.py, whiteboard.py, plus the WIDGET_TYPES registry) -- the widget-system analogue of routers/device.py's old RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS, generalized from "one mode owns the whole panel" to "each widget renders into its own region and responds to named button actions." Each module exposes render(db, frame, widget, target_w, target_h) -> Image.Image (never raises -- a widget's own fetch hiccup falls back to a small placeholder rather than taking the whole panel's render down) and an ACTIONS registry for NEXT/BACK button assignment. Supporting changes needed to give the widget modules something to call, all mechanical/behavior-preserving for every existing caller: - image_pipeline.py: render_panel(regions, ...) generalizes render_frame's tail (paste, enhance once, overlay once, quantize once, pack once) from one photo to N regions -- not a restructuring, since calendar mode's photo-inlay feature already pastes a second composed image onto the canvas before that single shared pipeline runs. - photo_queue.py: advance_forced/back_forced/remove_from_rotation/ get_current take an explicit `frame` param now that `cfg` won't always be the Frame itself once photo-queue state moves to PhotoWidgetConfig -- caught a real latent bug while doing this: get_current was reading refresh_interval_s off `cfg`, but that's a frame-level wake-cadence setting, not something that becomes per-widget, so it now reads that off `frame` explicitly instead. - routers/common.py: list_assets/fetch_source_and_faces take album_id/ display_mode directly instead of a whole Frame (both only ever read that one attribute off it); new get_or_refresh_*_for_widget siblings of the existing calendar/weather/tasks/whiteboard cache helpers, read/ writing the new per-widget config tables -- the Frame-scoped originals are untouched and still what routers/device.py's actual dispatch calls until the Phase 2 cutover. 26 new tests (95 total): render_panel size/placement/orientation coverage, and per-widget-type render/action tests (unconfigured -> placeholder, a fetch failure -> placeholder not a crash, actions mutate the right state). Full suite passes; diff-reviewed to confirm device.py's actual RENDERERS dispatch and the old Frame-scoped get_or_refresh_* bodies are unchanged, so this is safe to deploy on its own despite being step 1 of a two-step cutover (see the project plan on why the *next* step, not this one, has to ship atomically).
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""Registry mapping a Widget's widget_type to its render/action module --
|
||||
the widget-system's analogue of routers/device.py's old RENDERERS/
|
||||
ADVANCE_RENDERERS/BACK_RENDERERS dicts, generalized from "one mode owns
|
||||
the whole panel" to "each widget renders into its own region and
|
||||
optionally responds to named button actions."
|
||||
|
||||
Each module in this package exposes:
|
||||
|
||||
render(db, frame, widget, target_w, target_h) -> Image.Image
|
||||
An RGB image exactly target_w x target_h, unquantized -- the
|
||||
widget's content composed into its own region. Never returns
|
||||
packed panel bytes or raises for a foreseeable failure (a
|
||||
widget's own fetch hiccup shows a small placeholder instead) --
|
||||
image_pipeline.render_panel composites every widget's own
|
||||
render() result onto one shared canvas and quantizes/packs the
|
||||
whole thing once (see its own docstring).
|
||||
|
||||
ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]
|
||||
Named button actions this widget type supports (e.g. "advance",
|
||||
"back", "check_now") -- see models.FrameButtonAction. Each
|
||||
function mutates the widget's own state via db.widget_locked
|
||||
internally; none return a value or re-render themselves --
|
||||
whatever dispatches a button press (routers/device.py, once the
|
||||
widget-system cutover lands) re-renders the whole panel once
|
||||
after running every assigned action.
|
||||
|
||||
ACTION_LABELS: dict[str, str]
|
||||
Human-readable labels for ACTIONS' keys, for the button-
|
||||
assignment UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import calendar, photos, whiteboard
|
||||
|
||||
WIDGET_TYPES = {
|
||||
"photos": photos,
|
||||
"calendar": calendar,
|
||||
"whiteboard": whiteboard,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Tiny widget-region placeholder image, shared by every widget-type
|
||||
module for the "not configured yet" / "temporarily unavailable" case --
|
||||
deliberately much simpler than image_pipeline.render_placeholder (no QR
|
||||
code, no full-panel-scale fonts): a widget's own region can be a small
|
||||
fraction of the panel, so its placeholder needs to scale down with it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
_BG = (245, 245, 245)
|
||||
_FG = (90, 90, 90)
|
||||
|
||||
|
||||
def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image:
|
||||
img = Image.new("RGB", (target_w, target_h), _BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font_size = max(10, min(20, target_h // 8))
|
||||
font = ImageFont.load_default(size=font_size)
|
||||
line_h = font_size + 4
|
||||
total_h = line_h * len(lines)
|
||||
y = max(4, (target_h - total_h) // 2)
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
line_w = bbox[2] - bbox[0]
|
||||
x = max(4, (target_w - line_w) // 2)
|
||||
draw.text((x, y), line, fill=_FG, font=font)
|
||||
y += line_h
|
||||
return img
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Calendar widget: merged-event agenda/week/month view into the
|
||||
widget's own region -- the widget-system analogue of routers/device.py's
|
||||
old _render_calendar_mode/_advance_calendar_mode/_back_calendar_mode.
|
||||
|
||||
Full-panel-only in this phase: calendar_render.py's layout math (font
|
||||
sizes, margins, row heights) is still tuned for a full ~800x480 canvas,
|
||||
not derived from an arbitrary target box -- see calendar_render._build.
|
||||
render() below builds at the frame's own logical_render_size(orientation)
|
||||
and resizes to whatever target box it's actually asked for, which is
|
||||
correct today (the only calendar widget that exists pre-Phase-4a is the
|
||||
single auto-migrated full-panel one) but not yet a real "small calendar
|
||||
widget" layout -- that's a later phase's job (see the project's plan
|
||||
file), not a shortcut being silently taken here.
|
||||
|
||||
"Photo inlay" (calendar mode's old half-and-half photo split) has no
|
||||
widget-system equivalent -- place an independent photo widget alongside
|
||||
instead; arbitrary placement is strictly more flexible than one fixed
|
||||
split ever was."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..calendar_render import _build
|
||||
from ..db import widget_locked
|
||||
from ..models import CalendarWidgetConfig, Frame, Widget
|
||||
from ..routers.common import (
|
||||
get_or_refresh_calendar_events_for_widget,
|
||||
get_or_refresh_tasks_for_widget,
|
||||
get_or_refresh_weather_for_widget,
|
||||
)
|
||||
|
||||
ACTION_LABELS = {"advance": "Next period", "back": "Previous period"}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
||||
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
|
||||
tasks = (
|
||||
get_or_refresh_tasks_for_widget(db, frame, widget)
|
||||
if (cfg.view == "week" and cfg.tasks_enabled) else None
|
||||
)
|
||||
|
||||
img = _build(
|
||||
events, view=cfg.view, browse_offset=cfg.browse_offset, orientation=frame.orientation,
|
||||
timezone=frame.timezone, photo_inlay=None, fetch_summary=fetch_summary, week_start=cfg.week_start,
|
||||
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
|
||||
week_days=cfg.week_days, week_layout=cfg.week_layout, tasks=tasks,
|
||||
week_start_offset=cfg.week_start_offset,
|
||||
)
|
||||
if img.size != (target_w, target_h):
|
||||
img = img.resize((target_w, target_h), Image.LANCZOS)
|
||||
return img
|
||||
|
||||
|
||||
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.browse_offset += 1
|
||||
|
||||
|
||||
def _back(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.browse_offset -= 1
|
||||
|
||||
|
||||
ACTIONS = {"advance": _advance, "back": _back}
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Photos widget: composes one photo from Immich into the widget's own
|
||||
region -- the widget-system analogue of routers/device.py's old
|
||||
_render_photos_mode/_advance_photos_mode/_back_photos_mode, and
|
||||
app/photo_queue.py's real client.
|
||||
|
||||
render() never raises -- an Immich hiccup for this one widget shouldn't
|
||||
take down the whole panel's render just because one region out of
|
||||
several couldn't be composed this cycle; it falls back to a small
|
||||
placeholder instead, the same resilience calendar mode's old photo-inlay
|
||||
already had (see routers/device.py's `except HTTPException: pass` around
|
||||
its own inlay fetch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import photo_queue, quiet_hours
|
||||
from ..db import widget_locked
|
||||
from ..image_pipeline import compose_into
|
||||
from ..models import Frame, PhotoWidgetConfig, Widget
|
||||
from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets
|
||||
from ._shared import placeholder_image
|
||||
|
||||
ACTION_LABELS = {"advance": "Next photo", "back": "Previous photo"}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
return placeholder_image(target_w, target_h, ["Photos widget", "not configured yet"])
|
||||
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, cfg.album_id)
|
||||
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
|
||||
photo_queue.get_current(locked_cfg, assets, locked_frame,
|
||||
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
|
||||
asset_id = locked_cfg.current_asset_id
|
||||
if not asset_id:
|
||||
return placeholder_image(target_w, target_h, ["No photos available"])
|
||||
source, faces = fetch_source_and_faces(client, cfg.display_mode, asset_id)
|
||||
except HTTPException as e:
|
||||
return placeholder_image(target_w, target_h, ["Photos widget", str(e.detail)[:40]])
|
||||
|
||||
return compose_into(source, faces, target_w, target_h, cfg.display_mode)
|
||||
|
||||
|
||||
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
return
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, cfg.album_id)
|
||||
except HTTPException:
|
||||
return # nothing to advance to this cycle -- next button press tries again
|
||||
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
|
||||
photo_queue.advance_forced(locked_cfg, assets, locked_frame)
|
||||
|
||||
|
||||
def _back(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
return
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, cfg.album_id)
|
||||
except HTTPException:
|
||||
return
|
||||
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
|
||||
photo_queue.back_forced(locked_cfg, assets, locked_frame)
|
||||
|
||||
|
||||
ACTIONS = {"advance": _advance, "back": _back}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Whiteboard widget: fetches/renders a Nextcloud Whiteboard (or any
|
||||
WebDAV .whiteboard file) into the widget's own region -- the
|
||||
widget-system analogue of routers/device.py's old
|
||||
_render_whiteboard_mode/_advance_whiteboard_mode/_back_whiteboard_mode.
|
||||
|
||||
No real "next"/"back" concept for a static board (same as before the
|
||||
widget system) -- both buttons map to the same "check now" action, a
|
||||
forced re-fetch/re-render bypassing the normal throttle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..image_pipeline import compose_into
|
||||
from ..models import Frame, Widget
|
||||
from ..routers.common import get_or_refresh_whiteboard_for_widget
|
||||
from ._shared import placeholder_image
|
||||
|
||||
ACTION_LABELS = {"check_now": "Check for updates"}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
|
||||
png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget)
|
||||
if png_bytes is None:
|
||||
return placeholder_image(target_w, target_h, ["Whiteboard widget", "not configured yet"])
|
||||
|
||||
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||
# letterbox, never cropped: unlike a photo, losing part of a
|
||||
# whiteboard to a crop loses actual content, not just some background
|
||||
# (see the old _render_whiteboard_mode's identical reasoning).
|
||||
return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode="letterbox")
|
||||
|
||||
|
||||
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
get_or_refresh_whiteboard_for_widget(db, frame, widget, force=True)
|
||||
|
||||
|
||||
ACTIONS = {"check_now": _check_now}
|
||||
Reference in New Issue
Block a user