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).
220 lines
9.3 KiB
Python
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
|