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.
143 lines
5.6 KiB
Python
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"}
|