Files
espresso_frame/server/app/routers/api_widgets.py
T
tfaour 5d4bb53b8a
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s
Widget system Phase 4a: widget CRUD + grid placement UI
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.
2026-07-24 12:48:56 -04:00

143 lines
5.6 KiB
Python

"""CRUD + grid placement for a frame's widgets (see models.Widget) --
backs the Layout tab's placement canvas (static/frame_widget_canvas.js).
Every mutation re-validates bounds/minimum footprint/no-overlap
server-side regardless of what the client already checked -- the
client's own checks are UX, not the source of truth (this project's
usual posture, e.g. api_frames.py's own field clamps)."""
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import grid
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..models import Frame, WIDGET_CONFIG_MODELS, Widget
from ..widgets import WIDGET_TYPES
router = APIRouter()
def _widget_dict(w: Widget) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order}
@router.get("/api/frames/{frame_id}/widgets")
def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
user = require_user_api(request, db)
cols, rows = grid.grid_dims(frame.orientation)
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
return {
"orientation": frame.orientation,
"grid": {"cols": cols, "rows": rows},
"widget_types": list(WIDGET_TYPES.keys()),
"min_footprint": grid.MIN_FOOTPRINT,
"widgets": [_widget_dict(w) for w in widgets],
"control": {
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
"you": frame.controlled_by_user_id == user.id,
},
}
def _other_rects(db: Session, frame_id: int, exclude_widget_id: int | None) -> list[grid.Rect]:
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame_id)).all()
return [(w.x, w.y, w.w, w.h) for w in widgets if w.id != exclude_widget_id]
def _validate_placement(db: Session, frame: Frame, widget_type: str, rect: grid.Rect,
exclude_widget_id: int | None = None) -> None:
if not grid.in_bounds(frame.orientation, rect):
raise HTTPException(400, "Placement is out of bounds for this frame's grid")
if not grid.meets_minimum(widget_type, rect):
min_w, min_h = grid.MIN_FOOTPRINT.get(widget_type, (1, 1))
raise HTTPException(400, f"A {widget_type} widget needs at least {min_w}x{min_h} grid cells")
for other_rect in _other_rects(db, frame.id, exclude_widget_id):
if grid.overlaps(rect, other_rect):
raise HTTPException(400, "Overlaps another widget")
class WidgetCreateRequest(BaseModel):
widget_type: str
x: int | None = None
y: int | None = None
w: int | None = None
h: int | None = None
@router.post("/api/frames/{frame_id}/widgets")
def api_widget_create(
body: WidgetCreateRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
if body.widget_type not in WIDGET_TYPES:
raise HTTPException(400, f"Unknown widget type: {body.widget_type}")
min_w, min_h = grid.MIN_FOOTPRINT.get(body.widget_type, (1, 1))
w, h = body.w or min_w, body.h or min_h
if body.x is None or body.y is None:
rect = grid.find_open_rect(frame.orientation, _other_rects(db, frame.id, None), w, h)
if rect is None:
raise HTTPException(400, "No open space left for a widget this size")
else:
rect = (body.x, body.y, w, h)
_validate_placement(db, frame, body.widget_type, rect)
with frame_locked(db, frame.id):
max_sort = db.scalar(select(func.max(Widget.sort_order)).where(Widget.frame_id == frame.id))
x, y, w, h = rect
widget = Widget(frame_id=frame.id, widget_type=body.widget_type, x=x, y=y, w=w, h=h,
sort_order=(max_sort or 0) + 1, created_at=time.time())
db.add(widget)
db.flush()
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
db.commit()
return _widget_dict(widget)
class WidgetPlacementRequest(BaseModel):
x: int
y: int
w: int
h: int
@router.patch("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_move(
widget_id: int, body: WidgetPlacementRequest,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
rect = (body.x, body.y, body.w, body.h)
_validate_placement(db, frame, widget.widget_type, rect, exclude_widget_id=widget.id)
with frame_locked(db, frame.id):
widget.x, widget.y, widget.w, widget.h = body.x, body.y, body.w, body.h
db.commit()
return _widget_dict(widget)
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_delete(
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
"""Cascades to the widget's own config row and any FrameButtonAction
bindings that pointed at it (both ondelete="CASCADE" FKs, see
models.py) -- nothing left pointing at a widget id that no longer
exists."""
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
with frame_locked(db, frame.id):
db.delete(widget)
db.commit()
return {"status": "deleted"}