"""Weather icon-drawing primitives (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. Icons are hand-drawn (no custom font/icon asset, same primitives-only approach calendar_render.py uses elsewhere for e.g. month view's density dots), styled after Environment Canada's own icon set (pointed sun rays, a smooth puffy cloud, teardrop rain, dendrite snowflakes, a zigzag bolt) but filled with this frame's *exact* panel ink RGB values rather than an arbitrary bitmap's anti-aliased colors -- a flat fill that's already one 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 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, and every fill stays an exact, ditherless palette match either way.""" 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 pointed triangular rays -- styled after Environment Canada's own sun glyph. Rays are solid triangles (base on the disc's edge, tip pointing outward), not thin lines: at small icon sizes thin lines read as a crosshair/asterisk, not sun rays, which is exactly what an earlier attempt here looked like.""" draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color) base_r, tip_r, half_w = r * 0.6, r * 1.2, r * 0.16 for i in range(8): 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, 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()