New widget type with four display modes -- current conditions, an hourly forecast strip, a multi-day forecast, and several cities' current day side by side -- backed by a pluggable provider registry (app/weather/, mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and NWS (US-only) both wired up now, Environment Canada documented as the next one to add given its more involved station/grid-lookup API. The calendar widget's existing embedded weather strip is untouched and still Open-Meteo-only; this lifts the same underlying icon-drawing primitives (now shared via app/weather_render.py, calendar_render.py still imports draw_weather_row unchanged) into a widget that can be placed and sized on its own. Icons are redrawn in the panel's actual ink colors (yellow sun/bolt, blue rain/snow) instead of flat black, and build_multi_city's icon/font sizing now scales with how many cities need to fit rather than the box's height alone -- both fixed after catching them via live browser verification, along with a mode-switch cache-shape crash and a mobile-width dialog overflow. New WeatherWidgetConfig table (migration 24), grid footprint, widget module, common.py fetch/cache helper, router endpoints (location set/ clear, city add/remove, preview), dialog template + JS, and full test coverage (providers, widget render, HTTP endpoints, migration replay). docs/widgets.md and CLAUDE.md's TODO updated accordingly.
400 lines
19 KiB
Python
400 lines
19 KiB
Python
"""Weather icon-drawing primitives (draw_cloud/draw_weather_icon/
|
|
draw_weather_row -- extracted out of calendar_render.py, which still
|
|
imports draw_weather_row for its own embedded weather strip, unchanged)
|
|
plus the standalone weather widget's four per-mode renderers
|
|
(build_current/build_hourly/build_daily/build_multi_city, dispatched by
|
|
build()) and its preview-PNG wrapper -- the weather analogue of
|
|
calendar_render.py's own _build_tasks/render_tasks_preview_png
|
|
relationship.
|
|
|
|
Every build_* function takes already-normalized data (see app/weather/'s
|
|
provider modules -- a `category` key from the shared clear/partly_cloudy/
|
|
cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and
|
|
returns an RGB Image exactly target_w x target_h, same contract every
|
|
other widget renderer in this project follows.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import math
|
|
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
|
|
|
|
MARGIN = 20
|
|
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, not a
|
|
hardcoded RGB triple."""
|
|
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
|
|
|
|
|
|
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
|
|
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
|
|
with a clean outline -- drawn as one outline-color pass slightly
|
|
larger than the shapes, then the same shapes again in `fill` on top.
|
|
Overlapping ellipses each drawn with their own `outline=` would leave
|
|
visible seams where they cross; this double-draw trick sidesteps that
|
|
entirely regardless of how the lobes overlap."""
|
|
stroke = 2
|
|
lobes = [
|
|
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
|
|
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
|
|
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
|
|
]
|
|
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
|
|
for x0, y0, x1, y1 in lobes:
|
|
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=outline)
|
|
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=outline)
|
|
for x0, y0, x1, y1 in lobes:
|
|
draw.ellipse([x0, y0, x1, y1], fill=fill)
|
|
draw.rectangle(base, fill=fill)
|
|
|
|
|
|
def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None:
|
|
"""A filled disc + 8 thick radiating rays -- the standard weather-app
|
|
"sun" glyph, in the panel's actual yellow ink rather than a bare
|
|
outline (a plain circle-with-4-ticks at small sizes read as a
|
|
crosshair/target, not a sun)."""
|
|
draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color)
|
|
for i in range(8):
|
|
angle = i * (math.pi / 4)
|
|
dx, dy = math.cos(angle), math.sin(angle)
|
|
draw.line([(cx + dx * r * 0.68, cy + dy * r * 0.68), (cx + dx * r * 1.05, cy + dy * r * 1.05)],
|
|
fill=color, width=max(2, round(r * 0.14)))
|
|
|
|
|
|
def draw_raindrop(draw: ImageDraw.ImageDraw, x: float, y: float, size: float, color) -> None:
|
|
"""A rounded teardrop (point up, bulb down) -- the standard rain
|
|
glyph, not a bare diagonal tick."""
|
|
draw.polygon([(x, y), (x - size * 0.38, y + size * 0.55), (x + size * 0.38, y + size * 0.55)], fill=color)
|
|
draw.ellipse([x - size * 0.4, y + size * 0.25, x + size * 0.4, y + size * 1.05], fill=color)
|
|
|
|
|
|
def draw_snowflake(draw: ImageDraw.ImageDraw, x: float, y: float, r: float, color) -> None:
|
|
"""A 6-pointed asterisk -- the standard snowflake glyph."""
|
|
for i in range(3):
|
|
angle = i * (math.pi / 3)
|
|
dx, dy = math.cos(angle) * r, math.sin(angle) * r
|
|
draw.line([(x - dx, y - dy), (x + dx, y + dy)], fill=color, width=max(2, round(r * 0.3)))
|
|
|
|
|
|
def draw_lightning_bolt(draw: ImageDraw.ImageDraw, cx: float, cy: float, size: float, color) -> None:
|
|
"""A zigzag bolt polygon -- the standard lightning glyph, not a bare
|
|
3-segment line."""
|
|
points = [
|
|
(cx + size * 0.15, cy - size * 0.7),
|
|
(cx - size * 0.35, cy + size * 0.05),
|
|
(cx - size * 0.05, cy + size * 0.05),
|
|
(cx - size * 0.2, cy + size * 0.7),
|
|
(cx + size * 0.4, cy - size * 0.1),
|
|
(cx + size * 0.05, cy - size * 0.1),
|
|
]
|
|
draw.polygon(points, fill=color)
|
|
|
|
|
|
def draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str,
|
|
palette_rgb: list | None = None) -> None:
|
|
"""A small glyph for one weather category -- no custom font/icon
|
|
asset (hand-primitives only, same approach calendar_render.py uses
|
|
elsewhere for e.g. month view's density dots), but drawn in the
|
|
panel's own ink colors (yellow sun/bolt, blue rain/snow) rather than
|
|
flat black -- a plain monochrome outline at small sizes read as
|
|
abstract shapes (a "sun" that looked like a crosshair), not
|
|
recognizable weather icons."""
|
|
yellow = _ink(palette_rgb, 2)
|
|
blue = _ink(palette_rgb, 4)
|
|
|
|
if category == "clear":
|
|
draw_sun(draw, cx, cy, r, yellow)
|
|
return
|
|
|
|
if category == "partly_cloudy":
|
|
draw_sun(draw, cx - r * 0.45, cy - r * 0.45, r * 0.75, yellow)
|
|
draw_cloud(draw, cx + r * 0.1, cy + r * 0.2, r * 0.9)
|
|
return
|
|
|
|
cloud_cy = cy if category in ("cloudy", "fog") else cy - r * 0.25
|
|
draw_cloud(draw, cx, cloud_cy, r)
|
|
|
|
if category == "fog":
|
|
for i in range(3):
|
|
y = cy + r * 0.55 + i * (r * 0.4)
|
|
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
|
|
elif category == "rain":
|
|
for dx in (-0.55, 0, 0.55):
|
|
draw_raindrop(draw, cx + dx * r, cloud_cy + r * 0.55, r * 0.55, blue)
|
|
elif category == "snow":
|
|
for dx in (-0.55, 0, 0.55):
|
|
draw_snowflake(draw, cx + dx * r, cloud_cy + r * 0.85, r * 0.3, blue)
|
|
elif category == "thunderstorm":
|
|
draw_lightning_bolt(draw, cx, cloud_cy + r * 0.7, r * 0.7, yellow)
|
|
|
|
|
|
def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
|
|
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
|
|
show_labels: bool = True, palette_rgb: list | None = None) -> int:
|
|
"""Draws one or more cities' weather side by side starting at
|
|
(x0, y0), stopping once another entry wouldn't fit within max_w
|
|
(narrow views like week columns just end up showing fewer cities --
|
|
same graceful-degradation approach month view takes with density
|
|
dots). Returns the row height consumed (0 if there was nothing to
|
|
draw, so callers can skip reserving space entirely)."""
|
|
if not entries:
|
|
return 0
|
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
|
row_h = icon_r * 2 + 8
|
|
x = x0
|
|
drew_any = False
|
|
for entry in entries:
|
|
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
|
|
label = f"{entry['label']} {temps}" if show_labels else temps
|
|
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
|
|
if drew_any and x + entry_w > x0 + max_w:
|
|
break
|
|
cx, cy = x + icon_r, y0 + icon_r
|
|
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
|
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
|
|
x += entry_w
|
|
drew_any = True
|
|
return row_h + 6
|
|
|
|
|
|
# --- Standalone weather widget (app/widgets/weather.py) -----------------
|
|
|
|
def _format_hour_label(iso_time: str) -> str:
|
|
dt = datetime.fromisoformat(iso_time)
|
|
text = dt.strftime("%I %p").lstrip("0")
|
|
return text if text else "12 AM"
|
|
|
|
|
|
def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int,
|
|
min_size: int = 9) -> 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."""
|
|
for size in range(max_size, min_size - 1, -1):
|
|
font = ImageFont.load_default(size=size)
|
|
if all(draw.textlength(t, font=font) <= max_width for t in texts):
|
|
return size
|
|
return min_size
|
|
|
|
|
|
def _day_label(day_date: date) -> str:
|
|
delta = (day_date - date.today()).days
|
|
if delta == 0:
|
|
return "Today"
|
|
if delta == 1:
|
|
return "Tomorrow"
|
|
return day_date.strftime("%a")
|
|
|
|
|
|
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
|
|
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
|
"""One big icon + big temp number + (optional) city label, centered --
|
|
entry is {"temp", "category"} or None if nothing's been fetched yet
|
|
(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)
|
|
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
|
|
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_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)
|
|
|
|
if city_label:
|
|
label_size = max(12, temp_size // 3)
|
|
label_font = ImageFont.load_default(size=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)
|
|
return img
|
|
|
|
|
|
def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
|
units: str = "fahrenheit", interval_hours: int = 4, city_label: str = "") -> Image.Image:
|
|
"""A row of ticks across the day, one every `interval_hours` hours
|
|
(entries is always 1-hour resolution -- see app/weather's provider
|
|
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
|
|
|
|
title_size = max(14, min(target_w, target_h) // 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
|
|
|
|
# Capped to however many columns actually fit at a legible width
|
|
# (narrower widgets/smaller intervals just show fewer ticks) rather
|
|
# than cramming every sampled tick in regardless of how narrow that
|
|
# makes each one -- same graceful-degradation idiom as
|
|
# draw_weather_row's own per-pixel-width stopping point.
|
|
min_col_w = 46
|
|
max_ticks = max(1, text_w // min_col_w)
|
|
ticks = entries[::max(1, interval_hours)][:max_ticks]
|
|
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))
|
|
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)
|
|
|
|
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)
|
|
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)
|
|
return img
|
|
|
|
|
|
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
|
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
|
"""A day-by-day strip (day label, icon, high/low), however many days
|
|
fit `target_w` (`daily` is already clamped to the widget's own
|
|
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
|
|
|
|
title_size = max(14, min(target_w, target_h) // 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
|
|
|
|
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))
|
|
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)
|
|
|
|
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)
|
|
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)
|
|
return img
|
|
|
|
|
|
def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
|
units: str = "fahrenheit") -> Image.Image:
|
|
"""Several cities' current-day high/low/icon side by side -- directly
|
|
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)
|
|
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
|
|
# weather.geocode_city, and the dialog's own "Cities" management
|
|
# list), not for a compact display row. Same shortening
|
|
# 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
|
|
# 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
|
|
# first city ever fit, and draw_weather_row's own "stop once it
|
|
# 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))
|
|
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,
|
|
show_labels=True, palette_rgb=palette_rgb)
|
|
return img
|
|
|
|
|
|
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
|
|
units: str = "fahrenheit", city_label: str = "", interval_hours: int = 4) -> Image.Image:
|
|
"""Dispatches to the right build_* function for this widget's
|
|
configured mode -- shared by app/widgets/weather.py's render() and
|
|
render_weather_preview_png below, so the two never drift apart."""
|
|
if mode == "current":
|
|
return build_current(data, target_w, target_h, palette_rgb, units, city_label)
|
|
if mode == "hourly":
|
|
return build_hourly(data, target_w, target_h, palette_rgb, units, interval_hours, city_label)
|
|
if mode == "daily":
|
|
return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
|
|
if mode == "multi_city":
|
|
return build_multi_city(data, target_w, target_h, palette_rgb, units)
|
|
return Image.new("RGB", (target_w, target_h), BG) # unreachable via a valid config -- see WeatherWidgetConfig.mode
|
|
|
|
|
|
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
|
|
units: str = "fahrenheit", manage: dict | None = None,
|
|
city_label: str = "", interval_hours: int = 4) -> bytes:
|
|
"""Same pipeline as calendar_render.render_tasks_preview_png -- a
|
|
normal browser-viewable PNG in logical (upright) orientation."""
|
|
target_w, target_h = logical_render_size(orientation)
|
|
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours)
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
buf = io.BytesIO()
|
|
quantized.convert("RGB").save(buf, format="PNG")
|
|
return buf.getvalue()
|