Files
tfaour d34eb1bf45
Build and push server image / test (push) Successful in 38s
Build and push server image / build-and-push (push) Successful in 2m46s
Build and push server image / deploy (push) Successful in 58s
Modernize on-panel widget visuals: real typography, theme colors, gutter
Introduces app/panel_style.py, a shared style module every render
module now draws through instead of independently duplicating margins/
colors/fonts: Inter Bold/Regular (already vendored, previously only
used by widgets/text.py) replace PIL's single-weight bundled default
font everywhere else; a per-widget-kind accent color (calendar=blue,
tasks=green, weather=black header) replaces plain black-on-white chrome
and is centralized in one THEME mapping so a future global theme only
needs to touch panel_style.py; a small per-widget gutter separates
adjacent widgets without touching grid.py's cell math; header bars,
color chips, and the battery icon get rounded corners.

Also drops the MUTED gray text color used throughout calendar_render.py
and weather_render.py -- a non-palette color that has no close match in
the panel's 6-ink palette and dithers into visible speckle once the
composited canvas is quantized. Secondary text now reads through size/
weight alone, always exact black.

widgets/battery.py and manage_overlay.py's previously-duplicated
battery-glyph-drawing code now share one implementation (panel_style.
draw_battery_icon). widgets/_shared.py's placeholder image is fixed to
use exact palette colors and route through image_pipeline.draw_text,
same as everything else -- it was quietly violating both rules already.

image_pipeline.draw_widget_border gains an opt-in radius param (default
0, unused by any call site) for a possible future rounded-border
setting -- doesn't touch the exact-corner-pixel behavior test_widget_
border.py already pins.

Deliberately out of scope: DEFAULT_PALETTE_RGB and the Floyd-Steinberg
quantization pipeline are untouched, per the prior reverted measured-
palette/OKLab attempt (05b417a/dfe9d701).
2026-07-30 03:12:17 +00:00

220 lines
9.3 KiB
Python

