Revert to hand-drawn weather icons, styled after EC's set but exact panel colors
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 49s

The vendored EC bitmaps looked good but dither into a visible speckle
once quantized to the panel's 6-color palette (their colors are
anti-aliased/arbitrary RGB, essentially never an exact palette match).
Hand-drawn icons filled with the frame's actual ink colors quantize with
zero dithering error to diffuse -- confirmed by running both through the
real quantize pass: the bitmap version speckles, the hand-drawn one is
pixel-identical before and after.

Redrawn to look more like EC's style this time around: pointed
triangular sun rays (the earlier attempt's thin-line rays read as a
crosshair, not a sun) and dendrite snowflakes (tick marks near each tip,
not a bare asterisk), plus the same cloud/raindrop/lightning-bolt shapes
as before. Removed the vendored server/app/weather_icons/ directory
entirely -- no longer used, and removes the icon-image licensing
question along with it.
This commit is contained in:
2026-07-27 17:32:51 +00:00
parent efb0f2e22d
commit 90a014d161
12 changed files with 217 additions and 119 deletions
+11 -5
View File
@@ -253,11 +253,17 @@ site, however far" matched a Miami, FL query to a site in Ontario,
embedded strip, unchanged) plus this widget's own `build_current`/ embedded strip, unchanged) plus this widget's own `build_current`/
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()` `build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/ -- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
`render_tasks_preview_png` relationship. Icons are vendored bitmaps (`app/ `render_tasks_preview_png` relationship. Icons are hand-drawn (no custom
weather_icons/`, one per shared category, sourced from Environment font/icon asset), styled after Environment Canada's own icon set
Canada's own weather icon set -- see that directory's `SOURCE.md` for (pointed sun rays, a puffy cloud, teardrop rain, dendrite snowflakes, a
attribution/licensing), not hand-drawn, and used for every provider's zigzag bolt) but filled with the panel's *exact* ink RGB values rather
rendering, not just when EC is selected as the provider. than an arbitrary bitmap's anti-aliased colors -- a flat fill that's
already a palette color quantizes with zero dithering error to diffuse,
where a fetched/vendored icon's colors (almost never an exact match)
dither into a visible speckle at these small on-panel sizes (confirmed
by actually running one through the real quantize pass during
development). Used for every provider's rendering, not just when EC is
selected as the provider.
## Known gaps (Phase 6, not yet done) ## Known gaps (Phase 6, not yet done)
+4 -2
View File
@@ -414,7 +414,8 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
weather_entries = _weather_for_day(weather_cities, day) weather_entries = _weather_for_day(weather_cities, day)
if weather_entries: if weather_entries:
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries, y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units) icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
palette_rgb=palette_rgb)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
row_h = body_font.size + 14 row_h = body_font.size + 14
@@ -636,7 +637,8 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
weather_entries = _weather_for_day(weather_cities, day) weather_entries = _weather_for_day(weather_cities, day)
if weather_entries: if weather_entries:
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries, y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
icon_r=8, font=weather_font, units=weather_units, show_labels=False) icon_r=8, font=weather_font, units=weather_units, show_labels=False,
palette_rgb=palette_rgb)
row_h = chip_font.size + 10 row_h = chip_font.size + 10
max_rows = max(0, (target_h - MARGIN - y) // row_h) max_rows = max(0, (target_h - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
-34
View File
@@ -1,34 +0,0 @@
# Weather icons
Source: Environment and Climate Change Canada's public weather icon set,
served from `https://weather.gc.ca/weathericons/`.
© His Majesty the King in Right of Canada, as represented by the Minister
of Environment and Climate Change Canada. Vendored (not fetched live at
render time) under Canada.ca's non-commercial-reproduction terms
(https://www.canada.ca/en/transparency/terms.html) -- this is a personal,
non-commercial project. If this project's use ever becomes commercial,
these specific image files need their own clearance (the ECCC Data
Servers End-use Licence that covers the weather *data* app/weather/ec.py
consumes is more permissive, but that licence is scoped to ECCC's data
servers -- these icons are served from the public website, not that
domain, so it doesn't clearly cover them).
One icon per shared weather category (see app/weather/__init__.py's
module docstring for the category set), picked as a representative
"day" variant from EC's full icon set
(https://weather.gc.ca/mainmenu/icon_e.html has the legend):
| file | EC icon code | original URL |
|---------------------|:------------:|--------------------------------------------------|
| clear.gif | 00 | https://weather.gc.ca/weathericons/00.gif |
| partly_cloudy.gif | 02 | https://weather.gc.ca/weathericons/02.gif |
| cloudy.gif | 10 | https://weather.gc.ca/weathericons/10.gif |
| fog.gif | 24 | https://weather.gc.ca/weathericons/24.gif |
| rain.gif | 12 | https://weather.gc.ca/weathericons/12.gif |
| snow.gif | 17 | https://weather.gc.ca/weathericons/17.gif |
| thunderstorm.gif | 19 | https://weather.gc.ca/weathericons/19.gif |
Used for every weather provider's rendering (Open-Meteo, NWS, and EC
itself), not just when EC is the selected provider -- see
app/weather_render.py's draw_weather_icon.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

+138 -38
View File
@@ -12,25 +12,28 @@ 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 returns an RGB Image exactly target_w x target_h, same contract every
other widget renderer in this project follows. other widget renderer in this project follows.
Icons are Environment Canada's own weather icon set (vendored at Icons are hand-drawn (no custom font/icon asset, same primitives-only
app/weather_icons/, one per shared category -- see that directory's approach calendar_render.py uses elsewhere for e.g. month view's density
SOURCE.md for attribution/licensing), not hand-drawn: these are small, dots), styled after Environment Canada's own icon set (pointed sun rays,
flat-shaded bitmaps that dither cleanly onto the panel's 6-color a smooth puffy cloud, teardrop rain, dendrite snowflakes, a zigzag bolt)
palette, and read as recognizable weather icons at a glance in a way an but filled with this frame's *exact* panel ink RGB values rather than an
earlier hand-drawn attempt (a plain circle-with-ticks "sun") didn't. arbitrary bitmap's anti-aliased colors -- a flat fill that's already one
Used for every provider's rendering, not just when EC is selected. of the palette's 6 colors quantizes with zero dithering error to diffuse,
where a fetched/vendored bitmap's colors (almost never an exact palette
match) dither into a visible speckle. An early plain circle-with-4-ticks
"sun" also just didn't read as a sun at a glance -- pointed triangular
rays fixed that without giving up the clean-quantization property.
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import math
from datetime import date, datetime from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size from .image_pipeline import DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, draw_text, logical_render_size
MARGIN = 20 MARGIN = 20
BG = (255, 255, 255) BG = (255, 255, 255)
@@ -38,37 +41,133 @@ FG = (0, 0, 0)
MUTED = (110, 110, 110) MUTED = (110, 110, 110)
RULE = (0, 0, 0) RULE = (0, 0, 0)
_ICON_DIR = Path(__file__).parent / "weather_icons"
_ICON_FALLBACK = "cloudy" # unrecognized category (shouldn't happen with a valid provider) -- see draw_weather_icon 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])
@lru_cache(maxsize=None) def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
def _icon_asset(category: str) -> Image.Image: """A simple puffy-cloud silhouette (three overlapping lobes + a base)
"""This category's vendored icon, loaded once (a handful of small with a clean outline -- drawn as one outline-color pass slightly
static files, never fetched over the network at render time) and larger than the shapes, then the same shapes again in `fill` on top.
cached at its native resolution -- draw_weather_icon resizes a copy Overlapping ellipses each drawn with their own `outline=` would leave
per call, since the requested size varies with icon_r.""" visible seams where they cross; this double-draw trick sidesteps that
path = _ICON_DIR / f"{category}.gif" entirely regardless of how the lobes overlap."""
if not path.exists(): stroke = 2
path = _ICON_DIR / f"{_ICON_FALLBACK}.gif" # unrecognized category -- shouldn't happen with a valid provider lobes = [
return Image.open(path).convert("RGBA") (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_weather_icon(img: Image.Image, cx: float, cy: float, r: float, category: str) -> None: def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None:
"""Pastes this category's icon (see module docstring), scaled to """A filled disc + 8 pointed triangular rays -- styled after
roughly 2r wide, centered on (cx, cy). Alpha-composited via the Environment Canada's own sun glyph. Rays are solid triangles (base on
icon's own transparency (confirmed present in the source GIFs), so the disc's edge, tip pointing outward), not thin lines: at small icon
only the glyph itself shows against whatever's already on `img`.""" sizes thin lines read as a crosshair/asterisk, not sun rays, which is
icon = _icon_asset(category) exactly what an earlier attempt here looked like."""
size = max(1, round(r * 2)) draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color)
aspect_h = max(1, round(size * icon.height / icon.width)) base_r, tip_r, half_w = r * 0.6, r * 1.2, r * 0.16
resized = icon.resize((size, aspect_h), Image.LANCZOS) for i in range(8):
img.paste(resized, (round(cx - size / 2), round(cy - aspect_h / 2)), resized) angle = i * (math.pi / 4)
perp = angle + math.pi / 2
bx, by = cx + math.cos(angle) * base_r, cy + math.sin(angle) * base_r
tx, ty = cx + math.cos(angle) * tip_r, cy + math.sin(angle) * tip_r
p1 = (bx + math.cos(perp) * half_w, by + math.sin(perp) * half_w)
p2 = (bx - math.cos(perp) * half_w, by - math.sin(perp) * half_w)
draw.polygon([p1, p2, (tx, ty)], fill=color)
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 dendrite -- three crossing lines plus a short
perpendicular tick near each of the 6 tips, closer to a real
snowflake glyph than a bare asterisk."""
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.28)))
perp = angle + math.pi / 2
tick = r * 0.35
for sign in (1, -1):
tx, ty = x + dx * sign, y + dy * sign
ex, ey = tx * 0.75 + x * 0.25, ty * 0.75 + y * 0.25
draw.line([(ex - math.cos(perp) * tick, ey - math.sin(perp) * tick),
(ex + math.cos(perp) * tick, ey + math.sin(perp) * tick)], fill=color, width=2)
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, styled after Environment
Canada's own icon set but hand-drawn in this frame's exact panel ink
colors (yellow sun/bolt, blue rain/snow) -- see module docstring for
why that's better for this display than reusing an actual bitmap."""
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, 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, entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
show_labels: bool = True) -> int: show_labels: bool = True, palette_rgb: list | None = None) -> int:
"""Draws one or more cities' weather side by side starting at """Draws one or more cities' weather side by side starting at
(x0, y0), stopping once another entry wouldn't fit within max_w (x0, y0), stopping once another entry wouldn't fit within max_w
(narrow views like week columns just end up showing fewer cities -- (narrow views like week columns just end up showing fewer cities --
@@ -88,7 +187,7 @@ def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: i
if drew_any and x + entry_w > x0 + max_w: if drew_any and x + entry_w > x0 + max_w:
break break
cx, cy = x + icon_r, y0 + icon_r cx, cy = x + icon_r, y0 + icon_r
draw_weather_icon(img, cx, cy, icon_r, entry["category"]) 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) draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
x += entry_w x += entry_w
drew_any = True drew_any = True
@@ -140,7 +239,7 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb:
icon_r = max(20, min(target_w, target_h) // 4) icon_r = max(20, min(target_w, target_h) // 4)
cx, cy = target_w // 2, target_h // 2 - icon_r // 2 cx, cy = target_w // 2, target_h // 2 - icon_r // 2
draw_weather_icon(img, cx, cy, icon_r, entry["category"]) draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C" unit_suffix = "F" if units == "fahrenheit" else "C"
temp_size = max(24, min(target_w, target_h) // 3) temp_size = max(24, min(target_w, target_h) // 3)
@@ -204,7 +303,7 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb:
tbbox = draw.textbbox((0, 0), time_label, font=label_font) 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) draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED)
cy = y + label_size + 10 + icon_r cy = y + label_size + 10 + icon_r
draw_weather_icon(img, cx, cy, icon_r, entry["category"]) draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
temp_label = temp_labels[i] temp_label = temp_labels[i]
tempbbox = draw.textbbox((0, 0), temp_label, font=label_font) 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) draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font)
@@ -249,7 +348,7 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
lbbox = draw.textbbox((0, 0), label, font=label_font) 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, MUTED)
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
draw_weather_icon(img, cx, cy, icon_r, d["category"]) draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
tbbox = draw.textbbox((0, 0), temps, font=label_font) 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) draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font)
return img return img
@@ -287,7 +386,8 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r) font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r)
font = ImageFont.load_default(size=font_size) font = ImageFont.load_default(size=font_size)
y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2) 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) 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 return img
+61 -37
View File
@@ -3,15 +3,18 @@ Mirrors calendar_render.py's own testing posture (this module has no
dedicated test file there either, since it's exercised indirectly via dedicated test file there either, since it's exercised indirectly via
test_widgets_calendar.py) but covers the non-obvious behavior worth test_widgets_calendar.py) but covers the non-obvious behavior worth
pinning down directly: build_multi_city's label shortening, and that pinning down directly: build_multi_city's label shortening, and that
every shared category actually has a distinct vendored icon (a missing weather icons are filled with the panel's *exact* ink colors (not an
or misnamed asset file would otherwise only surface as a silent arbitrary bitmap's anti-aliased ones) -- the whole reason icons are
fallback to the "cloudy" glyph, not an error).""" hand-drawn rather than a fetched/vendored image: an exact palette match
quantizes with zero dithering error to diffuse, where anything else
dithers into a visible speckle at these small on-panel sizes."""
from __future__ import annotations from __future__ import annotations
from PIL import Image from PIL import Image, ImageDraw
from app import weather_render from app import weather_render
from app.image_pipeline import DEFAULT_PALETTE_RGB, _quantize
def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch): def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch):
@@ -25,9 +28,10 @@ def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch)
seen_labels = [] seen_labels = []
real_draw_weather_row = weather_render.draw_weather_row real_draw_weather_row = weather_render.draw_weather_row
def spy(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels=True): def spy(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels=True, palette_rgb=None):
seen_labels.extend(e["label"] for e in entries) seen_labels.extend(e["label"] for e in entries)
return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels) return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels,
palette_rgb)
monkeypatch.setattr(weather_render, "draw_weather_row", spy) monkeypatch.setattr(weather_render, "draw_weather_row", spy)
@@ -45,40 +49,60 @@ def test_build_multi_city_empty_list_returns_blank_canvas():
assert img.size == (400, 150) assert img.size == (400, 150)
def test_icon_asset_exists_and_loads_for_every_shared_category(): def _colors_present(img: Image.Image) -> set[tuple[int, int, int]]:
"""The shared category set (see app/weather/__init__.py's module return {c for _, c in img.getcolors(maxcolors=100_000)}
docstring) must each resolve to a real vendored file, not silently
fall back to "cloudy" -- a typo'd filename would otherwise only show
up as every OTHER category quietly looking like a plain cloud."""
for category in ("clear", "partly_cloudy", "cloudy", "fog", "rain", "snow", "thunderstorm"):
path = weather_render._ICON_DIR / f"{category}.gif"
assert path.exists(), f"missing vendored icon for {category!r}"
icon = weather_render._icon_asset(category)
assert icon.mode == "RGBA"
assert icon.width > 0 and icon.height > 0
def test_icon_asset_is_cached(): def test_draw_weather_icon_clear_uses_exact_panel_yellow():
assert weather_render._icon_asset("clear") is weather_render._icon_asset("clear")
def test_draw_weather_icon_pastes_a_visibly_different_glyph_per_category():
"""Not a pixel-exact check (the actual art is a vendored asset, not
something this test should hardcode) -- just confirms distinct
categories actually produce visibly different canvases, i.e. the
right file is being loaded per category rather than one glyph
silently reused for all of them."""
rendered = {}
for category in ("clear", "rain", "snow", "thunderstorm"):
img = Image.new("RGB", (100, 100), (255, 255, 255)) img = Image.new("RGB", (100, 100), (255, 255, 255))
weather_render.draw_weather_icon(img, 50, 50, 30, category) draw = ImageDraw.Draw(img)
rendered[category] = img.tobytes() weather_render.draw_weather_icon(draw, 50, 50, 30, "clear")
assert len(set(rendered.values())) == len(rendered) assert tuple(DEFAULT_PALETTE_RGB[2]) in _colors_present(img) # yellow sun
assert (0, 0, 0) not in _colors_present(img) # not a flat-black glyph
def test_draw_weather_icon_unrecognized_category_falls_back_to_cloudy(): def test_draw_weather_icon_rain_uses_exact_panel_blue():
img = Image.new("RGB", (100, 100), (255, 255, 255)) img = Image.new("RGB", (100, 100), (255, 255, 255))
weather_render.draw_weather_icon(img, 50, 50, 30, "not_a_real_category") draw = ImageDraw.Draw(img)
fallback = Image.new("RGB", (100, 100), (255, 255, 255)) weather_render.draw_weather_icon(draw, 50, 50, 30, "rain")
weather_render.draw_weather_icon(fallback, 50, 50, 30, "cloudy") assert tuple(DEFAULT_PALETTE_RGB[4]) in _colors_present(img) # blue raindrops
assert img.tobytes() == fallback.tobytes()
def test_draw_weather_icon_thunderstorm_uses_exact_panel_yellow():
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "thunderstorm")
assert tuple(DEFAULT_PALETTE_RGB[2]) in _colors_present(img) # yellow bolt
def test_draw_weather_icon_respects_a_custom_frame_palette():
"""A frame with an Advanced-configuration palette override (see
Frame.palette_rgb) should still get ITS actual yellow, not the
hardcoded default -- same resolution rule as calendar_render.py's
_event_colors."""
custom_palette = [(0, 0, 0), (255, 255, 255), (10, 20, 30), (0, 0, 0), (0, 0, 0), (0, 0, 0)]
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear", palette_rgb=custom_palette)
assert (10, 20, 30) in _colors_present(img)
def test_icon_fill_quantizes_uniformly_with_no_dithering_speckle():
"""The actual point of hand-drawing icons in exact panel colors: a
small crop taken from deep inside the sun disc (nowhere near its own
edge) is a single flat fill -- if that fill weren't an exact palette
match, _quantize's Floyd-Steinberg dithering would diffuse rounding
error across it, breaking the crop into a speckle of 2+ colors. An
exact match has zero error to diffuse, so the crop stays perfectly
uniform after quantizing, same as before."""
img = Image.new("RGB", (200, 200), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 100, 100, 60, "clear")
quantized = _quantize(img, DEFAULT_PALETTE_RGB, dither_strength=1.0).convert("RGB")
# A 20x20 crop centered on the disc -- draw_sun's disc radius is
# 0.55*60=33px, comfortably clear of both the outer edge and the ray
# triangles' base line.
crop = quantized.crop((90, 90, 110, 110))
colors_in_crop = _colors_present(crop)
assert colors_in_crop == {tuple(DEFAULT_PALETTE_RGB[2])} # solid yellow, no speckle