From d34eb1bf457701dede39953d85a06899d138b0e1 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Thu, 30 Jul 2026 03:12:17 +0000 Subject: [PATCH] Modernize on-panel widget visuals: real typography, theme colors, gutter 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). --- server/app/calendar_render.py | 253 ++++++++++++++++++---------------- server/app/image_pipeline.py | 31 ++++- server/app/manage_overlay.py | 87 +++++------- server/app/panel_style.py | 184 +++++++++++++++++++++++++ server/app/weather_render.py | 138 ++++++++++--------- server/app/widgets/_shared.py | 12 +- server/app/widgets/battery.py | 76 ++++------ 7 files changed, 489 insertions(+), 292 deletions(-) create mode 100644 server/app/panel_style.py diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index dccb7f7..e33d177 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -11,7 +11,7 @@ Event dicts here are calendar_feed.py's shape: {"summary", "start", "end" (ISO 8601 strings), "all_day", "sources": [{"owner_display_name", "color_index"}, ...]} -- more than one entry in "sources" means merge_events collapsed several calendars' identical (same title/time) -events into one, see _event_colors/_draw_color_bar below. +events into one, see _event_colors/panel_style.draw_color_chip below. """ from __future__ import annotations @@ -26,6 +26,7 @@ from zoneinfo import ZoneInfo from PIL import Image, ImageDraw, ImageFont +from . import panel_style from .image_pipeline import ( DEFAULT_PALETTE_RGB, _apply_manage_overlay, @@ -42,12 +43,20 @@ CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (t "week": "Week", "month": "Month"} WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] -MARGIN = 20 +# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not +# re-tuned). BG/FG are this module's own plain black/white -- checkbox +# outlines, month-view grid hairlines -- not a text-emphasis concern (no +# MUTED gray here anymore -- see panel_style's module docstring for why: +# a mid-gray fill has no close palette match and dithers into speckle +# once the whole canvas is quantized. Secondary text now reads through +# size/weight alone, always exact black). +MARGIN = panel_style.CONTENT_MARGIN BG = (255, 255, 255) FG = (0, 0, 0) -MUTED = (110, 110, 110) -# Was a light gray, but that dithers away to near-invisible once quantized -# to the 6-color e-ink palette -- black reads as an actual line on-panel. +# Structural dividers/grid lines (between stacked day sections, week +# columns, month cells) stay a plain black rule -- gray dithers away to +# near-invisible once quantized to the 6-color e-ink palette. Headers +# no longer use this: see panel_style.draw_header_bar/theme_color. RULE = (0, 0, 0) # Fallback for any event whose calendar has no manually pinned color @@ -67,7 +76,7 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) one person's calendar). Usually just one color; more than one is what tells the "same event, more than one calendar" case apart from an ordinary single-calendar event at render time -- see - _draw_color_bar. Each source's own manually pinned color + panel_style.draw_color_chip. Each source's own manually pinned color (FrameCalendar.color_index -- see routers/api_widgets.py's api_widget_calendar_color) resolves against whichever palette this frame actually renders with, so a pinned "Blue" stays this frame's actual @@ -91,24 +100,6 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) return colors -def _draw_color_bar(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int, - colors: list[tuple[int, int, int]], radius: int) -> None: - """One rounded bar for a single-source event, or that same overall - footprint split into equal-width side-by-side segments -- one per - contributing calendar -- for a deduplicated shared event (see - _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.""" - if len(colors) == 1: - draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, 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 _event_start(event: dict, tz: ZoneInfo) -> datetime | date: """Parses event["start"] and, for timed events, converts to `tz` -- calendar_feed.py stores whatever timezone each source event carried @@ -158,11 +149,12 @@ def _fmt_task_due(due: str | None) -> str: return d.strftime("%b %-d") -# ImageFont.load_default() (used for everything else in this module -- -# see the module docstring) has no emoji glyphs, and PIL/FreeType don't -# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a -# visible filled rectangle) -- reads as a rendering glitch, not "emoji -# not supported". So event titles get drawn with two fonts: the normal +# Neither Inter (panel_style.font_bold/font_regular, this module's own +# body/title font -- see MARGIN/BG/FG comment above) nor PIL's bundled +# default font has emoji glyphs, and PIL/FreeType don't skip an +# unsupported codepoint, they substitute a ".notdef" tofu box (a visible +# filled rectangle) -- reads as a rendering glitch, not "emoji not +# supported". So event titles get drawn with two fonts: the normal # text font for everything else, and one of these for actual emoji runs # (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1, # vendored at app/fonts/ (license alongside at app/fonts/OFL.txt). @@ -401,15 +393,17 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve these vertically without duplicating the row-layout/truncation logic. Weather is drawn above the event list -- eating into the same row budget the event count is truncated against, exactly like the - header/rule above it already does.""" + header bar above it already does.""" x0, y0, w, h = region - text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN + header_h = title_font.size + 20 + panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h, + panel_style.theme_color("calendar", palette_rgb)) + text_x0 = x0 + MARGIN text_w = w - MARGIN * 2 header = day.strftime("%A, %B ") + str(day.day) - draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), title_font) - y = text_y0 + title_font.size + 12 - draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) - y += 12 + draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2), + _truncate_to_width(draw, header, title_font, text_w), title_font, BG) + y = y0 + header_h + 12 weather_entries = _weather_for_day(weather_cities, day) if weather_entries: @@ -422,13 +416,13 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve max_rows = max(0, (y0 + h - MARGIN - y) // row_h) if not day_events: - draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED) + draw_text(img, (text_x0, y), "Nothing scheduled", body_font) for i, event in enumerate(day_events): if i >= max_rows: - draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED) + draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font) break colors = _event_colors(event, owners_seen, palette_rgb) - _draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3) + panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors) time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz)) prefix = f"{time_str} " draw_text(img, (text_x0 + 18, y), prefix, body_font) @@ -445,13 +439,13 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, (`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks" default; the only widget type with its own on-panel title, since it's the only one where "which list is this" isn't obvious from its - content the way a calendar/photo/whiteboard's is), then a color bar - (reusing _event_colors/_draw_color_bar as-is: a task dict's - top-level owner_display_name/color_index is exactly _event_colors' - single-source fallback shape, since caldav_client.merge_tasks - doesn't cross-list-dedup tasks into a "sources" list the way - merge_events dedups events) + checkbox glyph + due date (if any) + - summary per task, same header/rule/row-cap/truncation shape as + content the way a calendar/photo/whiteboard's is), then a color chip + (reusing _event_colors/panel_style.draw_color_chip as-is: a task + dict's top-level owner_display_name/color_index is exactly + _event_colors' single-source fallback shape, since caldav_client. + merge_tasks doesn't cross-list-dedup tasks into a "sources" list the + way merge_events dedups events) + checkbox glyph + due date (if any) + + summary per task, same header/row-cap/truncation shape as _draw_agenda_day's event list so the standalone tasks widget (see _build_tasks) reads as the same consistent design as everything else on-panel, not a bolted-together look. Reuses _draw_mixed_line @@ -460,45 +454,52 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, Outstanding tasks get an empty checkbox; completed ones (only ever present when TaskWidgetConfig.show_completed is on -- see - caldav_client.fetch_tasks' completed_since) get a filled one and - muted text, no due-date prefix (irrelevant once done).""" + caldav_client.fetch_tasks' completed_since) get a filled checkbox in + this widget's own Green accent (see panel_style.THEME) -- that fill + is the "done" signal, no due-date prefix (irrelevant once done) and + no separate muted text treatment (see module-level MUTED removal + note above _event_colors).""" x0, y0, w, h = region - text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN + header_h = title_font.size + 20 + panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h, + panel_style.theme_color("tasks", palette_rgb)) + text_x0 = x0 + MARGIN text_w = w - MARGIN * 2 - draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font) - y = text_y0 + title_font.size + 12 - draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) - y += 12 + draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2), + _truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font, BG) + y = y0 + header_h + 12 row_h = body_font.size + 14 max_rows = max(0, (y0 + h - MARGIN - y) // row_h) if not tasks: - draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED) + draw_text(img, (text_x0, y), "Nothing outstanding", body_font) return owners_seen: list[str] = [] + checkbox_fill = panel_style.theme_color("tasks", palette_rgb) for i, task in enumerate(tasks): if i >= max_rows: - draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED) + draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font) break done = task.get("completed_at") is not None colors = _event_colors(task, owners_seen, palette_rgb) - _draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3) + panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors) box = body_font.size - 6 box_x = text_x0 + 18 box_y = y + (row_h - box) // 2 - 5 + box_r = min(panel_style.CHIP_RADIUS, box // 2) if done: - draw.rectangle([box_x, box_y, box_x + box, box_y + box], fill=FG) + draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, fill=checkbox_fill) else: - draw.rectangle([box_x, box_y, box_x + box, box_y + box], outline=FG, width=2) + draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, outline=FG, width=2) text_x = box_x + box + 10 due_str = None if done else _fmt_task_due(task.get("due")) prefix = f"{due_str} " if due_str else "" if prefix: - draw_text(img, (text_x, y), prefix, body_font, MUTED) + draw_text(img, (text_x, y), prefix, body_font) prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0 _draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"], - body_font, text_w - (text_x - text_x0) - prefix_w, fill=MUTED if done else FG) + body_font, text_w - (text_x - text_x0) - prefix_w) y += row_h @@ -512,17 +513,16 @@ _AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16 def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, palette_rgb: list | None = None, weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> Image.Image: - img = Image.new("RGB", (target_w, target_h), BG) - draw = ImageDraw.Draw(img) + img, draw, region = panel_style.card_canvas(target_w, target_h) title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)] - title_font = ImageFont.load_default(size=title_size) - body_font = ImageFont.load_default(size=body_size) - weather_font = ImageFont.load_default(size=weather_size) + title_font = panel_style.font_bold(title_size) + body_font = panel_style.font_regular(body_size) + weather_font = panel_style.font_regular(weather_size) day = datetime.now(tz).date() + timedelta(days=browse_offset) owners_seen: list[str] = [] - _draw_agenda_day(img, draw, day, events, tz, (0, 0, target_w, target_h), title_font, body_font, owners_seen, + _draw_agenda_day(img, draw, day, events, tz, region, title_font, body_font, owners_seen, palette_rgb, weather_cities, weather_font, weather_units) return img @@ -540,23 +540,22 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, shifts the whole two-day window together, same "days" unit _build_agenda already uses, so NEXT/BACK behaves identically across both views.""" - img = Image.new("RGB", (target_w, target_h), BG) - draw = ImageDraw.Draw(img) + img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)] - title_font = ImageFont.load_default(size=title_size) - body_font = ImageFont.load_default(size=body_size) - weather_font = ImageFont.load_default(size=weather_size) + title_font = panel_style.font_bold(title_size) + body_font = panel_style.font_regular(body_size) + weather_font = panel_style.font_regular(weather_size) start_day = datetime.now(tz).date() + timedelta(days=browse_offset) - section_h = target_h // 2 + section_h = ch // 2 owners_seen: list[str] = [] for i in range(2): - section_y0 = i * section_h + section_y0 = cy0 + i * section_h if i > 0: - draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE) + draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE) _draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz, - (0, section_y0, target_w, section_h), title_font, body_font, owners_seen, + (cx0, section_y0, cw, section_h), title_font, body_font, owners_seen, palette_rgb, weather_cities, weather_font, weather_units) return img @@ -585,8 +584,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: otherwise "start of the week" doesn't mean much for an arbitrary day count, so it instead starts `start_offset` days from today (0 = today, see routers/api_widgets.py's api_widget_config_save).""" - img = Image.new("RGB", (target_w, target_h), BG) - draw = ImageDraw.Draw(img) + img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) tier = _size_tier(target_w, target_h) today = datetime.now(tz).date() @@ -599,36 +597,36 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: if layout == "vertical": title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier] - title_font = ImageFont.load_default(size=max(14, title_base - days)) - body_font = ImageFont.load_default(size=max(11, body_base - days)) - weather_font = ImageFont.load_default(size=max(9, weather_base - days)) - section_h = target_h // days + title_font = panel_style.font_bold(max(14, title_base - days)) + body_font = panel_style.font_regular(max(11, body_base - days)) + weather_font = panel_style.font_regular(max(9, weather_base - days)) + section_h = ch // days for i in range(days): - section_y0 = i * section_h + section_y0 = cy0 + i * section_h if i > 0: - draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE) + draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE) day = week_first_day + timedelta(days=i) - _draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h), + _draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h), title_font, body_font, owners_seen, palette_rgb, weather_cities, weather_font, weather_units) return img header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier] - header_font = ImageFont.load_default(size=header_size) - chip_font = ImageFont.load_default(size=chip_size) - weather_font = ImageFont.load_default(size=weather_size) - col_w = (target_w - MARGIN * 2) // days + header_font = panel_style.font_bold(header_size) + chip_font = panel_style.font_regular(chip_size) + weather_font = panel_style.font_regular(weather_size) + col_w = (cw - MARGIN * 2) // days header_h = 44 for col in range(days): day = week_first_day + timedelta(days=col) - x0 = MARGIN + col * col_w + x0 = cx0 + MARGIN + col * col_w if col > 0: - draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE) + draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE) label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}" - draw_text(img, (x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font) + draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font) - y = MARGIN + header_h + y = cy0 + MARGIN + header_h # Columns are narrow, so only what actually fits gets drawn (see # weather_render.draw_weather_row) -- typically one city, no label # (the column itself makes which day it's for obvious; a city name @@ -640,14 +638,14 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: icon_r=8, font=weather_font, units=weather_units, show_labels=False, palette_rgb=palette_rgb) row_h = chip_font.size + 10 - max_rows = max(0, (target_h - MARGIN - y) // row_h) + max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h) day_events = _events_on_day(events, day, tz) for i, event in enumerate(day_events): if i >= max_rows: - draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED) + draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font) break colors = _event_colors(event, owners_seen, palette_rgb) - _draw_color_bar(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2) + panel_style.draw_color_chip(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2) if event["all_day"]: _draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20) else: @@ -672,39 +670,60 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h week_start: int, palette_rgb: list | None = None) -> Image.Image: """Density dots per day, not literal event text -- real text at typical month-cell size (~100x70px) is close to unreadable on a - 6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.""" - img = Image.new("RGB", (target_w, target_h), BG) - draw = ImageDraw.Draw(img) + 6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond. + "Not in this month" day numbers used to be a muted gray -- now + de-emphasized by weight instead (Regular vs. Bold), same reasoning + as everywhere else this module dropped MUTED -- see module-level + comment above MARGIN/BG/FG.""" + img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) header_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)] - header_font = ImageFont.load_default(size=header_size) - day_font = ImageFont.load_default(size=day_size) + header_font = panel_style.font_bold(header_size) + day_font_in_month = panel_style.font_bold(day_size) + day_font_out_of_month = panel_style.font_regular(day_size) today = datetime.now(tz).date() target_month = _add_months(date(today.year, today.month, 1), browse_offset) weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month)) - col_w = (target_w - MARGIN * 2) // 7 + col_w = (cw - MARGIN * 2) // 7 header_h = 28 - grid_top = MARGIN + header_h - row_h = (target_h - MARGIN - grid_top) // len(weeks) + grid_top = cy0 + MARGIN + header_h + row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks) + today_accent = panel_style.theme_color("calendar", palette_rgb) + today_badge_r = min(panel_style.CHIP_RADIUS, 9) day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start] for col, name in enumerate(day_names): - draw_text(img, (MARGIN + col * col_w + 6, MARGIN), name[:3], header_font, MUTED) + draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font) owners_seen: list[str] = [] dot_r = 6 for row, week in enumerate(weeks): for col, day in enumerate(week): - x0 = MARGIN + col * col_w + x0 = cx0 + MARGIN + col * col_w y0 = grid_top + row * row_h draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE) in_month = day.month == target_month.month - text_color = FG if in_month else MUTED if day == today: - draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG) - draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_color) + # A filled accent badge (this widget's own theme color, + # see panel_style.THEME) instead of the old bare outline + # -- an actual "today" indicator, not just an outline + # easy to miss at ~24px. Sized around the actual digit + # bbox (not a fixed pixel box) so a bold 2-digit day + # number ("30") fits as comfortably as a single digit + # ("3") at every size tier. + day_str = str(day.day) + text_x, text_y = x0 + 6, y0 + 4 + dbbox = draw.textbbox((text_x, text_y), day_str, font=day_font_in_month) + pad = 3 + badge_rect = [dbbox[0] - pad, dbbox[1] - pad, dbbox[2] + pad, dbbox[3] + pad] + badge_r = min(today_badge_r, (badge_rect[3] - badge_rect[1]) // 2) + draw.rounded_rectangle(badge_rect, radius=badge_r, fill=today_accent) + draw_text(img, (text_x, text_y), day_str, day_font_in_month, BG) + else: + day_font = day_font_in_month if in_month else day_font_out_of_month + draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font) day_events = _events_on_day(events, day, tz) dot_x = x0 + 8 @@ -718,7 +737,7 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color) dot_x += dot_r * 2 + 5 if len(day_events) > 4: - draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font, MUTED) + draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font) return img @@ -759,8 +778,13 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar weather_cities, weather_units) if fetch_summary: - font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11) - draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED) + # Drawn as a final overlay onto the already-composited img (not + # inside any one _build_* branch above), so it offsets by + # panel_style.GUTTER itself to land inside the same visible + # margin every builder's own content already respects. + font = panel_style.font_regular(14 if _size_tier(target_w, target_h) != "small" else 11) + draw_text(img, (panel_style.GUTTER + MARGIN, target_h - panel_style.GUTTER - MARGIN - font.size), + fetch_summary, font) return img @@ -815,12 +839,11 @@ def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: l """A tasks widget's entire region is the checklist -- unlike the old week-view slot, there's no day columns/header to share space with, so this is just _draw_tasks over the whole box.""" - img = Image.new("RGB", (target_w, target_h), BG) - draw = ImageDraw.Draw(img) + img, draw, region = panel_style.card_canvas(target_w, target_h) title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)] - title_font = ImageFont.load_default(size=title_size) - body_font = ImageFont.load_default(size=body_size) - _draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb, title) + title_font = panel_style.font_bold(title_size) + body_font = panel_style.font_regular(body_size) + _draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title) return img diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index b991511..60d5853 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -61,7 +61,8 @@ def _dotted_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: pos += spacing -def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int]) -> None: +def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int], + radius: int = 0) -> None: """Draws a border inset within img's own bounds, mutating it in place -- called once per widget's own region (routers/device.py's _render_widgets, and each widget type's own dialog preview) before @@ -74,24 +75,44 @@ def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tupl "solid"/"dashed"/"dotted" are a single thickness-px stroke traced just inside the image's edge; "fancy" is two thinner concentric strokes with a gap between them, picture-frame-mat style. "none" (or - a non-positive thickness) draws nothing.""" + a non-positive thickness) draws nothing. `radius` is opt-in and only + honored by "solid"/"fancy" (rounded_rectangle instead of rectangle) -- + "dashed"/"dotted" trace each of the 4 edges as independent straight + segments (see _dashed_edge/_dotted_edge) and ignore it, a documented + limitation rather than a bug. Defaults to 0 (unchanged sharp-corner + behavior) and no call site passes non-zero today -- this ships the + capability for a future per-widget "rounded border" setting without + changing default behavior anywhere (see tests/test_widget_border.py's + exact-corner-pixel assertions).""" if style == "none" or thickness <= 0: return w, h = img.size t = max(1, min(int(thickness), min(w, h) // 2)) draw = ImageDraw.Draw(img) + r = max(0, min(radius, (w - 1) // 2, (h - 1) // 2)) if style == "fancy": line_t = max(1, t // 3) gap = max(2, t - 2 * line_t) - draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t) + if r: + draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=line_t) + else: + draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t) inset = line_t + gap if w - 2 * inset > 1 and h - 2 * inset > 1: - draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t) + inner_r = max(0, min(r - inset, (w - 1 - 2 * inset) // 2, (h - 1 - 2 * inset) // 2)) if r else 0 + if inner_r: + draw.rounded_rectangle([inset, inset, w - 1 - inset, h - 1 - inset], radius=inner_r, + outline=color, width=line_t) + else: + draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t) return if style == "solid": - draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t) + if r: + draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=t) + else: + draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t) return # dashed/dotted trace the same centered-on-the-edge path solid/ diff --git a/server/app/manage_overlay.py b/server/app/manage_overlay.py index d382ca9..0e4265a 100644 --- a/server/app/manage_overlay.py +++ b/server/app/manage_overlay.py @@ -14,7 +14,8 @@ from __future__ import annotations from PIL import Image, ImageDraw, ImageFont -from .image_pipeline import DEFAULT_PALETTE_RGB, draw_text +from . import panel_style +from .image_pipeline import draw_text PADDING = 16 QR_TEXT_GAP = 8 @@ -27,9 +28,12 @@ BODY_FONT_SIZE = 20 BATTERY_ICON_W = 40 BATTERY_ICON_H = 22 -BATTERY_ICON_STROKE = 2 +# Stroke/nub width/height are no longer fixed constants here -- panel_ +# style.draw_battery_icon derives them from icon_w/icon_h itself (same +# formula widgets/battery.py's own icon already used). BATTERY_NUB_W +# below is kept only as this box's own outer-width estimate, not fed +# into the icon drawing itself. BATTERY_NUB_W = 5 -BATTERY_NUB_H = 10 BATTERY_ICON_TEXT_GAP = 8 BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box @@ -37,10 +41,6 @@ FACE_LABEL_PADDING = 8 FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box -def _font(size: int) -> ImageFont.ImageFont: - return ImageFont.load_default(size=size) - - def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image: import qrcode @@ -52,7 +52,7 @@ def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image: return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST) -def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont) -> tuple[int, int]: +def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont) -> tuple[int, int]: """(width, height) of `lines` stacked with LINE_GAP between them, at `font` -- the box _draw_text_box below will need.""" w = 0 @@ -64,7 +64,7 @@ def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.Image return w, h -def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont, +def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont, center_x: int, top: int) -> None: y = top for line in lines: @@ -82,7 +82,8 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption: (the battery, below the manage QR) use it instead of recomputing the same geometry a second time.""" qr_img = _qr_image(url) - text_w, text_h = _text_box(draw, caption, _font(TITLE_FONT_SIZE)) if caption else (0, 0) + caption_font = panel_style.font_bold(TITLE_FONT_SIZE) + text_w, text_h = _text_box(draw, caption, caption_font) if caption else (0, 0) content_w = max(qr_img.width, text_w) content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0) @@ -90,24 +91,26 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption: h = content_h + PADDING * 2 x0, y0 = _corner_origin(img.size, (w, h), corner) - draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0)) + draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS, + fill=(255, 255, 255), outline=(0, 0, 0)) center_x = x0 + w // 2 img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING)) if caption: - _draw_centered_lines(img, draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP) + _draw_centered_lines(img, draw, caption, caption_font, center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP) return x0, y0, w, h def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None: """White-padded box with centered text lines, placed in one of the panel's four corners.""" - font = _font(BODY_FONT_SIZE) + font = panel_style.font_regular(BODY_FONT_SIZE) text_w, text_h = _text_box(draw, lines, font) w = text_w + PADDING * 2 h = text_h + PADDING * 2 x0, y0 = _corner_origin(img.size, (w, h), corner) - draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0)) + draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS, + fill=(255, 255, 255), outline=(0, 0, 0)) _draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING) @@ -123,34 +126,17 @@ def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right -# DEFAULT_PALETTE_RGB order is [BLACK, WHITE, YELLOW, RED, BLUE, GREEN] -# (see image_pipeline.PANEL_CODES) -- picked by level so the fill itself -# carries the "how worried should I be" signal, not just the number next -# to it. Thresholds match the low-battery-alert spirit elsewhere in this -# project (not tied to a frame's own configured alert threshold, since -# this glyph has to make sense with no configuration at all). -_BATTERY_LOW = DEFAULT_PALETTE_RGB[3] # red -_BATTERY_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow -_BATTERY_HIGH = DEFAULT_PALETTE_RGB[5] # green - - -def _battery_fill_color(percent: int) -> tuple[int, int, int]: - if percent <= 15: - return _BATTERY_LOW - if percent <= 40: - return _BATTERY_MEDIUM - return _BATTERY_HIGH - - def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int, anchor_w: int, anchor_h: int) -> None: - """Battery glyph (now actually filled to `percent`, not just a static - outline -- easy now that this renders server-side instead of being a - fixed bitmap firmware drew) + "NN%" text, right-aligned under the - given anchor box (the manage QR box) -- a sensible default position, - not a constraint anything else has to route around; move this call - site's arguments to place it anywhere else instead.""" - font = _font(BODY_FONT_SIZE) + """Battery glyph + "NN%" text, right-aligned under the given anchor + box (the manage QR box) -- a sensible default position, not a + constraint anything else has to route around; move this call site's + arguments to place it anywhere else instead. The glyph itself is + panel_style.draw_battery_icon -- the one shared implementation + replacing what used to be a second, independent copy of widgets/ + battery.py's own icon-drawing code (same shape, same red/yellow/ + green thresholds, previously kept in sync by convention only).""" + font = panel_style.font_regular(BODY_FONT_SIZE) text = f"{percent}%" icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W text_w = draw.textlength(text, font=font) @@ -162,22 +148,14 @@ def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anc x0 = anchor_x0 + anchor_w - w y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP - draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0)) + draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS, + fill=(255, 255, 255), outline=(0, 0, 0)) icon_x = x0 + PADDING icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2 - inner_x0, inner_y0 = icon_x + BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_STROKE - inner_x1, inner_y1 = icon_x + BATTERY_ICON_W - BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_H - BATTERY_ICON_STROKE - fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (percent / 100)) - if fill_x1 > inner_x0: - draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_battery_fill_color(percent)) - draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0), - width=BATTERY_ICON_STROKE) - nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2 - draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H], - fill=(0, 0, 0)) + panel_style.draw_battery_icon(draw, icon_x, icon_y, BATTERY_ICON_W, BATTERY_ICON_H, percent) draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2), - text, font) + text, font, panel_style.battery_fill_color(percent)) def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None: @@ -185,7 +163,7 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc anchor_y) point, flipped above if there's no room below, clamped to stay fully on-panel -- unlike the four corner boxes (always in-bounds by construction), a face can be anywhere, including near an edge.""" - font = _font(BODY_FONT_SIZE) + font = panel_style.font_regular(BODY_FONT_SIZE) text_w = draw.textlength(name, font=font) bbox = draw.textbbox((0, 0), name, font=font) text_h = bbox[3] - bbox[1] @@ -201,7 +179,8 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc x0 = max(0, min(x0, img_w - w)) y0 = max(0, min(y0, img_h - h)) - draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0)) + draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS, + fill=(255, 255, 255), outline=(0, 0, 0)) draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font) diff --git a/server/app/panel_style.py b/server/app/panel_style.py new file mode 100644 index 0000000..c9198d5 --- /dev/null +++ b/server/app/panel_style.py @@ -0,0 +1,184 @@ +"""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)) diff --git a/server/app/weather_render.py b/server/app/weather_render.py index 84fdbc3..d719e77 100644 --- a/server/app/weather_render.py +++ b/server/app/weather_render.py @@ -33,23 +33,27 @@ 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 +from . import panel_style +from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size -MARGIN = 20 +# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not +# re-tuned -- every column-width/icon-size calc below was measured +# against 20px). BG/FG are this module's own cloud-icon fill/outline and +# fog-line color (see draw_cloud/draw_weather_icon), not a text-emphasis +# concern -- those live in panel_style (font_bold/font_regular, no MUTED +# gray -- see its module docstring for why). +MARGIN = panel_style.CONTENT_MARGIN 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]) + already this module's BG/FG) -- thin wrapper over panel_style.ink + (which generalized this same resolution idiom), kept so every + draw_weather_icon call site below doesn't need touching.""" + return panel_style.ink(palette_rgb, index) def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None: @@ -203,14 +207,17 @@ def _format_hour_label(iso_time: str) -> str: def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int, - min_size: int = 9) -> int: + min_size: int = 9, font_loader=panel_style.font_bold) -> 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.""" + next column at a large icon size on a narrow column. Measured against + `font_loader` (default Inter Bold -- the wider of the two weights a + column actually mixes, a label in Regular and a temp in Bold, so + fitting against Bold keeps both safely inside max_width).""" for size in range(max_size, min_size - 1, -1): - font = ImageFont.load_default(size=size) + font = font_loader(size) if all(draw.textlength(t, font=font) <= max_width for t in texts): return size return min_size @@ -232,29 +239,28 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: (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) + img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) 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 + icon_r = max(20, min(cw, ch) // 4) + cx, cy = cx0 + cw // 2, cy0 + ch // 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_size = max(24, min(cw, ch) // 3) + temp_font = panel_style.font_bold(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) + draw_text(img, (cx0 + cw // 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) + label_font = panel_style.font_regular(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) + draw_text(img, (cx0 + cw // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8), + city_label, label_font) return img @@ -265,19 +271,23 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb: 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 + img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) + text_x0 = cx0 + MARGIN + text_w = cw - MARGIN * 2 + y = cy0 + MARGIN - title_size = max(14, min(target_w, target_h) // 16) + title_size = max(14, min(cw, ch) // 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 + # A filled header bar (this widget's chosen accent is black, not + # a color, so the hand-drawn icons below stay the star -- see + # panel_style module docstring) replaces the old plain title + + # thin rule line. + header_h = title_size + 20 + panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h, + panel_style.theme_color("weather", palette_rgb)) + title_font = panel_style.font_bold(title_size) + draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG) + y = cy0 + header_h + 12 # Capped to however many columns actually fit at a legible width # (narrower widgets/smaller intervals just show fewer ticks) rather @@ -290,23 +300,24 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb: 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)) + icon_r = max(10, min(col_w // 3, (cy0 + ch - 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) + time_font = panel_style.font_regular(label_size) + temp_font = panel_style.font_bold(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) + tbbox = draw.textbbox((0, 0), time_label, font=time_font) + draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, time_font) 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) + tempbbox = draw.textbbox((0, 0), temp_label, font=temp_font) + draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, temp_font) return img @@ -317,40 +328,41 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg 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 + img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) + text_x0 = cx0 + MARGIN + text_w = cw - MARGIN * 2 + y = cy0 + MARGIN - title_size = max(14, min(target_w, target_h) // 16) + title_size = max(14, min(cw, ch) // 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 + header_h = title_size + 20 + panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h, + panel_style.theme_color("weather", palette_rgb)) + title_font = panel_style.font_bold(title_size) + draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG) + y = cy0 + header_h + 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)) + icon_r = max(12, min(col_w // 3, (cy0 + ch - 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) + label_font = panel_style.font_regular(label_size) + temp_font = panel_style.font_bold(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) + draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font) 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) + tbbox = draw.textbbox((0, 0), temps, font=temp_font) + draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, temp_font) return img @@ -360,10 +372,9 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r 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) + img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) 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 @@ -372,7 +383,7 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r # 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 + text_w = cw - 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 @@ -380,13 +391,14 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r # 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)) + icon_r = max(10, min(col_w // 6, ch // 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, + font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r, + font_loader=panel_style.font_regular) + font = panel_style.font_regular(font_size) + y = max(cy0 + MARGIN, cy0 + (ch - (icon_r * 2 + 8)) // 2) + draw_weather_row(img, draw, cx0 + MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units, show_labels=True, palette_rgb=palette_rgb) return img diff --git a/server/app/widgets/_shared.py b/server/app/widgets/_shared.py index 5d018af..ae37683 100644 --- a/server/app/widgets/_shared.py +++ b/server/app/widgets/_shared.py @@ -7,17 +7,19 @@ fraction of the panel, so its placeholder needs to scale down with it. from __future__ import annotations -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageDraw -_BG = (245, 245, 245) -_FG = (90, 90, 90) +from .. import panel_style +from ..image_pipeline import draw_text + +_BG = (255, 255, 255) def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image: img = Image.new("RGB", (target_w, target_h), _BG) draw = ImageDraw.Draw(img) font_size = max(10, min(20, target_h // 8)) - font = ImageFont.load_default(size=font_size) + font = panel_style.font_regular(font_size) line_h = font_size + 4 total_h = line_h * len(lines) y = max(4, (target_h - total_h) // 2) @@ -25,6 +27,6 @@ def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.I bbox = draw.textbbox((0, 0), line, font=font) line_w = bbox[2] - bbox[0] x = max(4, (target_w - line_w) // 2) - draw.text((x, y), line, fill=_FG, font=font) + draw_text(img, (x, y), line, font) y += line_h return img diff --git a/server/app/widgets/battery.py b/server/app/widgets/battery.py index 35ee28f..d2dd047 100644 --- a/server/app/widgets/battery.py +++ b/server/app/widgets/battery.py @@ -17,10 +17,11 @@ from __future__ import annotations import io import time -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageDraw from sqlalchemy.orm import Session -from ..image_pipeline import DEFAULT_PALETTE_RGB, _quantize, draw_text, logical_render_size +from .. import panel_style +from ..image_pipeline import _quantize, draw_text, logical_render_size from ..models import BatteryWidgetConfig, Frame, Widget from ..routers.common import battery_estimate_s from ._shared import placeholder_image @@ -28,45 +29,16 @@ from ._shared import placeholder_image ACTIONS: dict = {} ACTION_LABELS: dict[str, str] = {} -BG = (255, 255, 255) -MUTED = (110, 110, 110) -# Same thresholds/colors as manage_overlay.py's own battery glyph (not -# shared code -- that one draws onto the manage-QR overlay in a fixed -# small size, this one fills an arbitrary widget region -- but the -# "how worried should I be" color story should read the same wherever a -# battery glyph shows up on a panel). Exact panel ink RGB values, not -# arbitrary reds/yellows/greens -- a flat fill already at a palette -# color quantizes with zero dithering error once the whole composited -# canvas gets quantized, where an off-palette color would dither into a -# visible speckle at these small on-panel sizes. -_LOW = DEFAULT_PALETTE_RGB[3] # red -_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow -_HIGH = DEFAULT_PALETTE_RGB[5] # green - - -def _fill_color(percent: int) -> tuple[int, int, int]: - if percent <= 15: - return _LOW - if percent <= 40: - return _MEDIUM - return _HIGH - - -def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int) -> None: - stroke = max(2, icon_h // 12) +def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int, + palette_rgb: list | None = None) -> None: + """Centers panel_style.draw_battery_icon (top-left-anchored) under + `cx` -- this widget's own layout picks a center point, that helper's + shared implementation (also used by manage_overlay.py's battery + readout) just needs a top-left corner.""" nub_w = max(3, icon_w // 10) - nub_h = icon_h // 2 x0 = cx - (icon_w + nub_w) // 2 - y0 = top - 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: - draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_fill_color(percent)) - draw.rectangle([x0, y0, x0 + icon_w, y0 + icon_h], outline=(0, 0, 0), width=stroke) - nub_y = y0 + (icon_h - nub_h) // 2 - draw.rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], fill=(0, 0, 0)) + panel_style.draw_battery_icon(draw, x0, top, icon_w, icon_h, percent, palette_rgb) def _format_estimate(seconds: float) -> str: @@ -99,22 +71,26 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i cfg = db.get(BatteryWidgetConfig, widget.id) mode = cfg.mode if cfg else "detailed" + palette_rgb = frame.palette_rgb - img = Image.new("RGB", (target_w, target_h), BG) - draw = ImageDraw.Draw(img) - cx = target_w // 2 + img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) + cx = cx0 + cw // 2 - icon_h = max(20, min(target_w, target_h) // 3) + icon_h = max(20, min(cw, ch) // 3) icon_w = int(icon_h * 1.8) - icon_top = max(4, target_h // 8) - _draw_icon(draw, cx, icon_top, icon_w, icon_h, percent) + icon_top = max(4, cy0 + ch // 8) + _draw_icon(draw, cx, icon_top, icon_w, icon_h, percent, palette_rgb) - pct_font_size = max(18, min(target_w, target_h) // 3) - pct_font = ImageFont.load_default(size=pct_font_size) + # The percent number picks up the icon's own charge-level color + # (red/yellow/green) instead of plain black -- ties the two into one + # visual statement rather than "colored icon, black number". + pct_font_size = max(18, min(cw, ch) // 3) + pct_font = panel_style.font_bold(pct_font_size) pct_text = f"{percent}%" bbox = draw.textbbox((0, 0), pct_text, font=pct_font) pct_y = icon_top + icon_h + 10 - draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font) + draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font, + panel_style.battery_fill_color(percent, palette_rgb)) if mode == "detailed": lines = [] @@ -125,13 +101,13 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i lines.append(f"Reported {_format_age(frame.battery_as_of)}") small_font_size = max(11, pct_font_size // 3) - small_font = ImageFont.load_default(size=small_font_size) + small_font = panel_style.font_regular(small_font_size) y = pct_y + pct_font_size + 12 for line in lines: - if y + small_font_size > target_h - 4: + if y + small_font_size > cy0 + ch - 4: break lbbox = draw.textbbox((0, 0), line, font=small_font) - draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font, MUTED) + draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font) y += small_font_size + 6 return img