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).
185 lines
8.9 KiB
Python
185 lines
8.9 KiB
Python
"""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))
|