The first modern-style rollout translated each widget's existing classic layout into HTML/CSS -- same gradient headers, same rounded-shadowed card, prettier chrome around an unchanged composition. This actually redesigns weather (current/daily), calendar (all four views), tasks, and battery: no card/shadow anywhere, a slim accent-colored rule instead of a full gradient banner (and only that rule dithers at the richer accent amplitude now, not the header text sitting on it), and a dominant hero value (temperature/percent) instead of a centered icon+number of equal weight. Padding and type sizes scale as a clamped proportion of widget size instead of fixed pixel values. Text and static/whiteboard are left alone -- text already had zero chrome and its styling is user content, not this system's to redesign; framed_image's card was already minimal. Direction was picked from three divergent mockups reviewed with the maintainer, then verified against the real render pipeline (actual Chromium render, actual ordered dithering, actual theme system) rather than just eyeballed -- that caught a day-section/month-grid divider color (#e2e6ec) that's nowhere near this panel's 6-color palette and was dithering to invisible white; fixed with a real black hairline in the one place (month view) that still needed one.
345 lines
18 KiB
Python
345 lines
18 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.
|
|
|
|
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 = 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"
|
|
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) -> 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 = 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"
|
|
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) -> 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
|
|
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 = 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
|
|
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) -> 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 = max(11, min(16, target_h // 30))
|
|
day_size = max(10, min(15, target_w // 55))
|
|
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) -> 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)
|