Files
espresso_frame/server/app/grid.py
T
Thomas Faour b5c52004c8
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
Split the tasks feature out of the calendar widget into its own widget type
Task lists used to be a week-view-only sub-feature bolted onto calendar
widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist
tied to a calendar's view and only inside its footprint. Tasks are now
a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that
can be placed and sized independently, same as photos/calendar/
whiteboard -- no separate "enabled" flag either, since being on the
grid at all is the on/off switch, matching every other widget type.

Migration 17 creates task_widget_configs, extracts any existing
calendar widget's configured task source into a new sibling tasks
widget (auto-placed in open grid space, source dropped+logged if truly
none is left), then drops calendar_widget_configs' now-dead tasks_*
columns in the same migration -- this project's usual same-migration-
drop convention. Also handles the rarer case of a database jumping
straight from before the widget system existed to after this split in
one boot, via the legacy Frame.calendar_tasks_* columns.

Verified live in the browser at desktop and mobile widths: adding a
Tasks widget, its own dialog (task-list source picker + preview), and
confirming the calendar widget's dialog no longer mentions tasks at
all. Full test suite (180 tests, including new coverage for the widget
render/actions, the migration's data-extraction path, and the
permission-boundary shape for tasks-source) passes.
2026-07-25 02:11:45 +00:00

101 lines
4.0 KiB
Python

"""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),
}
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)