Modernize on-panel widget visuals: real typography, theme colors, gutter
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

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:
2026-07-30 03:12:17 +00:00
parent bcea090e73
commit d34eb1bf45
7 changed files with 489 additions and 292 deletions
+138 -115
View File
@@ -11,7 +11,7 @@ Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
"color_index"}, ...]} -- more than one entry in "sources" means
merge_events collapsed several calendars' identical (same title/time)
events into one, see _event_colors/_draw_color_bar below.
events into one, see _event_colors/panel_style.draw_color_chip below.
"""
from __future__ import annotations
@@ -26,6 +26,7 @@ from zoneinfo import ZoneInfo
from PIL import Image, ImageDraw, ImageFont
from . import panel_style
from .image_pipeline import (
DEFAULT_PALETTE_RGB,
_apply_manage_overlay,
@@ -42,12 +43,20 @@ CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (t
"week": "Week", "month": "Month"}
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
MARGIN = 20
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
# re-tuned). BG/FG are this module's own plain black/white -- checkbox
# outlines, month-view grid hairlines -- not a text-emphasis concern (no
# MUTED gray here anymore -- see panel_style's module docstring for why:
# a mid-gray fill has no close palette match and dithers into speckle
# once the whole canvas is quantized. Secondary text now reads through
# size/weight alone, always exact black).
MARGIN = panel_style.CONTENT_MARGIN
BG = (255, 255, 255)
FG = (0, 0, 0)
MUTED = (110, 110, 110)
# Was a light gray, but that dithers away to near-invisible once quantized
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
# Structural dividers/grid lines (between stacked day sections, week
# columns, month cells) stay a plain black rule -- gray dithers away to
# near-invisible once quantized to the 6-color e-ink palette. Headers
# no longer use this: see panel_style.draw_header_bar/theme_color.
RULE = (0, 0, 0)
# Fallback for any event whose calendar has no manually pinned color
@@ -67,7 +76,7 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None)
one person's calendar). Usually just one color; more than one is
what tells the "same event, more than one calendar" case apart from
an ordinary single-calendar event at render time -- see
_draw_color_bar. Each source's own manually pinned color
panel_style.draw_color_chip. Each source's own manually pinned color
(FrameCalendar.color_index -- see routers/api_widgets.py's
api_widget_calendar_color) resolves against whichever palette this frame
actually renders with, so a pinned "Blue" stays this frame's actual
@@ -91,24 +100,6 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None)
return colors
def _draw_color_bar(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
colors: list[tuple[int, int, int]], radius: int) -> None:
"""One rounded bar for a single-source event, or that same overall
footprint split into equal-width side-by-side segments -- one per
contributing calendar -- for a deduplicated shared event (see
_event_colors/calendar_feed.merge_events). Splitting rather than
e.g. concentric rings keeps every color equally "thick and bold" at
a glance, the same design goal a single pinned color already has."""
if len(colors) == 1:
draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=colors[0])
return
seg_w = (x1 - x0) / len(colors)
for i, color in enumerate(colors):
seg_x0 = round(x0 + i * seg_w)
seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
"""Parses event["start"] and, for timed events, converts to `tz` --
calendar_feed.py stores whatever timezone each source event carried
@@ -158,11 +149,12 @@ def _fmt_task_due(due: str | None) -> str:
return d.strftime("%b %-d")
# ImageFont.load_default() (used for everything else in this module --
# see the module docstring) has no emoji glyphs, and PIL/FreeType don't
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a
# visible filled rectangle) -- reads as a rendering glitch, not "emoji
# not supported". So event titles get drawn with two fonts: the normal
# Neither Inter (panel_style.font_bold/font_regular, this module's own
# body/title font -- see MARGIN/BG/FG comment above) nor PIL's bundled
# default font has emoji glyphs, and PIL/FreeType don't skip an
# unsupported codepoint, they substitute a ".notdef" tofu box (a visible
# filled rectangle) -- reads as a rendering glitch, not "emoji not
# supported". So event titles get drawn with two fonts: the normal
# text font for everything else, and one of these for actual emoji runs
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
@@ -401,15 +393,17 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
these vertically without duplicating the row-layout/truncation
logic. Weather is drawn above the event list -- eating into the same
row budget the event count is truncated against, exactly like the
header/rule above it already does."""
header bar above it already does."""
x0, y0, w, h = region
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
header_h = title_font.size + 20
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
panel_style.theme_color("calendar", palette_rgb))
text_x0 = x0 + MARGIN
text_w = w - MARGIN * 2
header = day.strftime("%A, %B ") + str(day.day)
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
_truncate_to_width(draw, header, title_font, text_w), title_font, BG)
y = y0 + header_h + 12
weather_entries = _weather_for_day(weather_cities, day)
if weather_entries:
@@ -422,13 +416,13 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not day_events:
draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED)
draw_text(img, (text_x0, y), "Nothing scheduled", body_font)
for i, event in enumerate(day_events):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font)
break
colors = _event_colors(event, owners_seen, palette_rgb)
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
prefix = f"{time_str} "
draw_text(img, (text_x0 + 18, y), prefix, body_font)
@@ -445,13 +439,13 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
(`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
default; the only widget type with its own on-panel title, since
it's the only one where "which list is this" isn't obvious from its
content the way a calendar/photo/whiteboard's is), then a color bar
(reusing _event_colors/_draw_color_bar as-is: a task dict's
top-level owner_display_name/color_index is exactly _event_colors'
single-source fallback shape, since caldav_client.merge_tasks
doesn't cross-list-dedup tasks into a "sources" list the way
merge_events dedups events) + checkbox glyph + due date (if any) +
summary per task, same header/rule/row-cap/truncation shape as
content the way a calendar/photo/whiteboard's is), then a color chip
(reusing _event_colors/panel_style.draw_color_chip as-is: a task
dict's top-level owner_display_name/color_index is exactly
_event_colors' single-source fallback shape, since caldav_client.
merge_tasks doesn't cross-list-dedup tasks into a "sources" list the
way merge_events dedups events) + checkbox glyph + due date (if any)
+ summary per task, same header/row-cap/truncation shape as
_draw_agenda_day's event list so the standalone tasks widget (see
_build_tasks) reads as the same consistent design as everything
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
@@ -460,45 +454,52 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
Outstanding tasks get an empty checkbox; completed ones (only ever
present when TaskWidgetConfig.show_completed is on -- see
caldav_client.fetch_tasks' completed_since) get a filled one and
muted text, no due-date prefix (irrelevant once done)."""
caldav_client.fetch_tasks' completed_since) get a filled checkbox in
this widget's own Green accent (see panel_style.THEME) -- that fill
is the "done" signal, no due-date prefix (irrelevant once done) and
no separate muted text treatment (see module-level MUTED removal
note above _event_colors)."""
x0, y0, w, h = region
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
header_h = title_font.size + 20
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
panel_style.theme_color("tasks", palette_rgb))
text_x0 = x0 + MARGIN
text_w = w - MARGIN * 2
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
_truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font, BG)
y = y0 + header_h + 12
row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not tasks:
draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED)
draw_text(img, (text_x0, y), "Nothing outstanding", body_font)
return
owners_seen: list[str] = []
checkbox_fill = panel_style.theme_color("tasks", palette_rgb)
for i, task in enumerate(tasks):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED)
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font)
break
done = task.get("completed_at") is not None
colors = _event_colors(task, owners_seen, palette_rgb)
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
box = body_font.size - 6
box_x = text_x0 + 18
box_y = y + (row_h - box) // 2 - 5
box_r = min(panel_style.CHIP_RADIUS, box // 2)
if done:
draw.rectangle([box_x, box_y, box_x + box, box_y + box], fill=FG)
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, fill=checkbox_fill)
else:
draw.rectangle([box_x, box_y, box_x + box, box_y + box], outline=FG, width=2)
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, outline=FG, width=2)
text_x = box_x + box + 10
due_str = None if done else _fmt_task_due(task.get("due"))
prefix = f"{due_str} " if due_str else ""
if prefix:
draw_text(img, (text_x, y), prefix, body_font, MUTED)
draw_text(img, (text_x, y), prefix, body_font)
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
body_font, text_w - (text_x - text_x0) - prefix_w, fill=MUTED if done else FG)
body_font, text_w - (text_x - text_x0) - prefix_w)
y += row_h
@@ -512,17 +513,16 @@ _AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16
def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image:
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
img, draw, region = panel_style.card_canvas(target_w, target_h)
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
weather_font = ImageFont.load_default(size=weather_size)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
weather_font = panel_style.font_regular(weather_size)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
owners_seen: list[str] = []
_draw_agenda_day(img, draw, day, events, tz, (0, 0, target_w, target_h), title_font, body_font, owners_seen,
_draw_agenda_day(img, draw, day, events, tz, region, title_font, body_font, owners_seen,
palette_rgb, weather_cities, weather_font, weather_units)
return img
@@ -540,23 +540,22 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
shifts the whole two-day window together, same "days" unit
_build_agenda already uses, so NEXT/BACK behaves identically across
both views."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
weather_font = ImageFont.load_default(size=weather_size)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
weather_font = panel_style.font_regular(weather_size)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2
section_h = ch // 2
owners_seen: list[str] = []
for i in range(2):
section_y0 = i * section_h
section_y0 = cy0 + i * section_h
if i > 0:
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
(0, section_y0, target_w, section_h), title_font, body_font, owners_seen,
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
palette_rgb, weather_cities, weather_font, weather_units)
return img
@@ -585,8 +584,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
otherwise "start of the week" doesn't mean much for an arbitrary day
count, so it instead starts `start_offset` days from today (0 =
today, see routers/api_widgets.py's api_widget_config_save)."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
tier = _size_tier(target_w, target_h)
today = datetime.now(tz).date()
@@ -599,36 +597,36 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
if layout == "vertical":
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
title_font = ImageFont.load_default(size=max(14, title_base - days))
body_font = ImageFont.load_default(size=max(11, body_base - days))
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
section_h = target_h // days
title_font = panel_style.font_bold(max(14, title_base - days))
body_font = panel_style.font_regular(max(11, body_base - days))
weather_font = panel_style.font_regular(max(9, weather_base - days))
section_h = ch // days
for i in range(days):
section_y0 = i * section_h
section_y0 = cy0 + i * section_h
if i > 0:
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
day = week_first_day + timedelta(days=i)
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
_draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h),
title_font, body_font, owners_seen, palette_rgb,
weather_cities, weather_font, weather_units)
return img
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
header_font = ImageFont.load_default(size=header_size)
chip_font = ImageFont.load_default(size=chip_size)
weather_font = ImageFont.load_default(size=weather_size)
col_w = (target_w - MARGIN * 2) // days
header_font = panel_style.font_bold(header_size)
chip_font = panel_style.font_regular(chip_size)
weather_font = panel_style.font_regular(weather_size)
col_w = (cw - MARGIN * 2) // days
header_h = 44
for col in range(days):
day = week_first_day + timedelta(days=col)
x0 = MARGIN + col * col_w
x0 = cx0 + MARGIN + col * col_w
if col > 0:
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
draw_text(img, (x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
y = MARGIN + header_h
y = cy0 + MARGIN + header_h
# Columns are narrow, so only what actually fits gets drawn (see
# weather_render.draw_weather_row) -- typically one city, no label
# (the column itself makes which day it's for obvious; a city name
@@ -640,14 +638,14 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
palette_rgb=palette_rgb)
row_h = chip_font.size + 10
max_rows = max(0, (target_h - MARGIN - y) // row_h)
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz)
for i, event in enumerate(day_events):
if i >= max_rows:
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font)
break
colors = _event_colors(event, owners_seen, palette_rgb)
_draw_color_bar(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
panel_style.draw_color_chip(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
if event["all_day"]:
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
else:
@@ -672,39 +670,60 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h
week_start: int, palette_rgb: list | None = None) -> Image.Image:
"""Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.
"Not in this month" day numbers used to be a muted gray -- now
de-emphasized by weight instead (Regular vs. Bold), same reasoning
as everywhere else this module dropped MUTED -- see module-level
comment above MARGIN/BG/FG."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
header_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)]
header_font = ImageFont.load_default(size=header_size)
day_font = ImageFont.load_default(size=day_size)
header_font = panel_style.font_bold(header_size)
day_font_in_month = panel_style.font_bold(day_size)
day_font_out_of_month = panel_style.font_regular(day_size)
today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
col_w = (target_w - MARGIN * 2) // 7
col_w = (cw - MARGIN * 2) // 7
header_h = 28
grid_top = MARGIN + header_h
row_h = (target_h - MARGIN - grid_top) // len(weeks)
grid_top = cy0 + MARGIN + header_h
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
today_accent = panel_style.theme_color("calendar", palette_rgb)
today_badge_r = min(panel_style.CHIP_RADIUS, 9)
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
for col, name in enumerate(day_names):
draw_text(img, (MARGIN + col * col_w + 6, MARGIN), name[:3], header_font, MUTED)
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font)
owners_seen: list[str] = []
dot_r = 6
for row, week in enumerate(weeks):
for col, day in enumerate(week):
x0 = MARGIN + col * col_w
x0 = cx0 + MARGIN + col * col_w
y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month
text_color = FG if in_month else MUTED
if day == today:
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_color)
# A filled accent badge (this widget's own theme color,
# see panel_style.THEME) instead of the old bare outline
# -- an actual "today" indicator, not just an outline
# easy to miss at ~24px. Sized around the actual digit
# bbox (not a fixed pixel box) so a bold 2-digit day
# number ("30") fits as comfortably as a single digit
# ("3") at every size tier.
day_str = str(day.day)
text_x, text_y = x0 + 6, y0 + 4
dbbox = draw.textbbox((text_x, text_y), day_str, font=day_font_in_month)
pad = 3
badge_rect = [dbbox[0] - pad, dbbox[1] - pad, dbbox[2] + pad, dbbox[3] + pad]
badge_r = min(today_badge_r, (badge_rect[3] - badge_rect[1]) // 2)
draw.rounded_rectangle(badge_rect, radius=badge_r, fill=today_accent)
draw_text(img, (text_x, text_y), day_str, day_font_in_month, BG)
else:
day_font = day_font_in_month if in_month else day_font_out_of_month
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font)
day_events = _events_on_day(events, day, tz)
dot_x = x0 + 8
@@ -718,7 +737,7 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
dot_x += dot_r * 2 + 5
if len(day_events) > 4:
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font, MUTED)
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font)
return img
@@ -759,8 +778,13 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
weather_cities, weather_units)
if fetch_summary:
font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11)
draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED)
# Drawn as a final overlay onto the already-composited img (not
# inside any one _build_* branch above), so it offsets by
# panel_style.GUTTER itself to land inside the same visible
# margin every builder's own content already respects.
font = panel_style.font_regular(14 if _size_tier(target_w, target_h) != "small" else 11)
draw_text(img, (panel_style.GUTTER + MARGIN, target_h - panel_style.GUTTER - MARGIN - font.size),
fetch_summary, font)
return img
@@ -815,12 +839,11 @@ def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: l
"""A tasks widget's entire region is the checklist -- unlike the old
week-view slot, there's no day columns/header to share space with,
so this is just _draw_tasks over the whole box."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
img, draw, region = panel_style.card_canvas(target_w, target_h)
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb, title)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
_draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title)
return img
+26 -5
View File
@@ -61,7 +61,8 @@ def _dotted_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1:
pos += spacing
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int]) -> None:
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int],
radius: int = 0) -> None:
"""Draws a border inset within img's own bounds, mutating it in
place -- called once per widget's own region (routers/device.py's
_render_widgets, and each widget type's own dialog preview) before
@@ -74,24 +75,44 @@ def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tupl
"solid"/"dashed"/"dotted" are a single thickness-px stroke traced
just inside the image's edge; "fancy" is two thinner concentric
strokes with a gap between them, picture-frame-mat style. "none" (or
a non-positive thickness) draws nothing."""
a non-positive thickness) draws nothing. `radius` is opt-in and only
honored by "solid"/"fancy" (rounded_rectangle instead of rectangle) --
"dashed"/"dotted" trace each of the 4 edges as independent straight
segments (see _dashed_edge/_dotted_edge) and ignore it, a documented
limitation rather than a bug. Defaults to 0 (unchanged sharp-corner
behavior) and no call site passes non-zero today -- this ships the
capability for a future per-widget "rounded border" setting without
changing default behavior anywhere (see tests/test_widget_border.py's
exact-corner-pixel assertions)."""
if style == "none" or thickness <= 0:
return
w, h = img.size
t = max(1, min(int(thickness), min(w, h) // 2))
draw = ImageDraw.Draw(img)
r = max(0, min(radius, (w - 1) // 2, (h - 1) // 2))
if style == "fancy":
line_t = max(1, t // 3)
gap = max(2, t - 2 * line_t)
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
if r:
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=line_t)
else:
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
inset = line_t + gap
if w - 2 * inset > 1 and h - 2 * inset > 1:
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
inner_r = max(0, min(r - inset, (w - 1 - 2 * inset) // 2, (h - 1 - 2 * inset) // 2)) if r else 0
if inner_r:
draw.rounded_rectangle([inset, inset, w - 1 - inset, h - 1 - inset], radius=inner_r,
outline=color, width=line_t)
else:
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
return
if style == "solid":
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
if r:
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=t)
else:
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
return
# dashed/dotted trace the same centered-on-the-edge path solid/
+33 -54
View File
@@ -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)
+184
View File
@@ -0,0 +1,184 @@
"""Shared visual language for everything drawn onto the e-ink panel
(excluding widgets/text.py, which already has its own richer multi-
family font picker and is left alone) -- spacing, ink-color resolution,
Inter font loading, and the small set of drawing primitives
(header bar, color chip, battery icon) more than one render module needs.
Centralizes what used to be independently redefined per render file
(calendar_render.py/weather_render.py each had their own MARGIN/BG/FG/
RULE, widgets/battery.py and manage_overlay.py each had their own
battery-glyph-drawing code) so the panel reads as one consistent system
instead of N separately-styled widgets. Still bound by the same hard
constraints as everything else that draws before the single whole-canvas
quantize/dither pass (see image_pipeline.py's module docstring/draw_text):
every fill here is one of DEFAULT_PALETTE_RGB's 6 exact colors, and text
always routes through image_pipeline.draw_text.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import DEFAULT_PALETTE_RGB
# Spacing scale. CONTENT_MARGIN carries over calendar_render.py/
# weather_render.py's own long-tuned MARGIN=20 value unchanged (not
# re-tuned -- every wrap/truncation-width calc in those modules was
# measured against it). GUTTER is new: the inset every widget applies
# within its own target_w x target_h box (see card_canvas) to get a
# visible seam between adjacent widgets without touching grid.py's
# zero-gap cell math.
GUTTER = 6
CONTENT_MARGIN = 20
CARD_RADIUS = 12
CHIP_RADIUS = 4
# Index constants into DEFAULT_PALETTE_RGB/a frame's own Frame.
# palette_rgb override -- same order as image_pipeline.PALETTE_LABELS.
BLACK, WHITE, YELLOW, RED, BLUE, GREEN = range(6)
# Which accent ink each widget kind's chrome (header bar, task checkbox,
# etc.) uses -- one dict, so "what color is a calendar header" has a
# single answer instead of being hardcoded separately everywhere a
# render module wants it. This is what makes a future global color
# theme *possible* without another pass through every render module: a
# per-frame override just needs to pick a different THEME mapping (or
# remap individual entries) here and resolve through theme_color/ink
# below, which already goes through a frame's own tuned Frame.
# palette_rgb -- swapping a slot's actual RGB (e.g. a custom "blue")
# already re-themes every widget that uses THEME_CALENDAR for its
# header, with no other code to touch. Weather deliberately maps to
# BLACK, not a color -- see weather_render's header call site -- so its
# own hand-drawn, already-colorful icons stay the star.
THEME_CALENDAR = BLUE
THEME_TASKS = GREEN
THEME_WEATHER = BLACK
THEME = {"calendar": THEME_CALENDAR, "tasks": THEME_TASKS, "weather": THEME_WEATHER}
def theme_color(widget_kind: str, palette_rgb: list | None = None) -> tuple[int, int, int]:
"""THEME[widget_kind] resolved against this frame's actual palette --
the one call every render module's header/accent chrome should go
through instead of hardcoding a palette index inline."""
return ink(palette_rgb, THEME[widget_kind])
def ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
index -- generalizes the same resolution idiom weather_render._ink/
calendar_render._event_colors already used locally, so a custom
palette override (Frame.palette_rgb) still gets its own actual
yellow/red/blue/green, and every fill stays an exact, ditherless
palette match either way."""
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
@lru_cache(maxsize=256)
def font_bold(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_FONT_DIR / "Inter-Bold.ttf"), size)
@lru_cache(maxsize=256)
def font_regular(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_FONT_DIR / "Inter-Regular.ttf"), size)
def card_canvas(target_w: int, target_h: int,
bg: tuple[int, int, int] = (255, 255, 255)) -> tuple:
"""A full target_w x target_h canvas filled with `bg`, plus the
GUTTER-inset rect (x0, y0, w, h) every widget should draw its actual
chrome/content within -- this is the whole mechanism behind the
gutter between widgets (see module docstring): the widget's render()
contract (exact target_w x target_h in, same size out, unchanged) is
what routers/device.py pastes and what draw_widget_border frames, so
a border still frames the widget's true full box; only the widget's
own drawing backs off from that box's true edge."""
img = Image.new("RGB", (target_w, target_h), bg)
draw = ImageDraw.Draw(img)
x0, y0 = GUTTER, GUTTER
w, h = max(1, target_w - 2 * GUTTER), max(1, target_h - 2 * GUTTER)
return img, draw, (x0, y0, w, h)
def _clamped_radius(radius: int, w: int, h: int) -> int:
return max(0, min(radius, w // 2, h // 2))
def draw_header_bar(draw: ImageDraw.ImageDraw, rect: tuple[int, int, int, int], height: int,
fill: tuple[int, int, int], radius: int = CARD_RADIUS) -> None:
"""A widget's title bar: rounded top corners only (corners=(tl, tr,
bl, br), the bottom pair left square) so it reads as a card's header
fused to the content below it, not a standalone pill floating with a
gap above its own body."""
x0, y0, w, h = rect
r = _clamped_radius(radius, w, height * 2)
draw.rounded_rectangle([x0, y0, x0 + w, y0 + height], radius=r, fill=fill,
corners=(True, True, False, False))
def draw_color_chip(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
colors: list[tuple[int, int, int]], radius: int = CHIP_RADIUS) -> None:
"""One rounded chip for a single-source event/task, or that same
footprint split into equal-width side-by-side segments -- one per
contributing calendar -- for a deduplicated shared event (see
calendar_render._event_colors/calendar_feed.merge_events). Splitting
rather than e.g. concentric rings keeps every color equally "thick
and bold" at a glance, the same design goal a single pinned color
already has. Generalizes calendar_render.py's old private
_draw_color_bar so the radius comes from one shared constant."""
if len(colors) == 1:
r = _clamped_radius(radius, x1 - x0, y1 - y0)
draw.rounded_rectangle([x0, y0, x1, y1], radius=r, fill=colors[0])
return
seg_w = (x1 - x0) / len(colors)
for i, color in enumerate(colors):
seg_x0 = round(x0 + i * seg_w)
seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)
def battery_fill_color(percent: int, palette_rgb: list | None = None) -> tuple[int, int, int]:
"""Red/yellow/green by charge level -- the fill itself carries the
"how worried should I be" signal, not just the number next to it.
Shared threshold logic for widgets/battery.py and manage_overlay.py,
which previously each defined the same three-tier thresholds twice."""
if percent <= 15:
return ink(palette_rgb, RED)
if percent <= 40:
return ink(palette_rgb, YELLOW)
return ink(palette_rgb, GREEN)
def draw_battery_icon(draw: ImageDraw.ImageDraw, x0: int, y0: int, icon_w: int, icon_h: int,
percent: int, palette_rgb: list | None = None) -> None:
"""A rounded battery glyph -- outline + charge-level fill + terminal
nub -- anchored at (x0, y0), the body's own top-left corner (the nub
extends past icon_w on the right). The one shared implementation
behind what used to be two separate ImageDraw glyphs: widgets/
battery.py's own icon+percent widget, and manage_overlay.py's compact
battery readout on the "scan to manage" overlay -- same shape, same
red/yellow/green thresholds, previously kept in sync by convention
rather than by sharing code."""
stroke = max(2, icon_h // 12)
nub_w = max(3, icon_w // 10)
nub_h = icon_h // 2
radius = _clamped_radius(icon_h // 6, icon_w, icon_h)
inner_x0, inner_y0 = x0 + stroke, y0 + stroke
inner_x1, inner_y1 = x0 + icon_w - stroke, y0 + icon_h - stroke
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (max(0, min(100, percent)) / 100))
if fill_x1 > inner_x0:
fill_radius = _clamped_radius(radius, fill_x1 - inner_x0, inner_y1 - inner_y0)
draw.rounded_rectangle([inner_x0, inner_y0, fill_x1, inner_y1], radius=fill_radius,
fill=battery_fill_color(percent, palette_rgb))
draw.rounded_rectangle([x0, y0, x0 + icon_w, y0 + icon_h], radius=radius, outline=(0, 0, 0), width=stroke)
nub_y = y0 + (icon_h - nub_h) // 2
nub_radius = _clamped_radius(max(1, nub_w // 3), nub_w, nub_h)
draw.rounded_rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], radius=nub_radius,
fill=(0, 0, 0))
+75 -63
View File
@@ -33,23 +33,27 @@ from datetime import date, datetime
from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, draw_text, logical_render_size
from . import panel_style
from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size
MARGIN = 20
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
# re-tuned -- every column-width/icon-size calc below was measured
# against 20px). BG/FG are this module's own cloud-icon fill/outline and
# fog-line color (see draw_cloud/draw_weather_icon), not a text-emphasis
# concern -- those live in panel_style (font_bold/font_regular, no MUTED
# gray -- see its module docstring for why).
MARGIN = panel_style.CONTENT_MARGIN
BG = (255, 255, 255)
FG = (0, 0, 0)
MUTED = (110, 110, 110)
RULE = (0, 0, 0)
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
already this module's BG/FG) -- same resolution idiom as
calendar_render.py's _event_colors, so a custom palette override
(Frame.palette_rgb) still gets its own actual yellow/blue, and every
fill stays an exact, ditherless palette match either way."""
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
already this module's BG/FG) -- thin wrapper over panel_style.ink
(which generalized this same resolution idiom), kept so every
draw_weather_icon call site below doesn't need touching."""
return panel_style.ink(palette_rgb, index)
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
@@ -203,14 +207,17 @@ def _format_hour_label(iso_time: str) -> str:
def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int,
min_size: int = 9) -> int:
min_size: int = 9, font_loader=panel_style.font_bold) -> int:
"""Largest size <= max_size at which every string in `texts` fits
within max_width -- used to size a per-column label/temp font against
the actual column width instead of an icon-radius-derived guess,
which (e.g. "Tomorrow" vs. "Wed") let long labels overlap into the
next column at a large icon size on a narrow column."""
next column at a large icon size on a narrow column. Measured against
`font_loader` (default Inter Bold -- the wider of the two weights a
column actually mixes, a label in Regular and a temp in Bold, so
fitting against Bold keeps both safely inside max_width)."""
for size in range(max_size, min_size - 1, -1):
font = ImageFont.load_default(size=size)
font = font_loader(size)
if all(draw.textlength(t, font=font) <= max_width for t in texts):
return size
return min_size
@@ -232,29 +239,28 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb:
(callers normally catch that earlier and show a placeholder instead,
but this degrades to a blank canvas rather than erroring either
way)."""
img = Image.new("RGB", (target_w, target_h), BG)
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
if not entry:
return img
draw = ImageDraw.Draw(img)
icon_r = max(20, min(target_w, target_h) // 4)
cx, cy = target_w // 2, target_h // 2 - icon_r // 2
icon_r = max(20, min(cw, ch) // 4)
cx, cy = cx0 + cw // 2, cy0 + ch // 2 - icon_r // 2
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
temp_size = max(24, min(target_w, target_h) // 3)
temp_font = ImageFont.load_default(size=temp_size)
temp_size = max(24, min(cw, ch) // 3)
temp_font = panel_style.font_bold(temp_size)
temp_text = f"{round(entry['temp'])}°{unit_suffix}"
bbox = draw.textbbox((0, 0), temp_text, font=temp_font)
temp_y = cy + icon_r + 12
draw_text(img, (target_w // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)
draw_text(img, (cx0 + cw // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)
if city_label:
label_size = max(12, temp_size // 3)
label_font = ImageFont.load_default(size=label_size)
label_font = panel_style.font_regular(label_size)
lbbox = draw.textbbox((0, 0), city_label, font=label_font)
draw_text(img, (target_w // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
city_label, label_font, MUTED)
draw_text(img, (cx0 + cw // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
city_label, label_font)
return img
@@ -265,19 +271,23 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb:
fetch_hourly), each showing an hour label, icon, and temp. Same
"draw however many fit" graceful degradation as draw_weather_row if
the box is too narrow for every tick."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
text_x0 = MARGIN
text_w = target_w - MARGIN * 2
y = MARGIN
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
text_x0 = cx0 + MARGIN
text_w = cw - MARGIN * 2
y = cy0 + MARGIN
title_size = max(14, min(target_w, target_h) // 16)
title_size = max(14, min(cw, ch) // 16)
if city_label:
title_font = ImageFont.load_default(size=title_size)
draw_text(img, (text_x0, y), city_label, title_font)
y += title_size + 10
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
# A filled header bar (this widget's chosen accent is black, not
# a color, so the hand-drawn icons below stay the star -- see
# panel_style module docstring) replaces the old plain title +
# thin rule line.
header_h = title_size + 20
panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
panel_style.theme_color("weather", palette_rgb))
title_font = panel_style.font_bold(title_size)
draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG)
y = cy0 + header_h + 12
# Capped to however many columns actually fit at a legible width
# (narrower widgets/smaller intervals just show fewer ticks) rather
@@ -290,23 +300,24 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb:
if not ticks:
return img
col_w = max(1, text_w // len(ticks))
icon_r = max(10, min(col_w // 3, (target_h - y - MARGIN) // 4))
icon_r = max(10, min(col_w // 3, (cy0 + ch - y - MARGIN) // 4))
unit_suffix = "F" if units == "fahrenheit" else "C"
time_labels = [_format_hour_label(e["time"]) for e in ticks]
temp_labels = [f"{round(e['temp'])}°{unit_suffix}" for e in ticks]
label_size = _fit_font_size(draw, time_labels + temp_labels, col_w - 6, max_size=icon_r)
label_font = ImageFont.load_default(size=label_size)
time_font = panel_style.font_regular(label_size)
temp_font = panel_style.font_bold(label_size)
for i, entry in enumerate(ticks):
cx = text_x0 + i * col_w + col_w // 2
time_label = time_labels[i]
tbbox = draw.textbbox((0, 0), time_label, font=label_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED)
tbbox = draw.textbbox((0, 0), time_label, font=time_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, time_font)
cy = y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
temp_label = temp_labels[i]
tempbbox = draw.textbbox((0, 0), temp_label, font=label_font)
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font)
tempbbox = draw.textbbox((0, 0), temp_label, font=temp_font)
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, temp_font)
return img
@@ -317,40 +328,41 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
configured day count by app/weather's provider fetch_daily -- this
just draws whatever it's handed, same "stop once it doesn't fit"
graceful degradation as draw_weather_row)."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
text_x0 = MARGIN
text_w = target_w - MARGIN * 2
y = MARGIN
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
text_x0 = cx0 + MARGIN
text_w = cw - MARGIN * 2
y = cy0 + MARGIN
title_size = max(14, min(target_w, target_h) // 16)
title_size = max(14, min(cw, ch) // 16)
if city_label:
title_font = ImageFont.load_default(size=title_size)
draw_text(img, (text_x0, y), city_label, title_font)
y += title_size + 10
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
header_h = title_size + 20
panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
panel_style.theme_color("weather", palette_rgb))
title_font = panel_style.font_bold(title_size)
draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG)
y = cy0 + header_h + 12
days = list(daily.items())
if not days:
return img
col_w = max(1, text_w // len(days))
icon_r = max(12, min(col_w // 3, (target_h - y - MARGIN) // 4))
icon_r = max(12, min(col_w // 3, (cy0 + ch - y - MARGIN) // 4))
unit_suffix = "F" if units == "fahrenheit" else "C"
labels = [_day_label(date.fromisoformat(day_str)) for day_str, _ in days]
temps_strs = [f"{round(d['high'])}°/{round(d['low'])}°{unit_suffix}" for _, d in days]
label_size = _fit_font_size(draw, labels + temps_strs, col_w - 6, max_size=icon_r)
label_font = ImageFont.load_default(size=label_size)
label_font = panel_style.font_regular(label_size)
temp_font = panel_style.font_bold(label_size)
for i, (_, d) in enumerate(days):
x0 = text_x0 + i * col_w
label, temps = labels[i], temps_strs[i]
lbbox = draw.textbbox((0, 0), label, font=label_font)
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED)
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font)
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
tbbox = draw.textbbox((0, 0), temps, font=label_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font)
tbbox = draw.textbbox((0, 0), temps, font=temp_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, temp_font)
return img
@@ -360,10 +372,9 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
reuses draw_weather_row (the same layout calendar_render.py's
embedded strip uses), just as the whole widget's own content instead
of a strip above an agenda day."""
img = Image.new("RGB", (target_w, target_h), BG)
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
if not cities:
return img
draw = ImageDraw.Draw(img)
# Just the city name on-panel ("Portland", not the full disambiguated
# "Portland, Oregon, United States") -- that fuller form matters for
# telling apart geocoder candidates when adding a city (see
@@ -372,7 +383,7 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
# calendar_render.py's _weather_for_day already does for its own
# embedded strip.
cities = [{**c, "label": c["label"].split(",")[0].strip()} for c in cities]
text_w = target_w - MARGIN * 2
text_w = cw - MARGIN * 2
# Sized against how many entries actually need to fit side by side,
# not just the box's height -- an icon/font picked from target_h
# alone (as this used to do) drew each entry so wide that only the
@@ -380,13 +391,14 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
# doesn't fit" degradation silently dropped every city after it,
# even in an ordinary-sized widget with plenty of cities configured.
col_w = max(1, text_w // len(cities))
icon_r = max(10, min(col_w // 6, target_h // 6, 40))
icon_r = max(10, min(col_w // 6, ch // 6, 40))
unit_suffix = "F" if units == "fahrenheit" else "C"
labels = [f"{c['label']} {round(c['high'])}°/{round(c['low'])}°{unit_suffix}" for c in cities]
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r)
font = ImageFont.load_default(size=font_size)
y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2)
draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r,
font_loader=panel_style.font_regular)
font = panel_style.font_regular(font_size)
y = max(cy0 + MARGIN, cy0 + (ch - (icon_r * 2 + 8)) // 2)
draw_weather_row(img, draw, cx0 + MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
show_labels=True, palette_rgb=palette_rgb)
return img
+7 -5
View File
@@ -7,17 +7,19 @@ fraction of the panel, so its placeholder needs to scale down with it.
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageDraw
_BG = (245, 245, 245)
_FG = (90, 90, 90)
from .. import panel_style
from ..image_pipeline import draw_text
_BG = (255, 255, 255)
def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image:
img = Image.new("RGB", (target_w, target_h), _BG)
draw = ImageDraw.Draw(img)
font_size = max(10, min(20, target_h // 8))
font = ImageFont.load_default(size=font_size)
font = panel_style.font_regular(font_size)
line_h = font_size + 4
total_h = line_h * len(lines)
y = max(4, (target_h - total_h) // 2)
@@ -25,6 +27,6 @@ def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.I
bbox = draw.textbbox((0, 0), line, font=font)
line_w = bbox[2] - bbox[0]
x = max(4, (target_w - line_w) // 2)
draw.text((x, y), line, fill=_FG, font=font)
draw_text(img, (x, y), line, font)
y += line_h
return img
+26 -50
View File
@@ -17,10 +17,11 @@ from __future__ import annotations
import io
import time
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageDraw
from sqlalchemy.orm import Session
from ..image_pipeline import DEFAULT_PALETTE_RGB, _quantize, draw_text, logical_render_size
from .. import panel_style
from ..image_pipeline import _quantize, draw_text, logical_render_size
from ..models import BatteryWidgetConfig, Frame, Widget
from ..routers.common import battery_estimate_s
from ._shared import placeholder_image
@@ -28,45 +29,16 @@ from ._shared import placeholder_image
ACTIONS: dict = {}
ACTION_LABELS: dict[str, str] = {}
BG = (255, 255, 255)
MUTED = (110, 110, 110)
# Same thresholds/colors as manage_overlay.py's own battery glyph (not
# shared code -- that one draws onto the manage-QR overlay in a fixed
# small size, this one fills an arbitrary widget region -- but the
# "how worried should I be" color story should read the same wherever a
# battery glyph shows up on a panel). Exact panel ink RGB values, not
# arbitrary reds/yellows/greens -- a flat fill already at a palette
# color quantizes with zero dithering error once the whole composited
# canvas gets quantized, where an off-palette color would dither into a
# visible speckle at these small on-panel sizes.
_LOW = DEFAULT_PALETTE_RGB[3] # red
_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
_HIGH = DEFAULT_PALETTE_RGB[5] # green
def _fill_color(percent: int) -> tuple[int, int, int]:
if percent <= 15:
return _LOW
if percent <= 40:
return _MEDIUM
return _HIGH
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int) -> None:
stroke = max(2, icon_h // 12)
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int,
palette_rgb: list | None = None) -> None:
"""Centers panel_style.draw_battery_icon (top-left-anchored) under
`cx` -- this widget's own layout picks a center point, that helper's
shared implementation (also used by manage_overlay.py's battery
readout) just needs a top-left corner."""
nub_w = max(3, icon_w // 10)
nub_h = icon_h // 2
x0 = cx - (icon_w + nub_w) // 2
y0 = top
inner_x0, inner_y0 = x0 + stroke, y0 + stroke
inner_x1, inner_y1 = x0 + icon_w - stroke, y0 + icon_h - stroke
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (max(0, min(100, percent)) / 100))
if fill_x1 > inner_x0:
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_fill_color(percent))
draw.rectangle([x0, y0, x0 + icon_w, y0 + icon_h], outline=(0, 0, 0), width=stroke)
nub_y = y0 + (icon_h - nub_h) // 2
draw.rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], fill=(0, 0, 0))
panel_style.draw_battery_icon(draw, x0, top, icon_w, icon_h, percent, palette_rgb)
def _format_estimate(seconds: float) -> str:
@@ -99,22 +71,26 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
cfg = db.get(BatteryWidgetConfig, widget.id)
mode = cfg.mode if cfg else "detailed"
palette_rgb = frame.palette_rgb
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
cx = target_w // 2
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
cx = cx0 + cw // 2
icon_h = max(20, min(target_w, target_h) // 3)
icon_h = max(20, min(cw, ch) // 3)
icon_w = int(icon_h * 1.8)
icon_top = max(4, target_h // 8)
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent)
icon_top = max(4, cy0 + ch // 8)
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent, palette_rgb)
pct_font_size = max(18, min(target_w, target_h) // 3)
pct_font = ImageFont.load_default(size=pct_font_size)
# The percent number picks up the icon's own charge-level color
# (red/yellow/green) instead of plain black -- ties the two into one
# visual statement rather than "colored icon, black number".
pct_font_size = max(18, min(cw, ch) // 3)
pct_font = panel_style.font_bold(pct_font_size)
pct_text = f"{percent}%"
bbox = draw.textbbox((0, 0), pct_text, font=pct_font)
pct_y = icon_top + icon_h + 10
draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font)
draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font,
panel_style.battery_fill_color(percent, palette_rgb))
if mode == "detailed":
lines = []
@@ -125,13 +101,13 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
small_font_size = max(11, pct_font_size // 3)
small_font = ImageFont.load_default(size=small_font_size)
small_font = panel_style.font_regular(small_font_size)
y = pct_y + pct_font_size + 12
for line in lines:
if y + small_font_size > target_h - 4:
if y + small_font_size > cy0 + ch - 4:
break
lbbox = draw.textbbox((0, 0), line, font=small_font)
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font, MUTED)
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font)
y += small_font_size + 6
return img