"""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))