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.
348 lines
19 KiB
Python
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)
|