Files
espresso_frame/server/app/panel_style.py
T
tfaour 2868087467
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m24s
Add a per-widget text-size picker for calendar/tasks legibility
Calendar and tasks pack the most body text at the smallest default
sizes, so those two gear-icon dialogs get a "Text size" card (Normal/
Large/X-Large) alongside the existing Border card -- a new Widget-level
font_scale column with its own POST .../font-scale endpoint, same
Widget-property-not-config-field shape as border_style. Threaded through
every classic (calendar_render.py) and modern (html_render.py/
calendar_html_render.py) size calc via one shared panel_style.
scaled_size() so row heights/max_rows already derived from font size
re-fit around the bigger text automatically.
2026-08-02 03:23:27 +00:00

207 lines
10 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
# models.Widget.font_scale's allowed values -- a per-widget text-size
# multiplier (the Layout dialog's "Text size" picker), same "reject
# invalid, don't silently coerce" posture as border_style. Deliberately a
# small fixed set (a <select>, not a raw slider) rather than an arbitrary
# float: every _AGENDA_FONTS/_TASKS_FONTS-style tuple in calendar_render.py
# and every proportional size in html_render.py/calendar_html_render.py
# derives row heights/max_rows from the same scaled font size, so an
# unbounded scale risks a layout that no longer fits its own box.
FONT_SCALE_CHOICES = (1.0, 1.25, 1.5)
def scaled_size(value: float, font_scale: float) -> int:
"""Applies a widget's font_scale to a text-size value and rounds to
an int px, floored at 1 so an extreme scale can never zero out a
font. Shared by both the classic (PIL, calendar_render.py) and modern
(HTML/CSS, html_render.py/calendar_html_render.py) renderers so "make
this widget's text bigger" behaves identically regardless of
render_style -- every caller applies this immediately after its own
tier lookup/floor calc, so row heights/max_rows computed from the
result already account for the bigger text."""
return max(1, round(value * font_scale))
# 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))