"""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)