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).
This commit is contained in:
@@ -14,7 +14,8 @@ from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .image_pipeline import DEFAULT_PALETTE_RGB, draw_text
|
||||
from . import panel_style
|
||||
from .image_pipeline import draw_text
|
||||
|
||||
PADDING = 16
|
||||
QR_TEXT_GAP = 8
|
||||
@@ -27,9 +28,12 @@ BODY_FONT_SIZE = 20
|
||||
|
||||
BATTERY_ICON_W = 40
|
||||
BATTERY_ICON_H = 22
|
||||
BATTERY_ICON_STROKE = 2
|
||||
# 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_NUB_H = 10
|
||||
BATTERY_ICON_TEXT_GAP = 8
|
||||
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
|
||||
|
||||
@@ -37,10 +41,6 @@ FACE_LABEL_PADDING = 8
|
||||
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
|
||||
|
||||
|
||||
def _font(size: int) -> ImageFont.ImageFont:
|
||||
return ImageFont.load_default(size=size)
|
||||
|
||||
|
||||
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
|
||||
import qrcode
|
||||
|
||||
@@ -52,7 +52,7 @@ def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
|
||||
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
||||
|
||||
|
||||
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont) -> tuple[int, int]:
|
||||
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
|
||||
@@ -64,7 +64,7 @@ def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.Image
|
||||
return w, h
|
||||
|
||||
|
||||
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
|
||||
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:
|
||||
@@ -82,7 +82,8 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
|
||||
(the battery, below the manage QR) use it instead of recomputing the
|
||||
same geometry a second time."""
|
||||
qr_img = _qr_image(url)
|
||||
text_w, text_h = _text_box(draw, caption, _font(TITLE_FONT_SIZE)) if caption else (0, 0)
|
||||
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)
|
||||
|
||||
@@ -90,24 +91,26 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
|
||||
h = content_h + PADDING * 2
|
||||
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
||||
|
||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
||||
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, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
|
||||
_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 = _font(BODY_FONT_SIZE)
|
||||
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.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
||||
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)
|
||||
|
||||
|
||||
@@ -123,34 +126,17 @@ def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner:
|
||||
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
|
||||
|
||||
|
||||
# DEFAULT_PALETTE_RGB order is [BLACK, WHITE, YELLOW, RED, BLUE, GREEN]
|
||||
# (see image_pipeline.PANEL_CODES) -- picked by level so the fill itself
|
||||
# carries the "how worried should I be" signal, not just the number next
|
||||
# to it. Thresholds match the low-battery-alert spirit elsewhere in this
|
||||
# project (not tied to a frame's own configured alert threshold, since
|
||||
# this glyph has to make sense with no configuration at all).
|
||||
_BATTERY_LOW = DEFAULT_PALETTE_RGB[3] # red
|
||||
_BATTERY_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
|
||||
_BATTERY_HIGH = DEFAULT_PALETTE_RGB[5] # green
|
||||
|
||||
|
||||
def _battery_fill_color(percent: int) -> tuple[int, int, int]:
|
||||
if percent <= 15:
|
||||
return _BATTERY_LOW
|
||||
if percent <= 40:
|
||||
return _BATTERY_MEDIUM
|
||||
return _BATTERY_HIGH
|
||||
|
||||
|
||||
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 (now actually filled to `percent`, not just a static
|
||||
outline -- easy now that this renders server-side instead of being a
|
||||
fixed bitmap firmware drew) + "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."""
|
||||
font = _font(BODY_FONT_SIZE)
|
||||
"""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)
|
||||
@@ -162,22 +148,14 @@ def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anc
|
||||
x0 = anchor_x0 + anchor_w - w
|
||||
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
|
||||
|
||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
||||
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
|
||||
inner_x0, inner_y0 = icon_x + BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_STROKE
|
||||
inner_x1, inner_y1 = icon_x + BATTERY_ICON_W - BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_H - BATTERY_ICON_STROKE
|
||||
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (percent / 100))
|
||||
if fill_x1 > inner_x0:
|
||||
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_battery_fill_color(percent))
|
||||
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
|
||||
width=BATTERY_ICON_STROKE)
|
||||
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
|
||||
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
|
||||
fill=(0, 0, 0))
|
||||
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)
|
||||
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:
|
||||
@@ -185,7 +163,7 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
|
||||
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 = _font(BODY_FONT_SIZE)
|
||||
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]
|
||||
@@ -201,7 +179,8 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
|
||||
x0 = max(0, min(x0, img_w - w))
|
||||
y0 = max(0, min(y0, img_h - h))
|
||||
|
||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user