"""Composites the manage-button overlay -- "scan to manage" QR, battery,
location/date-taken, share-QR, named face labels -- server-side, onto an
already-composed image (any mode: a photo, or a calendar view), before
quantization. Replaces what used to be firmware/main/manage_qr_overlay.c
generating and positioning all of this on-device.
Corner/spacing constants below are plain Python now, not a protocol
contract with firmware -- adjustable here without touching anything else.
Uses the same toolkit image_pipeline.render_placeholder already does
(PIL ImageDraw/ImageFont, the qrcode library), just doing more with it.
"""
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
from . import panel_style
from .image_pipeline import draw_text
PADDING = 16
QR_TEXT_GAP = 8
LINE_GAP = 4
PANEL_MARGIN = 20
QR_TARGET_PX = 180
TITLE_FONT_SIZE = 22
BODY_FONT_SIZE = 20
BATTERY_ICON_W = 40
BATTERY_ICON_H = 22
# Stroke/nub width/height are no longer fixed constants here -- panel_
# style.draw_battery_icon derives them from icon_w/icon_h itself (same
# formula widgets/battery.py's own icon already used). BATTERY_NUB_W
# below is kept only as this box's own outer-width estimate, not fed
# into the icon drawing itself.
BATTERY_NUB_W = 5
BATTERY_ICON_TEXT_GAP = 8
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
FACE_LABEL_PADDING = 8
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
import qrcode
qr = qrcode.QRCode(border=1, box_size=1)
qr.add_data(url)
qr.make(fit=True)
raw = qr.make_image().get_image().convert("RGB")
scale = max(1, target_px // raw.width)
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont) -> tuple[int, int]:
"""(width, height) of `lines` stacked with LINE_GAP between them, at
`font` -- the box _draw_text_box below will need."""
w = 0
h = 0
for i, line in enumerate(lines):
bbox = draw.textbbox((0, 0), line, font=font)
w = max(w, bbox[2] - bbox[0])
h += (bbox[3] - bbox[1]) + (LINE_GAP if i else 0)
return w, h
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont,
center_x: int, top: int) -> None:
y = top
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
w = bbox[2] - bbox[0]
draw_text(img, (center_x - w // 2, y), line, font)
y += (bbox[3] - bbox[1]) + LINE_GAP
def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption: list[str],
corner: str) -> tuple[int, int, int, int]:
"""White-padded box with a QR code and centered caption lines below
it, placed in one of the panel's four corners. Returns (x0, y0, w, h)
-- callers that need to anchor something else relative to this box
(the battery, below the manage QR) use it instead of recomputing the
same geometry a second time."""
qr_img = _qr_image(url)
caption_font = panel_style.font_bold(TITLE_FONT_SIZE)
text_w, text_h = _text_box(draw, caption, caption_font) if caption else (0, 0)
content_w = max(qr_img.width, text_w)
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0)
w = content_w + PADDING * 2
h = content_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
fill=(255, 255, 255), outline=(0, 0, 0))
center_x = x0 + w // 2
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
if caption:
_draw_centered_lines(img, draw, caption, caption_font, center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
return x0, y0, w, h
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None:
"""White-padded box with centered text lines, placed in one of the
panel's four corners."""
font = panel_style.font_regular(BODY_FONT_SIZE)
text_w, text_h = _text_box(draw, lines, font)
w = text_w + PADDING * 2
h = text_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
fill=(255, 255, 255), outline=(0, 0, 0))
_draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING)
def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]:
img_w, img_h = img_size
box_w, box_h = box_size
if corner == "top-left":
return PANEL_MARGIN, PANEL_MARGIN
if corner == "top-right":
return img_w - PANEL_MARGIN - box_w, PANEL_MARGIN
if corner == "bottom-left":
return PANEL_MARGIN, img_h - PANEL_MARGIN - box_h
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
anchor_w: int, anchor_h: int) -> None:
"""Battery glyph + "NN%" text, right-aligned under the given anchor
box (the manage QR box) -- a sensible default position, not a
constraint anything else has to route around; move this call site's
arguments to place it anywhere else instead. The glyph itself is
panel_style.draw_battery_icon -- the one shared implementation
replacing what used to be a second, independent copy of widgets/
battery.py's own icon-drawing code (same shape, same red/yellow/
green thresholds, previously kept in sync by convention only)."""
font = panel_style.font_regular(BODY_FONT_SIZE)
text = f"{percent}%"
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
text_w = draw.textlength(text, font=font)
content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w
content_h = max(font.size, BATTERY_ICON_H)
w = int(content_w + PADDING * 2)
h = int(content_h + PADDING * 2)
x0 = anchor_x0 + anchor_w - w
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
fill=(255, 255, 255), outline=(0, 0, 0))
icon_x = x0 + PADDING
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
panel_style.draw_battery_icon(draw, icon_x, icon_y, BATTERY_ICON_W, BATTERY_ICON_H, percent)
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
text, font, panel_style.battery_fill_color(percent))
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
"""White-padded name label centered under an arbitrary (anchor_x,
anchor_y) point, flipped above if there's no room below, clamped to
stay fully on-panel -- unlike the four corner boxes (always in-bounds
by construction), a face can be anywhere, including near an edge."""
font = panel_style.font_regular(BODY_FONT_SIZE)
text_w = draw.textlength(name, font=font)
bbox = draw.textbbox((0, 0), name, font=font)
text_h = bbox[3] - bbox[1]
w = int(text_w + FACE_LABEL_PADDING * 2)
h = int(text_h + FACE_LABEL_PADDING * 2)
img_w, img_h = img.size
x0 = anchor_x - w // 2
y0 = anchor_y + FACE_LABEL_GAP
if y0 + h > img_h:
y0 = anchor_y - FACE_LABEL_GAP - h # no room below -- place above instead
x0 = max(0, min(x0, img_w - w))
y0 = max(0, min(y0, img_h - h))
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
fill=(255, 255, 255), outline=(0, 0, 0))
draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
def compose(image: Image.Image, management_url: str, battery_percent: int | None = None,
location_lines: tuple[str, str] | None = None, taken_at: str | None = None,
share_url: str | None = None, face_labels: list[dict] | None = None) -> Image.Image:
"""Draws the manage overlay onto a copy of `image` (RGB, any mode's
already-composed/enhanced logical-space canvas) and returns it.
management_url's "scan to manage" box always shows; everything else
is optional and simply omitted when not given -- battery_percent
None or out of 0-100 skips the battery box, location_lines/taken_at/
share_url empty/None skip their own box, face_labels empty skips
those."""
img = image.copy()
draw = ImageDraw.Draw(img)
qr_x0, qr_y0, qr_w, qr_h = _draw_qr_box(img, draw, management_url, ["SCAN TO", "MANAGE"], "top-right")
if battery_percent is not None and 0 <= battery_percent <= 100:
_draw_battery(img, draw, battery_percent, qr_x0, qr_y0, qr_w, qr_h)
if location_lines and location_lines[0]:
lines = [line for line in location_lines if line]
_draw_text_box(img, draw, lines, "top-left")
if taken_at:
_draw_text_box(img, draw, [taken_at], "bottom-right")
if share_url:
_draw_qr_box(img, draw, share_url, ["SCAN TO", "DOWNLOAD"], "bottom-left")
for label in face_labels or []:
if label.get("name"):
_draw_face_label(img, draw, label["name"], label["x"], label["y"])
return img