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.
This commit is contained in:
@@ -22,7 +22,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from . import html_render, panel_style
|
||||
from . import html_render, panel_style, theme_tokens
|
||||
from .calendar_render import (
|
||||
MARGIN,
|
||||
WEEKDAY_NAMES,
|
||||
@@ -71,8 +71,11 @@ def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb,
|
||||
|
||||
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") -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of calendar_render._build_agenda."""
|
||||
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)
|
||||
@@ -91,24 +94,30 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
|
||||
data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
|
||||
target_h - header_h - MARGIN, row_h)
|
||||
|
||||
accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
|
||||
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=panel_style.CARD_RADIUS,
|
||||
font_dir=str(html_render._FONT_DIR), header=data["header"], title_size=title_size, header_h=header_h,
|
||||
accent_start=accent, accent_end=accent, weather_entries=data["weather_entries"],
|
||||
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)
|
||||
return html_render.ordered_dither(rendered, palette_rgb)
|
||||
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") -> Image.Image:
|
||||
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)."""
|
||||
-- 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)
|
||||
@@ -131,26 +140,35 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
|
||||
for d in day_dates
|
||||
]
|
||||
|
||||
accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
|
||||
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=panel_style.CARD_RADIUS,
|
||||
font_dir=str(html_render._FONT_DIR), days=days, title_size=title_size, header_h=header_h,
|
||||
accent_start=accent, accent_end=accent, weather_size=weather_size, unit_suffix=unit_suffix,
|
||||
row_h=row_h, body_size=body_size,
|
||||
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)
|
||||
return html_render.ordered_dither(rendered, palette_rgb)
|
||||
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) -> Image.Image:
|
||||
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."""
|
||||
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
|
||||
@@ -158,7 +176,6 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
else:
|
||||
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
|
||||
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
|
||||
accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
|
||||
owners_seen: list[str] = []
|
||||
|
||||
if layout == "vertical":
|
||||
@@ -180,13 +197,19 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
]
|
||||
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=panel_style.CARD_RADIUS,
|
||||
font_dir=str(html_render._FONT_DIR), days=day_sections, title_size=title_size, header_h=header_h,
|
||||
accent_start=accent, accent_end=accent, weather_size=weather_size, unit_suffix=unit_suffix,
|
||||
row_h=row_h, body_size=body_size,
|
||||
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)
|
||||
return html_render.ordered_dither(rendered, palette_rgb)
|
||||
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)
|
||||
@@ -219,20 +242,29 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
|
||||
template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||
font_dir=str(html_render._FONT_DIR), cols=cols, header_size=header_size, chip_size=chip_size,
|
||||
header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix, accent_start=accent, accent_end=accent,
|
||||
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)
|
||||
return html_render.ordered_dither(rendered, palette_rgb)
|
||||
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) -> Image.Image:
|
||||
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)."""
|
||||
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(
|
||||
@@ -243,7 +275,6 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
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 = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
|
||||
|
||||
owners_seen: list[str] = []
|
||||
weeks = []
|
||||
@@ -260,18 +291,22 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
|
||||
template = html_render._jinja_env.get_template("calendar_month.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||
font_dir=str(html_render._FONT_DIR), day_names=day_names, weeks=weeks,
|
||||
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=accent,
|
||||
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)
|
||||
return html_render.ordered_dither(rendered, palette_rgb)
|
||||
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) -> Image.Image:
|
||||
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
|
||||
@@ -281,11 +316,12 @@ def build(events: list[dict], view: str, browse_offset: int, target_w: int, targ
|
||||
effective_view = "agenda"
|
||||
|
||||
if effective_view == "agenda":
|
||||
return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, weather_units)
|
||||
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)
|
||||
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)
|
||||
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user