Shows Frame.battery_percent/battery_as_of, already set by every device wake-on-battery report, plus routers/common.py's existing battery_estimate_s time-remaining estimate -- nothing new to fetch or cache. Compact (icon + percent) or detailed (+ estimate, last report age) display mode. No button actions.
114 lines
4.7 KiB
Python
114 lines
4.7 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; weather needs enough room for its
|
|
# hourly/daily strips to stay legible (its current/multi_city modes
|
|
# would tolerate smaller, but every mode shares one footprint value).
|
|
# battery is just an icon + a percent (+ two optional small lines in
|
|
# "detailed" mode) -- legible even at a single cell, like photos/static.
|
|
# NOTE: a 1x1 widget-box on a narrow mobile canvas can clip its own
|
|
# gear/remove buttons behind theme.css's overflow: hidden (their fixed
|
|
# pixel offsets overflow the box's clipped width) -- a pre-existing
|
|
# layout gap that already affects photos/static at 1x1 too, not fixed
|
|
# here; see the finding called out where this was discovered.
|
|
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
|
"photos": (1, 1),
|
|
"calendar": (3, 2),
|
|
"whiteboard": (2, 2),
|
|
"tasks": (2, 2),
|
|
"static": (1, 1),
|
|
"text": (2, 1),
|
|
"weather": (2, 2),
|
|
"battery": (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)
|