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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user