"""Snap-to-grid placement math for widgets (see models.Widget) -- pure, no I/O, no ORM. The grid is defined relative to the panel's long/short axis, not landscape/portrait specifically, so it stays valid across image_pipeline.logical_render_size(orientation)'s genuine width/height swap for portrait (not just a rotation applied at the very end) -- landscape orientations are GRID_LONG columns x GRID_SHORT rows, portrait orientations are GRID_SHORT columns x GRID_LONG rows, same cell size either way. Changing a frame's orientation therefore invalidates any existing widget layout (an 8x5 arrangement isn't valid on a 5x8 grid) -- callers are expected to reset to one full-panel widget on an orientation change, not try to remap coordinates. """ from __future__ import annotations GRID_LONG = 8 GRID_SHORT = 5 # Per-widget-type minimum grid footprint (cols, rows) -- enforced both in # the placement UI and server-side (routers/api_widgets.py). A calendar # widget crammed into 1x1 would be illegible regardless of size-tier # scaling (see calendar_render.py); whiteboard needs enough room to be # worth looking at; photos can go as small as a single cell; tasks needs # enough width for a due-date prefix plus a couple words of summary # without truncating on every row. MIN_FOOTPRINT: dict[str, tuple[int, int]] = { "photos": (1, 1), "calendar": (3, 2), "whiteboard": (2, 2), "tasks": (2, 2), "static": (1, 1), } Rect = tuple[int, int, int, int] # (x, y, w, h) def grid_dims(orientation: str) -> tuple[int, int]: """(cols, rows) for this orientation.""" if orientation in ("portrait", "portrait_flipped"): return GRID_SHORT, GRID_LONG return GRID_LONG, GRID_SHORT def full_panel_rect(orientation: str) -> Rect: """The single full-panel widget rect for this orientation -- what a frame gets reset to whenever its layout can't carry over (initial migration backfill, an orientation change).""" cols, rows = grid_dims(orientation) return (0, 0, cols, rows) def in_bounds(orientation: str, rect: Rect) -> bool: cols, rows = grid_dims(orientation) x, y, w, h = rect return x >= 0 and y >= 0 and w > 0 and h > 0 and x + w <= cols and y + h <= rows def meets_minimum(widget_type: str, rect: Rect) -> bool: min_w, min_h = MIN_FOOTPRINT.get(widget_type, (1, 1)) _, _, w, h = rect return w >= min_w and h >= min_h def overlaps(a: Rect, b: Rect) -> bool: ax, ay, aw, ah = a bx, by, bw, bh = b return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah def find_open_rect(orientation: str, existing: list[Rect], w: int, h: int) -> Rect | None: """First w x h rect that's in-bounds and doesn't overlap any of `existing`, scanning row-major (top-left first) -- used when creating a widget without an explicit placement (see routers/api_widgets.py), so adding one from a type picker doesn't require the caller to find empty space itself first. None if no such rect fits anywhere.""" cols, rows = grid_dims(orientation) for y in range(rows - h + 1): for x in range(cols - w + 1): candidate = (x, y, w, h) if not any(overlaps(candidate, other) for other in existing): return candidate return None def cell_to_pixels(orientation: str, panel_w: int, panel_h: int, rect: Rect) -> tuple[int, int, int, int]: """Grid rect -> pixel rect in logical (pre-rotation) canvas space -- against image_pipeline.logical_render_size(orientation)'s own (panel_w, panel_h), the same space every renderer already composes in before the final orientation transpose.""" cols, rows = grid_dims(orientation) cell_w = panel_w / cols cell_h = panel_h / rows x, y, w, h = rect px, py = round(x * cell_w), round(y * cell_h) # Snap the far edge to the next cell boundary rather than compounding # per-cell rounding error across w/h -- keeps adjacent widgets' # shared edge pixel-exact instead of leaving a stray gap/overlap. px2, py2 = round((x + w) * cell_w), round((y + h) * cell_h) return (px, py, px2 - px, py2 - py)