Widget system Phase 4a: widget CRUD + grid placement UI
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s

Adds the actual "Android home screen" placement experience: a new
Layout tab with a pointer-driven canvas for dragging/resizing widgets
and adding new ones from a type picker. Backed by a new
routers/api_widgets.py (create/move/delete), which re-validates
bounds, minimum footprint, and no-overlap server-side regardless of
what the client already checked. A widget added without an explicit
position lands in the first open space that fits it
(grid.find_open_rect), so users don't have to hunt for empty space
themselves.

Also fixes a real latent bug this surfaced: changing a frame's
orientation swaps the widget grid's long/short axis, which left
existing widget placements out of bounds on the new grid with no
render-time safeguard. Orientation changes now reset the layout to a
single full-panel widget (keeping the first widget's type, dropping
the rest), with a client-side confirm before it happens.
This commit is contained in:
2026-07-24 12:48:56 -04:00
parent 99069ba5fe
commit 5d4bb53b8a
11 changed files with 780 additions and 5 deletions
+46 -3
View File
@@ -25,7 +25,7 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather, webdav_client
from .. import calendar_render, gitea_releases, grid, photo_queue, quiet_hours, weather, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
@@ -36,7 +36,15 @@ from ..image_pipeline import (
render_preview_png,
)
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, WhiteboardWidgetConfig
from ..models import (
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameCalendar,
PhotoWidgetConfig,
WhiteboardWidgetConfig,
Widget,
)
from .common import (
OVERDUE_FACTOR,
battery_estimate_s,
@@ -67,6 +75,32 @@ MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
cols x rows (see grid.grid_dims) -- landscape and portrait use a
transposed grid (8x5 vs 5x8), so an existing placement is often
literally out of bounds on the new grid, not just visually wrong.
There's no sensible coordinate remap between two differently-shaped
grids, so instead: keep whichever widget was first by placement
order, resized to fill the new full panel, and delete the rest --
cascading to their own config rows and any FrameButtonAction
bindings via ondelete="CASCADE" (see models.py). The frontend is
expected to confirm this with the user before submitting an
orientation change (see frame_config.js) -- this always executes
unconditionally once called, same posture as every other
confirm-on-the-client / act-unconditionally-on-the-server action in
this codebase."""
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame_id).order_by(Widget.sort_order)
).all()
if not widgets:
return
keep, *rest = widgets
for widget in rest:
db.delete(widget)
keep.x, keep.y, keep.w, keep.h = grid.full_panel_rect(new_orientation)
@router.get("/api/frames/{frame_id}/albums")
def api_albums(frame: Frame = Depends(require_frame_view)):
url, key = immich_creds(frame)
@@ -124,6 +158,12 @@ def api_config_save(
harmless no-ops if an old cached page still POSTs them -- FastAPI
silently ignores form fields with no matching parameter.
An actual orientation *change* resets the frame's widget layout (see
_reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a
landscape<->portrait change, so an old placement is usually not just
visually wrong but literally out of bounds on the new grid.
Until the widget-placement UI (a later phase) lets a frame have more
than one widget of a type, "the photo widget" / "the calendar
widget" below unambiguously means the frame's single auto-migrated
@@ -138,7 +178,10 @@ def api_config_save(
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
)
if orientation is not None:
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
new_orientation = orientation if orientation in ORIENTATIONS else "landscape"
if new_orientation != cfg.orientation:
_reset_widget_layout_for_new_orientation(db, cfg.id, new_orientation)
cfg.orientation = new_orientation
if quiet_hours_enabled is not None:
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):