Files
espresso_frame/server/app/calendar_html_render.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

348 lines
19 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,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_agenda.
Bold-minimal: no card/border/shadow (theme["radius"]/theme["shadow"]
are unused, same carve-out as weather's build_current/build_daily --
see docs/widgets.md). The day header is plain ink text under a slim
accent-colored rule instead of white text on a full gradient band --
only that thin rule dithers at the theme's richer accent_amplitude
now, not the header text sitting on top of it, which is a legibility
improvement over the old design, not just a visual one."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
title_size = panel_style.scaled_size(max(14, min(target_w, target_h) // 12), font_scale)
body_size = panel_style.scaled_size(max(11, min(target_w, target_h) // 20), font_scale)
weather_size = max(10, body_size - 2)
row_h = body_size + 14
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.025, 4, 8))
# 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 = accent_h + 10 + title_size + ((weather_size + 10) 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,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
header=data["header"], title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], 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 + accent_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,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
-- two day-sections stacked (see _day_section_data). Bold-minimal, no
card (see build_agenda's docstring) -- each section's own slim accent
rule dithers richer via ordered_dither_regions, not its header text."""
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 = panel_style.scaled_size(max(13, section_h // 8), font_scale)
body_size = panel_style.scaled_size(max(10, min(target_w, target_h) // 26), font_scale)
weather_size = max(9, body_size - 2)
row_h = body_size + 12
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
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 = accent_h + 8 + title_size + ((weather_size + 8) 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,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=days, title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], 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 + accent_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, font_scale: float = 1.0) -> 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 = panel_style.scaled_size(max(11, min(20, section_h // 6)), font_scale)
body_size = panel_style.scaled_size(max(9, min(target_w, target_h) // (18 + days)), font_scale)
weather_size = max(8, body_size - 2)
row_h = body_size + 10
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.018, 3, 5))
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 = accent_h + 6 + title_size + ((weather_size + 6) 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,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=day_sections, title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], 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 + accent_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 = panel_style.scaled_size(max(10, min(16, (target_w // days) // 6)), font_scale)
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
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
# 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 + 8 + (weather_size + 4 if weather_cities else 0)
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - accent_h - 6 - 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,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
cols=cols, header_size=header_size, chip_size=chip_size,
header_h=header_h, accent_h=accent_h, weather_size=weather_size, unit_suffix=unit_suffix,
accent_start=theme["accent_hex"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_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,
font_scale: float = 1.0) -> 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.
Bold-minimal: no card (see build_agenda's docstring); the old flat
accent-colored weekday-name band is now a slim accent rule above
plain bold weekday labels, matching every other calendar view's
header treatment -- only that rule dithers at the theme's richer
accent_amplitude via ordered_dither_regions. "Today" is still called
out with a small accent-filled pill around its day number (a
genuinely small accent surface, not a band, so it was left alone)."""
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 = panel_style.scaled_size(max(11, min(16, target_h // 30)), font_scale)
day_size = panel_style.scaled_size(max(10, min(15, target_w // 55)), font_scale)
dot_size = max(4, day_size // 2)
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
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,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
day_names=day_names, weeks=weeks, accent_h=accent_h,
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)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_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, font_scale: float = 1.0) -> 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, font_scale)
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, font_scale)
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, font_scale)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name, font_scale)