Files
espresso_frame/server/app/calendar_html_render.py
T
tfaour 5f4f8f2ea7
Build and push server image / test (push) Successful in 44s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m27s
Add a curated theme system for "modern" style widgets, inspired by Tesserae
Frame.theme (7 presets in app/theme_tokens.py) drives font family,
corner radius, drop shadow, and an accent hue for every modern-style
widget's header/accent region. Rich accent colors (not just the 6 flat
panel inks) are approximated via denser Bayer stippling confined to just
that region (html_render.ordered_dither_regions), so icon/text content
elsewhere stays exactly as crisp as it is today -- verified directly
against real Chromium renders, both in unit tests and via run-server.
"classic" is a byte-identical no-visual-change default: weather's header
keeps its original fixed blue gradient, tasks/calendar keep their flat
THEME_* ink.

Themes are purely stylistic -- battery's charge-level color, calendar/
tasks' per-owner event chips, and text's own per-widget font choice are
never touched.
2026-07-31 10:34:28 +00:00

328 lines
17 KiB
Python

"""Calendar widget's "modern" render style -- all four view modes
(agenda/today_tomorrow/week/month), mirroring calendar_render.py's own
_build dispatch shape exactly so app/widgets/calendar.py and the
calendar preview endpoint can call either module identically. Kept in
its own module rather than joining app/html_render.py's other build_*
functions, mirroring calendar_render.py's own separation from the
simpler widgets (calendar is the one case where html_render.py growing
a 5th unrelated builder starts to hurt readability).
Reuses calendar_render's own private helpers (_events_on_day/_event_
colors/_event_start/_fmt_time/_weather_for_day/_month_view_fits/
_add_months) so a modern-style view's event list/colors/times/weather/
month-grid math match the classic renderer's data exactly -- only the
drawing differs, same relationship weather's build_current/build_daily
have with weather_render.py."""
from __future__ import annotations
import calendar as calendar_module
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from PIL import Image
from . import html_render, panel_style, theme_tokens
from .calendar_render import (
MARGIN,
WEEKDAY_NAMES,
_add_months,
_event_colors,
_event_start,
_events_on_day,
_fmt_time,
_month_view_fits,
_weather_for_day,
)
def _weather_row(weather_cities, day, units) -> list[dict]:
entries = _weather_for_day(weather_cities, day)
return [
{"emoji": html_render.CATEGORY_EMOJI.get(e["category"], ""), "high": round(e["high"]), "low": round(e["low"])}
for e in entries
]
def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb, weather_cities,
weather_units: str, owners_seen: list[str], rows_avail_h: int, row_h: int) -> dict:
"""One day's {header, weather_entries, rows, more_count} -- shared by
build_agenda/build_today_tomorrow/build_week's vertical layout, same
reuse relationship calendar_render._draw_agenda_day has with
_build_agenda/_build_today_tomorrow. `rows_avail_h` is the *rows*
area's own pixel budget only -- the caller has already reserved a
separate, uniform header_h (which is where weather actually renders,
see the day-header macro) for every section, so this function
doesn't need to account for weather space itself."""
header = day.strftime("%A, %B ") + str(day.day)
weather_entries = _weather_row(weather_cities, day, weather_units)
max_rows = max(0, rows_avail_h // row_h)
day_events = _events_on_day(events, day, tz)
rows = []
for event in day_events[:max_rows]:
colors = _event_colors(event, owners_seen, palette_rgb)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
rows.append({"colors": [html_render._rgb_to_hex(c) for c in colors], "time": time_str,
"summary": event["summary"]})
return {"header": header, "weather_entries": weather_entries, "rows": rows,
"more_count": max(0, len(day_events) - max_rows)}
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", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_agenda. Has a
header bar -- dithered at the theme's accent_amplitude via
ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
title_size = max(14, min(target_w, target_h) // 12)
body_size = max(11, min(target_w, target_h) // 20)
weather_size = max(10, body_size - 2)
row_h = body_size + 14
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
# Single day -- no cross-section alignment concern, so header_h can
# simply reflect whether THIS day actually has weather (unlike
# build_today_tomorrow/build_week's vertical layout, which must
# reserve the same header_h for every stacked section regardless).
has_weather = bool(_weather_row(weather_cities, day, weather_units))
header_h = title_size + 24 + ((weather_size + 12) if has_weather else 0)
owners_seen: list[str] = []
data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
target_h - header_h - MARGIN, row_h)
template = html_render._jinja_env.get_template("calendar_agenda.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
header=data["header"], title_size=title_size, header_h=header_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_entries=data["weather_entries"],
weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"],
more_count=data["more_count"], row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
return html_render.ordered_dither_regions(
rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
)
def build_today_tomorrow(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", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
-- two day-sections stacked (see _day_section_data), each with its own
header bar dithered richer via ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2
title_size = max(13, section_h // 8)
body_size = max(10, min(target_w, target_h) // 26)
weather_size = max(9, body_size - 2)
row_h = body_size + 12
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
day_dates = [start_day + timedelta(days=i) for i in range(2)]
# Uniform across both stacked sections regardless of which day(s)
# actually have weather -- see _day_section_data's own docstring for
# why a per-day header height misaligns where rows start.
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
header_h = title_size + 16 + ((weather_size + 10) if any_weather else 0)
owners_seen: list[str] = []
days = [
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
section_h - header_h, row_h)
for d in day_dates
]
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=days, title_size=title_size, header_h=header_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
theme["accent_amplitude"])
for i in range(len(days))
]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal",
start_offset: int = 0, theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_week -- both
the vertical (stacked day-sections, reusing build_today_tomorrow's
template with an arbitrary day count) and horizontal (side-by-side
columns) layouts. Each header (per-section or per-column) dithers
richer via ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
gutter = panel_style.GUTTER
today = datetime.now(tz).date()
if days == 7:
days_since_start = (today.weekday() - week_start) % 7
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
else:
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
owners_seen: list[str] = []
if layout == "vertical":
section_h = target_h // days
title_size = max(11, min(20, section_h // 6))
body_size = max(9, min(target_w, target_h) // (18 + days))
weather_size = max(8, body_size - 2)
row_h = body_size + 10
day_dates = [week_first_day + timedelta(days=i) for i in range(days)]
# Uniform across all `days` stacked sections -- see
# _day_section_data's own docstring for why a per-day header
# height misaligns where rows start.
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
header_h = title_size + 12 + ((weather_size + 8) if any_weather else 0)
day_sections = [
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
section_h - header_h, row_h)
for d in day_dates
]
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=day_sections, title_size=title_size, header_h=header_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
theme["accent_amplitude"])
for i in range(len(day_sections))
]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
header_size = max(10, min(16, (target_w // days) // 6))
chip_size = max(9, header_size - 3)
weather_size = max(8, chip_size - 1)
col_w = max(1, (target_w - panel_style.GUTTER * 2) // days)
row_h = chip_size + 8
# Reserve weather-line room in every column's header uniformly
# (whether or not THIS specific day has a cached forecast) -- a
# per-column height that depends on that day's own data would
# misalign where each column's event rows start across the week
# grid the moment any single day lacks a forecast entry.
header_h = header_size + 22 + (weather_size + 6 if weather_cities else 0)
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - header_h) // row_h)
cols = []
for i in range(days):
day = week_first_day + timedelta(days=i)
label = day.strftime("%a %-d") if day != today else f"★ {day.strftime('%a %-d')}"
weather_entries = _weather_row(weather_cities, day, weather_units)
day_events = _events_on_day(events, day, tz)
rows = []
for event in day_events[:max_rows]:
color = html_render._rgb_to_hex(_event_colors(event, owners_seen, palette_rgb)[0])
summary = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
rows.append({"color": color, "summary": summary})
cols.append({
"label": label, "weather": weather_entries[0] if weather_entries else None,
"rows": rows, "more_count": max(0, len(day_events) - max_rows),
})
template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
cols=cols, header_size=header_size, chip_size=chip_size,
header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_month --
density dots per day, not literal event text, same reasoning as the
classic renderer (real text at typical month-cell size is close to
unreadable on a 6-color dithered e-ink panel). The per-owner event
dots are identity-coding (like every other calendar view's chips) and
are never touched by a theme; only the weekday-name row (a flat
accent background, no gradient in this view) dithers richer via
ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
gutter = panel_style.GUTTER
today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks_dates = list(
calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month)
)
day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])]
header_size = max(11, min(16, target_h // 30))
day_size = max(10, min(15, target_w // 55))
dot_size = max(4, day_size // 2)
owners_seen: list[str] = []
weeks = []
for week in weeks_dates:
row = []
for day in week:
day_events = _events_on_day(events, day, tz)
dots = [html_render._rgb_to_hex(_event_colors(e, owners_seen, palette_rgb)[0]) for e in day_events[:4]]
row.append({
"day_num": day.day, "in_month": day.month == target_month.month,
"is_today": day == today, "dots": dots, "more_count": max(0, len(day_events) - 4),
})
weeks.append(row)
template = html_render._jinja_env.get_template("calendar_month.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
day_names=day_names, weeks=weeks,
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
weekday_row_h = header_size + 12
accent_rect = (gutter, gutter, target_w - gutter, gutter + weekday_row_h)
return html_render.ordered_dither_regions(rendered, palette_rgb,
accent_regions=[(accent_rect, theme["accent_amplitude"])])
def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0, theme_name: str | None = None) -> Image.Image:
"""Dispatches to the right build_* -- mirrors calendar_render._build's
exact "month falls back to agenda when it doesn't fit" resolution, so
a narrow month-mode widget set to modern style still gets a sensible
modern view instead of erroring or silently reverting to classic."""
effective_view = view
if view == "month" and not _month_view_fits(target_w, target_h):
effective_view = "agenda"
if effective_view == "agenda":
return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name)
if effective_view == "today_tomorrow":
return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name)
if effective_view == "week":
return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities,
weather_units, week_days, week_layout, week_start_offset, theme_name)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name)