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:
@@ -212,6 +212,70 @@ Per-widget-type notes:
|
||||
(`framed_image.html.jinja`, shared between the two) wrapping the
|
||||
already-composed image.
|
||||
|
||||
### Themes for modern-style widgets
|
||||
|
||||
`Frame.theme` (String, default `"classic"`, one Advanced Configuration
|
||||
`<select>`) picks a curated visual preset for every modern-style widget
|
||||
on that frame -- font family, corner radius, drop shadow, and an accent
|
||||
hue for widgets with a header/accent region. Presets live in
|
||||
`app/theme_tokens.py`'s `THEMES` dict; `resolve_theme(theme_name,
|
||||
widget_kind, palette_rgb)` turns one into concrete, ready-to-render
|
||||
values (`accent_hex`/`accent_hex_dark`, resolved `font_regular`/
|
||||
`font_bold` file paths, `radius`, `shadow`, `accent_amplitude`). Inspired
|
||||
by [Tesserae](https://github.com/dmellok/tesserae)'s (AGPL-3.0) own
|
||||
three-layer CSS custom-property theme system -- this is an original
|
||||
reimplementation of that *architecture*, not a copy of its token file
|
||||
(see this repo's `CLAUDE.md` on copyleft dependencies).
|
||||
|
||||
**A theme is purely stylistic, never functional color-coding.** Battery's
|
||||
charge-level red/yellow/green, calendar/tasks' per-owner event color
|
||||
chips, and text's user-authored inline run colors are status/identity
|
||||
signals, not style choices -- no theme may recolor them, and every
|
||||
`build_*`/`resolve_theme` call site that touches those stays on its own
|
||||
existing logic untouched. Text's own per-widget `font_family` setting
|
||||
(a user's explicit content-level choice, same carve-out reasoning) is
|
||||
similarly never overridden by a theme -- `build_text` accepts a
|
||||
`theme_name` param for signature uniformity with every other modern-
|
||||
style builder but deliberately ignores it.
|
||||
|
||||
**Rich accent hues, not just the 6 exact panel inks.** A theme's
|
||||
`accent_hex` can be any arbitrary color (e.g. terracotta, moss, slate) --
|
||||
`html_render.ordered_dither_regions(rendered, palette_rgb,
|
||||
base_amplitude, accent_regions=[(rect, amplitude), ...])` dithers the
|
||||
whole widget at the existing safe default (`ordered_dither`'s tuned 48,
|
||||
unchanged, still icon/text-legible) and then *separately* re-dithers
|
||||
just the accent rectangle (a header bar's already-computed pixel rect)
|
||||
at a theme's higher `accent_amplitude` (~130) and pastes it back. Safe
|
||||
to do per-region for the same reason `ordered_dither` itself is safe
|
||||
per-widget: ordered (Bayer) dithering has no cross-pixel error term, so
|
||||
a region's result depends only on its own pixels. A single higher
|
||||
amplitude applied to the *whole* widget instead was tried and rejected --
|
||||
it washes out pale content (a weather icon's white cloud body nearly
|
||||
vanished in testing); confining the higher amplitude to just the accent
|
||||
rect avoids that while still letting the rect approximate a rich hue via
|
||||
denser stippling instead of flatly snapping to one nearest ink (what
|
||||
happens to a rich hue at the base amplitude).
|
||||
|
||||
**"classic" is a deliberately no-visual-change default.** Its
|
||||
`accent_hex` is `None`, meaning "keep this widget kind's own pre-theme
|
||||
look exactly": weather's header was always a fixed blue gradient (now
|
||||
`theme_tokens._CLASSIC_WEATHER_GRADIENT`, byte-identical to the old
|
||||
module-level `ACCENT_START`/`ACCENT_END` constants this system
|
||||
replaced); tasks/calendar's header was always a flat single ink resolved
|
||||
through `panel_style.THEME` (still is, just via `resolve_theme` now).
|
||||
Widget kinds with no ink of their own (battery/text/static/whiteboard)
|
||||
fall back to black, though none of their templates currently have an
|
||||
accent-colored surface for it to visibly affect.
|
||||
|
||||
Which widgets get the richer accent-region treatment: weather's
|
||||
`build_daily` (the header bar, when `city_label` is set), tasks, and
|
||||
calendar's four view builders (each already computed a `header_h` in
|
||||
Python for layout, reused as the accent rect). Weather's `build_current`,
|
||||
battery, and static/whiteboard's shared `build_framed_image` are
|
||||
theme-aware for font/radius/shadow only -- no header/accent region to
|
||||
dither richer, so they call plain `ordered_dither` exactly as before
|
||||
themes existed.
|
||||
|
||||
## Button actions
|
||||
|
||||
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
|
||||
|
||||
@@ -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)
|
||||
|
||||
+91
-46
@@ -8,7 +8,7 @@ dithering) plus one `build_*` function per widget type that has a
|
||||
modern-style builder -- battery/text/tasks/static image/whiteboard live
|
||||
here directly (mirroring how those widget types are themselves "inlined"
|
||||
in their own widget.py rather than getting a dedicated render module);
|
||||
calendar's (agenda mode only, see calendar_html_render.py) is the one
|
||||
calendar's (all four view modes, see calendar_html_render.py) is the one
|
||||
exception, kept separate the same reason calendar_render.py itself is
|
||||
its own 800+ line file rather than joining battery/text/tasks inline.
|
||||
Not offered for the photos widget -- a real photograph isn't a
|
||||
@@ -66,7 +66,7 @@ import numpy as np
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from PIL import Image
|
||||
|
||||
from . import panel_style
|
||||
from . import panel_style, theme_tokens
|
||||
from .image_pipeline import DEFAULT_PALETTE_RGB, hex_to_rgb
|
||||
|
||||
_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html"
|
||||
@@ -87,16 +87,6 @@ CATEGORY_EMOJI = {
|
||||
"thunderstorm": "⛈️",
|
||||
}
|
||||
|
||||
# ACCENT_START/END: a fixed blue gradient pair for the "modern" style's
|
||||
# card header -- deliberately not routed through panel_style.theme_color
|
||||
# (unlike every classic-rendered widget's chrome), since the whole point
|
||||
# of this style is the gradient look ordered_dither below then commits
|
||||
# to exact palette colors anyway; which literal hex this starts from
|
||||
# doesn't matter to the end result the way it would for a flat PIL fill.
|
||||
ACCENT_START = "#1c4fd6"
|
||||
ACCENT_END = "#6fa8ff"
|
||||
|
||||
|
||||
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||
return "#%02x%02x%02x" % tuple(rgb)
|
||||
|
||||
@@ -104,7 +94,7 @@ def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||
def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
|
||||
"""A darker shade of `rgb` for a CSS gradient's second stop -- purely
|
||||
decorative (ordered_dither commits everything to exact palette colors
|
||||
regardless), same reasoning ACCENT_START/END's own comment gives."""
|
||||
regardless of which literal hex a gradient starts from)."""
|
||||
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
|
||||
|
||||
|
||||
@@ -247,6 +237,26 @@ def ordered_dither(img: Image.Image, palette_rgb: list | None, amplitude: float
|
||||
return Image.fromarray(palette[idx].astype(np.uint8), "RGB")
|
||||
|
||||
|
||||
def ordered_dither_regions(rendered: Image.Image, palette_rgb: list | None, base_amplitude: float = 48.0,
|
||||
accent_regions: list[tuple[tuple[int, int, int, int], float]] = ()) -> Image.Image:
|
||||
"""Like `ordered_dither`, but lets specific rectangles (e.g. a themed
|
||||
header bar) dither at a higher amplitude than the rest of the widget.
|
||||
A single higher amplitude applied to a whole widget washes out pale
|
||||
content (a weather icon's white cloud body nearly disappeared in
|
||||
testing); dithering the base image at the safe default and only
|
||||
re-dithering an accent rect on top -- pasted back over the base --
|
||||
lets a header carry a rich, arbitrary accent hue (via denser
|
||||
stippling) without touching icon/text legibility elsewhere. Safe to
|
||||
do per-region for the same reason `ordered_dither` is safe per-widget
|
||||
(see its docstring): no cross-pixel error-diffusion term, so each
|
||||
region's result depends only on its own pixels."""
|
||||
base = ordered_dither(rendered, palette_rgb, amplitude=base_amplitude)
|
||||
for (x0, y0, x1, y1), amplitude in accent_regions:
|
||||
crop = rendered.crop((x0, y0, x1, y1))
|
||||
base.paste(ordered_dither(crop, palette_rgb, amplitude=amplitude), (x0, y0))
|
||||
return base
|
||||
|
||||
|
||||
# --- Weather "modern" style ----------------------------------------------
|
||||
|
||||
def _day_label(day_date: date) -> str:
|
||||
@@ -259,21 +269,25 @@ def _day_label(day_date: date) -> str:
|
||||
|
||||
|
||||
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of weather_render.build_current --
|
||||
same call signature, so app/widgets/weather.py can dispatch to
|
||||
either interchangeably. Returns an already-palette-exact RGB image
|
||||
(see ordered_dither)."""
|
||||
(see ordered_dither). No header/accent region here (just a centered
|
||||
icon+temp) -- theme-aware for font/radius only, plain ordered_dither
|
||||
(no ordered_dither_regions call)."""
|
||||
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||
if not entry:
|
||||
return img
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
icon_size = max(28, min(target_w, target_h) // 3)
|
||||
template = _jinja_env.get_template("weather_current.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||
font_dir=str(_FONT_DIR), emoji=CATEGORY_EMOJI.get(entry["category"], ""),
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
emoji=CATEGORY_EMOJI.get(entry["category"], ""),
|
||||
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label,
|
||||
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
|
||||
label_size=max(12, icon_size // 3),
|
||||
@@ -283,15 +297,18 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb:
|
||||
|
||||
|
||||
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
|
||||
call signature. Returns an already-palette-exact RGB image (see
|
||||
ordered_dither)."""
|
||||
ordered_dither). Has a header bar -- dithered at the theme's
|
||||
accent_amplitude via ordered_dither_regions, richer than the rest of
|
||||
the widget (see that function's docstring for why)."""
|
||||
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||
days = list(daily.items())
|
||||
if not days:
|
||||
return img
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
|
||||
col_w = max(1, target_w // len(days))
|
||||
@@ -307,21 +324,26 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
|
||||
]
|
||||
template = _jinja_env.get_template("weather_daily.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||
font_dir=str(_FONT_DIR), city_label=city_label, header_h=header_h,
|
||||
title_size=max(14, header_h - 12), accent_start=ACCENT_START, accent_end=ACCENT_END,
|
||||
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"],
|
||||
city_label=city_label, header_h=header_h,
|
||||
title_size=max(14, header_h - 12), accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2),
|
||||
unit_suffix=unit_suffix,
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
if header_h <= 0:
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
gutter = panel_style.GUTTER
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||
|
||||
|
||||
SUPPORTED_MODES = ("current", "daily")
|
||||
|
||||
|
||||
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
|
||||
"""Dispatches to build_current/build_daily -- mirrors weather_render.
|
||||
build()'s signature (minus interval_hours, which no modern-style mode
|
||||
uses) so app/widgets/weather.py and the weather preview endpoint can
|
||||
@@ -329,12 +351,13 @@ def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | Non
|
||||
SUPPORTED_MODES -- callers are expected to have already fallen back to
|
||||
weather_render.build() for hourly/multi_city (see weather.py)."""
|
||||
if mode == "current":
|
||||
return build_current(data, target_w, target_h, palette_rgb, units, city_label)
|
||||
return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
|
||||
return build_current(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
|
||||
return build_daily(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
|
||||
|
||||
|
||||
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
|
||||
units: str = "fahrenheit", city_label: str = "") -> bytes:
|
||||
units: str = "fahrenheit", city_label: str = "",
|
||||
theme_name: str | None = None) -> bytes:
|
||||
"""Modern-style analogue of weather_render.render_weather_preview_png
|
||||
-- same browser-viewable-PNG convention every other widget's preview
|
||||
endpoint uses. build()'s output is already palette-exact (see
|
||||
@@ -343,7 +366,7 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l
|
||||
from .image_pipeline import _quantize, _png_bytes, logical_render_size
|
||||
|
||||
target_w, target_h = logical_render_size(orientation)
|
||||
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label)
|
||||
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, theme_name)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
return _png_bytes(quantized)
|
||||
|
||||
@@ -361,13 +384,16 @@ def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -
|
||||
|
||||
|
||||
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||
palette_rgb: list | None = None) -> Image.Image:
|
||||
palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of widgets/battery.py's classic PIL
|
||||
drawing -- same icon+percent+caption-lines shape, `lines` already
|
||||
resolved by the caller (widgets/battery.py's _lines_for(), shared
|
||||
with the classic path so the estimate/age formatting only lives in
|
||||
one place). Returns an already-palette-exact RGB image (see
|
||||
ordered_dither).
|
||||
ordered_dither). Theme-aware for font/radius only -- the charge-level
|
||||
fill_color below is a functional status signal (not a style choice)
|
||||
and is never touched by a theme, and there's no header/accent region
|
||||
to dither richer via ordered_dither_regions.
|
||||
|
||||
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
|
||||
the whole stack actually fits the available height -- the classic
|
||||
@@ -392,6 +418,7 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||
num_lines = len(lines)
|
||||
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, "battery", palette_rgb)
|
||||
fill_color = panel_style.battery_fill_color(percent, palette_rgb)
|
||||
icon_h = sizes["icon_h"]
|
||||
icon_w = int(icon_h * 1.8)
|
||||
@@ -399,8 +426,8 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||
nub_w = max(3, icon_w // 10)
|
||||
template = _jinja_env.get_template("battery.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||
font_dir=str(_FONT_DIR), percent=percent, lines=lines,
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"], percent=percent, lines=lines,
|
||||
icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke,
|
||||
fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke),
|
||||
fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color),
|
||||
@@ -413,7 +440,8 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||
|
||||
# --- Text "modern" style ---------------------------------------------------
|
||||
|
||||
def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None) -> Image.Image:
|
||||
def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of widgets/text.py's classic PIL
|
||||
drawing. Reuses widgets/text.py's own `_fit()` for the one piece of
|
||||
logic CSS has no native equivalent for (shrink-to-fit sizing) --
|
||||
@@ -425,7 +453,15 @@ def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = Non
|
||||
algorithms can disagree on exact break points, an acceptable
|
||||
approximation since this style only needs to look good and fit
|
||||
reasonably, not be pixel-identical to classic. Returns an already-
|
||||
palette-exact RGB image (see ordered_dither)."""
|
||||
palette-exact RGB image (see ordered_dither).
|
||||
|
||||
theme_name is accepted (every modern-style build_* function takes
|
||||
one, threaded uniformly from frame.theme) but deliberately unused --
|
||||
the text widget's own font_family is a per-widget, user-authored
|
||||
choice (see widgets/text.py's module docstring), same carve-out
|
||||
reasoning as run-level colors; a frame theme overriding it would
|
||||
silently undo an explicit user choice. text.html.jinja also has no
|
||||
card chrome (no radius/shadow) for a theme to touch."""
|
||||
from PIL import ImageDraw
|
||||
|
||||
from . import widgets # local import: heavy-ish, and only "modern" text needs it
|
||||
@@ -459,15 +495,19 @@ def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = Non
|
||||
# --- Tasks "modern" style ---------------------------------------------------
|
||||
|
||||
def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
title: str = "Tasks") -> Image.Image:
|
||||
title: str = "Tasks", theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of calendar_render._build_tasks --
|
||||
same header+checklist shape. Reuses calendar_render's own
|
||||
_event_colors/_fmt_task_due (the exact color-dedup/due-date-format
|
||||
logic the classic renderer uses) so a task's color chip/due string
|
||||
matches classic style exactly; only the drawing differs. Returns an
|
||||
already-palette-exact RGB image (see ordered_dither)."""
|
||||
matches classic style exactly; only the drawing differs -- and a
|
||||
theme's accent never touches those per-owner chip colors (identity-
|
||||
coding, not style). Has a header bar -- dithered at the theme's
|
||||
accent_amplitude via ordered_dither_regions. Returns an already-
|
||||
palette-exact RGB image (see ordered_dither)."""
|
||||
from .calendar_render import _event_colors, _fmt_task_due
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
|
||||
header_h = max(28, min(target_w, target_h) // 8)
|
||||
body_size = max(11, min(target_w, target_h) // 20)
|
||||
row_h = body_size + 14
|
||||
@@ -491,35 +531,40 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
|
||||
|
||||
template = _jinja_env.get_template("tasks.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||
font_dir=str(_FONT_DIR), title=title, header_h=header_h, title_size=max(14, header_h - 12),
|
||||
accent_start=_rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_TASKS)),
|
||||
accent_end=_rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_TASKS)),
|
||||
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"],
|
||||
title=title, header_h=header_h, title_size=max(14, header_h - 12),
|
||||
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||
rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size,
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
gutter = panel_style.GUTTER
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||
|
||||
|
||||
# --- Static image / whiteboard "modern" style (shared) ---------------------
|
||||
|
||||
def build_framed_image(composed: Image.Image, target_w: int, target_h: int,
|
||||
palette_rgb: list | None = None) -> Image.Image:
|
||||
palette_rgb: list | None = None, theme_name: str | None = None,
|
||||
widget_kind: str = "static") -> Image.Image:
|
||||
"""Wraps an already-composed image (static_image.py/whiteboard.py's
|
||||
own compose_into() output, exactly target_w x target_h, already
|
||||
cropped/fit per that widget's own display_mode) in a rounded-corner,
|
||||
shadowed card -- the first visual chrome either widget type has ever
|
||||
had (both currently draw with zero chrome of their own). Returns an
|
||||
already-palette-exact RGB image (see ordered_dither)."""
|
||||
had (both currently draw with zero chrome of their own). Theme-aware
|
||||
for radius/shadow only -- no text/header content to accent or font.
|
||||
Returns an already-palette-exact RGB image (see ordered_dither)."""
|
||||
import base64
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, widget_kind, palette_rgb)
|
||||
buf = io.BytesIO()
|
||||
composed.convert("RGB").save(buf, format="PNG")
|
||||
image_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
template = _jinja_env.get_template("framed_image.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||
image_b64=image_b64,
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
|
||||
@@ -880,6 +880,15 @@ def _migration_38(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||
|
||||
|
||||
def _migration_39(conn) -> None:
|
||||
"""Frame-level curated theme for "modern" style widgets (models.
|
||||
Frame.theme, see theme_tokens.THEMES) -- same guarded-per-column
|
||||
shape as every prior migration."""
|
||||
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
||||
if "theme" not in existing:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -919,6 +928,7 @@ MIGRATIONS = [
|
||||
(36, _migration_36),
|
||||
(37, _migration_37),
|
||||
(38, _migration_38),
|
||||
(39, _migration_39),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -186,6 +186,11 @@ class Frame(Base):
|
||||
# for real photographs. NULL/1.0 = same defaults as the main fields.
|
||||
photo_palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
photo_dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
# Curated visual theme for "modern" (HTML/CSS) style widgets -- see
|
||||
# theme_tokens.THEMES. Frame-level (like palette_rgb/dither_strength
|
||||
# above), not per-widget, since a theme is "how this frame looks."
|
||||
# Widgets rendered in classic (PIL) style ignore this entirely.
|
||||
theme: Mapped[str] = mapped_column(String, default="classic")
|
||||
|
||||
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
||||
# routers/device.py's RENDERERS["calendar"]) --
|
||||
|
||||
@@ -25,7 +25,7 @@ from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import gitea_releases, grid, quiet_hours
|
||||
from .. import gitea_releases, grid, quiet_hours, theme_tokens
|
||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db
|
||||
from ..global_actions import GLOBAL_ACTIONS
|
||||
@@ -108,6 +108,7 @@ def api_config_save(
|
||||
photo_palette: list[str] | None = Form(None),
|
||||
photo_palette_reset: bool | None = Form(None),
|
||||
photo_dither_strength: float | None = Form(None),
|
||||
theme: str | None = Form(None),
|
||||
hold_duration_ms: int | None = Form(None),
|
||||
next_hold_action: str | None = Form(None),
|
||||
back_hold_action: str | None = Form(None),
|
||||
@@ -196,6 +197,8 @@ def api_config_save(
|
||||
cfg.photo_palette_rgb = [list(rgb) for rgb in parsed]
|
||||
if photo_dither_strength is not None:
|
||||
cfg.photo_dither_strength = max(0.0, min(1.0, photo_dither_strength))
|
||||
if theme is not None:
|
||||
cfg.theme = theme if theme in theme_tokens.THEMES else "classic"
|
||||
if hold_duration_ms is not None:
|
||||
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
|
||||
if next_hold_action is not None:
|
||||
|
||||
@@ -876,6 +876,7 @@ def api_widget_preview_calendar(
|
||||
img = calendar_html_render.build(
|
||||
events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb,
|
||||
weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset,
|
||||
frame.theme,
|
||||
)
|
||||
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||
png = _png_bytes(quantized)
|
||||
@@ -911,7 +912,7 @@ def api_widget_preview_tasks(
|
||||
from .. import html_render
|
||||
|
||||
target_w, target_h = logical_render_size(frame.orientation)
|
||||
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title)
|
||||
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
|
||||
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||
png = _png_bytes(quantized)
|
||||
else:
|
||||
@@ -1170,7 +1171,7 @@ def api_widget_preview_weather(
|
||||
|
||||
png = html_render.render_weather_preview_png(
|
||||
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
||||
city_label=wcfg.city_label or "",
|
||||
city_label=wcfg.city_label or "", theme_name=frame.theme,
|
||||
)
|
||||
else:
|
||||
png = weather_render.render_weather_preview_png(
|
||||
@@ -1230,7 +1231,7 @@ def api_widget_preview_static(
|
||||
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
|
||||
display_mode=scfg.display_mode)
|
||||
fitted = _enhance(composed, frame.color_boost, frame.contrast_boost)
|
||||
img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb)
|
||||
img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb, frame.theme, "static")
|
||||
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||
png = _png_bytes(quantized)
|
||||
else:
|
||||
@@ -1257,7 +1258,8 @@ def api_widget_preview_text(
|
||||
xcfg = db.get(TextWidgetConfig, widget.id)
|
||||
if not has_text(xcfg.content):
|
||||
raise HTTPException(400, "No text authored on this widget yet")
|
||||
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
|
||||
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
theme_name=frame.theme)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
@@ -1379,7 +1381,8 @@ def api_widget_preview_whiteboard(
|
||||
target_w, target_h = logical_render_size(frame.orientation)
|
||||
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
|
||||
display_mode="letterbox")
|
||||
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb)
|
||||
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
|
||||
"whiteboard")
|
||||
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||
png = _png_bytes(quantized)
|
||||
else:
|
||||
|
||||
@@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import weather
|
||||
from .. import theme_tokens, weather
|
||||
from ..auth import can_view_frame, current_user
|
||||
from ..calendar_render import CALENDAR_VIEW_LABELS
|
||||
from ..db import get_db
|
||||
@@ -94,6 +94,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
|
||||
palette_to_hex=palette_to_hex,
|
||||
photo_widget_id=photo_widget_id,
|
||||
global_action_labels=GLOBAL_ACTION_LABELS,
|
||||
themes=theme_tokens.THEMES,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -260,6 +260,24 @@ document.getElementById('photo-palette-reset').addEventListener('click', () => {
|
||||
savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' });
|
||||
});
|
||||
|
||||
// ---- Theme ----
|
||||
|
||||
document.getElementById('theme-save').addEventListener('click', async () => {
|
||||
const body = new URLSearchParams({ theme: document.getElementById('theme-select').value });
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
loadPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Preview: current photo vs. how it renders with saved settings ----
|
||||
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
|
||||
// set by the template) rather than window.FRAME_API -- palette/color/
|
||||
|
||||
@@ -196,6 +196,23 @@
|
||||
Compare against the physical panel before keeping it.</p>
|
||||
</details>
|
||||
|
||||
<details class="card">
|
||||
<summary class="card-title">Theme</summary>
|
||||
<p class="sub">A curated visual style for widgets using the
|
||||
"modern" (experimental) render style -- font, corner radius,
|
||||
shadow, and header accent. Widgets rendered in the classic
|
||||
style are unaffected. Photos are unaffected too (see Photos
|
||||
configuration below).</p>
|
||||
<label>Theme
|
||||
<select id="theme-select">
|
||||
{% for key, t in themes.items() %}
|
||||
<option value="{{ key }}" {% if frame.theme == key %}selected{% endif %}>{{ t.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="secondary" id="theme-save" style="margin-top: 16px;">Save</button>
|
||||
</details>
|
||||
|
||||
<details class="card">
|
||||
<summary class="card-title">Photos configuration</summary>
|
||||
<p class="sub">Palette and dithering used <strong>only</strong> by
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html><head><style>
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html><head><style>
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
@@ -10,7 +10,7 @@
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html><head><style>
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
@@ -10,7 +10,7 @@
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html><head><style>
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
@@ -10,7 +10,7 @@
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html><head><style>
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
@@ -10,7 +10,7 @@
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
|
||||
background: #ffffff;
|
||||
}
|
||||
.card img {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html><head><style>
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
@@ -10,7 +10,7 @@
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html><head><style>
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html><head><style>
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
|
||||
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", sans-serif; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
@@ -10,7 +10,7 @@
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
|
||||
background: #ffffff;
|
||||
}
|
||||
.header {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Curated theme presets for "modern" (HTML/CSS) style widgets, plus the
|
||||
font-family table every modern-style widget -- and widgets/text.py's own
|
||||
classic PIL path -- resolves fonts through. The font table used to be
|
||||
widgets/text.py's private property; it's hoisted here (text.py now
|
||||
imports it back) since html_render.py's build_text/build_daily/etc. all
|
||||
need to resolve a theme's font_family to real font files too, not just
|
||||
the text widget.
|
||||
|
||||
Inspired by Tesserae's (github.com/dmellok/tesserae, AGPL-3.0) three-
|
||||
layer CSS custom-property theme system -- primitives, semantic per-
|
||||
theme tokens, component tokens -- reimplemented here as original Python/
|
||||
CSS rather than copied (see docs/widgets.md and this repo's CLAUDE.md on
|
||||
copyleft dependencies).
|
||||
|
||||
A theme is purely **stylistic**: font family, corner radius, drop
|
||||
shadow, header gradient on/off, and an accent hue for the widgets that
|
||||
have an actual header/accent region to dither richer (see
|
||||
html_render.ordered_dither_regions). It never touches **functional**
|
||||
color-coding -- battery's charge-level red/yellow/green, calendar/tasks'
|
||||
per-owner event color chips, and text's user-authored inline run colors
|
||||
are status/identity signals, not style choices, and no theme may
|
||||
recolor them.
|
||||
|
||||
Rich accent_hex values are not restricted to the 6 exact panel inks --
|
||||
`ordered_dither_regions` approximates them via denser Bayer stippling in
|
||||
just the accent region (verified directly: terracotta/ochre/moss/teal/
|
||||
slate-blue/plum swatches all resolve to a believable multi-ink
|
||||
approximation at amplitude ~130, the same mechanism -- spatial
|
||||
dithering, not flat quantization -- Tesserae's own calibrated-palette
|
||||
Floyd-Steinberg uses, just ordered instead of diffused)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import ImageFont
|
||||
|
||||
from . import panel_style
|
||||
|
||||
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
|
||||
|
||||
# --- Font family table (hoisted from widgets/text.py) -----------------
|
||||
|
||||
DEFAULT_FONT_FAMILY = "sans"
|
||||
FONT_FAMILIES: dict[str, str] = {
|
||||
"sans": "Sans-serif (Noto Sans)",
|
||||
"inter": "Inter",
|
||||
"source_sans": "Source Sans",
|
||||
"serif": "Serif (Noto Serif)",
|
||||
"elegant": "Elegant serif (Crimson Text)",
|
||||
"slab": "Slab serif (Arvo)",
|
||||
"mono": "Monospace (IBM Plex Mono)",
|
||||
}
|
||||
_FONT_FILES = {
|
||||
"sans": {
|
||||
(False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
|
||||
(False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
|
||||
},
|
||||
"inter": {
|
||||
(False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
|
||||
(False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
|
||||
},
|
||||
"source_sans": {
|
||||
(False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
|
||||
(False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
|
||||
},
|
||||
"serif": {
|
||||
(False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
|
||||
(False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
|
||||
},
|
||||
"elegant": {
|
||||
(False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
|
||||
(False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
|
||||
},
|
||||
"slab": {
|
||||
(False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
|
||||
(False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
|
||||
},
|
||||
"mono": {
|
||||
(False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
|
||||
(False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-BoldItalic.ttf",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def font_path(family: str, bold: bool, italic: bool) -> Path:
|
||||
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
|
||||
return _FONT_DIR / files[(bold, italic)]
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
|
||||
return ImageFont.truetype(str(font_path(family, bold, italic)), size)
|
||||
|
||||
|
||||
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||
return "#%02x%02x%02x" % tuple(rgb)
|
||||
|
||||
|
||||
def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
|
||||
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
|
||||
|
||||
|
||||
# --- Theme presets -------------------------------------------------------
|
||||
|
||||
DEFAULT_THEME = "classic"
|
||||
|
||||
# accent_hex=None means "keep each widget's own classic THEME_* ink" --
|
||||
# resolve_theme() below is what actually looks that up -- so "classic" is
|
||||
# deliberately a no-visual-change default, byte-identical to how modern
|
||||
# style already rendered before this theme system existed.
|
||||
THEMES: dict[str, dict] = {
|
||||
"classic": {
|
||||
"label": "Classic", "accent_hex": None,
|
||||
"font_family": "inter", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
||||
"gradient": True, "accent_amplitude": 48.0,
|
||||
},
|
||||
"terracotta": {
|
||||
"label": "Terracotta", "accent_hex": "#a84b2a",
|
||||
"font_family": "inter", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
||||
"gradient": True, "accent_amplitude": 130.0,
|
||||
},
|
||||
"ochre": {
|
||||
"label": "Ochre", "accent_hex": "#b8892b",
|
||||
"font_family": "slab", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
||||
"gradient": True, "accent_amplitude": 130.0,
|
||||
},
|
||||
"moss": {
|
||||
"label": "Moss", "accent_hex": "#4f6f36",
|
||||
"font_family": "serif", "radius": 4, "shadow": False,
|
||||
"gradient": False, "accent_amplitude": 130.0,
|
||||
},
|
||||
"teal": {
|
||||
"label": "Teal", "accent_hex": "#2b7a78",
|
||||
"font_family": "source_sans", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
||||
"gradient": True, "accent_amplitude": 130.0,
|
||||
},
|
||||
"slate": {
|
||||
"label": "Slate", "accent_hex": "#3f5a88",
|
||||
"font_family": "inter", "radius": 0, "shadow": False,
|
||||
"gradient": False, "accent_amplitude": 130.0,
|
||||
},
|
||||
"plum": {
|
||||
"label": "Plum", "accent_hex": "#6a3b5e",
|
||||
"font_family": "elegant", "radius": panel_style.CARD_RADIUS, "shadow": True,
|
||||
"gradient": False, "accent_amplitude": 130.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# "classic" theme's accent_hex is None, meaning "keep this specific
|
||||
# widget kind's own pre-theme-system look exactly" -- for tasks/calendar
|
||||
# that's their classic THEME_TASKS/THEME_CALENDAR ink (a flat color, both
|
||||
# gradient stops equal, since neither ever had a gradient header before
|
||||
# this system existed); weather's modern style never went through
|
||||
# panel_style.THEME at all -- its header was always this fixed blue
|
||||
# gradient (see html_render.py's now-removed ACCENT_START/ACCENT_END
|
||||
# constants) -- preserved here byte-for-byte so "classic" stays a
|
||||
# genuinely no-visual-change default for every widget kind that shipped
|
||||
# before themes existed.
|
||||
_CLASSIC_WEATHER_GRADIENT = ("#1c4fd6", "#6fa8ff")
|
||||
|
||||
|
||||
def resolve_theme(theme_name: str | None, widget_kind: str, palette_rgb: list | None) -> dict:
|
||||
"""Concrete, ready-to-render values for one widget's modern-style
|
||||
build_* function: accent_hex/accent_hex_dark (a gradient's two CSS
|
||||
stops -- both equal when the theme has no gradient), font_family
|
||||
(validated against FONT_FAMILIES) plus its resolved font_regular/
|
||||
font_bold file paths, radius, shadow, gradient, and accent_amplitude
|
||||
(for ordered_dither_regions' header rect -- unused by widgets that
|
||||
dither their header at the base amplitude only, see module
|
||||
docstring). widget_kind is one of panel_style.THEME's keys
|
||||
("calendar"/"tasks"/"weather") plus "battery"/"text"/"static"/
|
||||
"whiteboard" for the widgets that have no classic THEME_* ink of
|
||||
their own -- those fall back to BLACK when theme_name is "classic"
|
||||
or unrecognized."""
|
||||
theme = THEMES.get(theme_name or DEFAULT_THEME, THEMES[DEFAULT_THEME])
|
||||
accent_hex = theme["accent_hex"]
|
||||
if accent_hex is None:
|
||||
if widget_kind == "weather":
|
||||
accent_hex, accent_hex_dark = _CLASSIC_WEATHER_GRADIENT
|
||||
else:
|
||||
ink_index = panel_style.THEME.get(widget_kind, panel_style.BLACK)
|
||||
accent_hex = accent_hex_dark = _rgb_to_hex(panel_style.ink(palette_rgb, ink_index))
|
||||
else:
|
||||
accent_hex_dark = accent_hex if not theme["gradient"] else _darken_hex(
|
||||
tuple(int(accent_hex[i:i + 2], 16) for i in (1, 3, 5))
|
||||
)
|
||||
family = theme["font_family"] if theme["font_family"] in FONT_FAMILIES else DEFAULT_FONT_FAMILY
|
||||
return {
|
||||
"theme_name": theme_name if theme_name in THEMES else DEFAULT_THEME,
|
||||
"accent_hex": accent_hex,
|
||||
"accent_hex_dark": accent_hex_dark,
|
||||
"font_family": family,
|
||||
"font_regular": str(font_path(family, False, False)),
|
||||
"font_bold": str(font_path(family, True, False)),
|
||||
"radius": theme["radius"],
|
||||
"shadow": theme["shadow"],
|
||||
"gradient": theme["gradient"],
|
||||
"accent_amplitude": theme["accent_amplitude"],
|
||||
}
|
||||
@@ -96,7 +96,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
# image_pipeline.render_placeholder's local `import qrcode`).
|
||||
from .. import html_render
|
||||
|
||||
return html_render.build_battery(percent, lines, target_w, target_h, palette_rgb)
|
||||
return html_render.build_battery(percent, lines, target_w, target_h, palette_rgb, frame.theme)
|
||||
|
||||
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||
cx = cx0 + cw // 2
|
||||
|
||||
@@ -59,6 +59,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
return calendar_html_render.build(
|
||||
events, cfg.view, cfg.browse_offset, target_w, target_h, tz, cfg.week_start, frame.palette_rgb,
|
||||
weather_cities, cfg.weather_units, cfg.week_days, cfg.week_layout, cfg.week_start_offset,
|
||||
frame.theme,
|
||||
)
|
||||
|
||||
return _build(
|
||||
|
||||
@@ -39,5 +39,5 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
# own classic path, should never pay for it.
|
||||
from .. import html_render
|
||||
|
||||
return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb)
|
||||
return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme, "static")
|
||||
return composed
|
||||
|
||||
@@ -43,7 +43,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
# own classic path, should never pay for it.
|
||||
from .. import html_render
|
||||
|
||||
return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title)
|
||||
return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
|
||||
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title)
|
||||
|
||||
|
||||
|
||||
+23
-66
@@ -20,12 +20,11 @@ block of authored text."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import Image, ImageDraw
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import theme_tokens
|
||||
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
|
||||
from ..models import Frame, TextWidgetConfig, Widget
|
||||
from ..text_content import has_text
|
||||
@@ -40,66 +39,20 @@ LINE_HEIGHT_FACTOR = 1.35
|
||||
DEFAULT_FG = (0, 0, 0)
|
||||
DEFAULT_BG = (255, 255, 255)
|
||||
|
||||
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts"
|
||||
|
||||
# A small curated set, not an open-ended picker -- each entry needs a
|
||||
# real vendored Regular/Bold/Italic/BoldItalic file, so families that
|
||||
# only ship as a variable font (Playfair Display, Lora, Merriweather,
|
||||
# stock "Inter"/"Source Sans 3" from Google Fonts) were skipped in favor
|
||||
# of static builds from their own upstream repos where one exists (see
|
||||
# app/fonts/OFL-*.txt for each non-Noto family's own license/copyright --
|
||||
# they're all OFL, same as the Noto fonts already vendored here, but
|
||||
# each has a different copyright holder so gets its own license file
|
||||
# rather than sharing app/fonts/OFL.txt).
|
||||
DEFAULT_FONT_FAMILY = "sans"
|
||||
FONT_FAMILIES: dict[str, str] = {
|
||||
"sans": "Sans-serif (Noto Sans)",
|
||||
"inter": "Inter",
|
||||
"source_sans": "Source Sans",
|
||||
"serif": "Serif (Noto Serif)",
|
||||
"elegant": "Elegant serif (Crimson Text)",
|
||||
"slab": "Slab serif (Arvo)",
|
||||
"mono": "Monospace (IBM Plex Mono)",
|
||||
}
|
||||
_FONT_FILES = {
|
||||
"sans": {
|
||||
(False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
|
||||
(False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
|
||||
},
|
||||
"inter": {
|
||||
(False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
|
||||
(False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
|
||||
},
|
||||
"source_sans": {
|
||||
(False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
|
||||
(False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
|
||||
},
|
||||
"serif": {
|
||||
(False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
|
||||
(False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
|
||||
},
|
||||
"elegant": {
|
||||
(False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
|
||||
(False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
|
||||
},
|
||||
"slab": {
|
||||
(False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
|
||||
(False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
|
||||
},
|
||||
"mono": {
|
||||
(False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
|
||||
(False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-BoldItalic.ttf",
|
||||
},
|
||||
}
|
||||
# The font-family table (a small curated set, not an open-ended picker --
|
||||
# each entry needs a real vendored Regular/Bold/Italic/BoldItalic file)
|
||||
# lives in app/theme_tokens.py now, shared with every modern-style
|
||||
# widget's own font resolution -- re-exported here under their original
|
||||
# names since this was the text widget's own table before the theme
|
||||
# system needed it too (see app/fonts/OFL-*.txt for each non-Noto
|
||||
# family's own license/copyright).
|
||||
DEFAULT_FONT_FAMILY = theme_tokens.DEFAULT_FONT_FAMILY
|
||||
FONT_FAMILIES = theme_tokens.FONT_FAMILIES
|
||||
_FONT_FILES = theme_tokens._FONT_FILES
|
||||
_font = theme_tokens.font
|
||||
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
|
||||
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
|
||||
return ImageFont.truetype(str(_FONT_DIR / files[(bold, italic)]), size)
|
||||
|
||||
|
||||
def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
|
||||
"""One paragraph's styled runs -> word groups: each group is a list
|
||||
of same-word sub-tokens that must stay glued together on one line
|
||||
@@ -225,7 +178,7 @@ def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.I
|
||||
|
||||
|
||||
def _render_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int,
|
||||
palette_rgb: list | None) -> Image.Image:
|
||||
palette_rgb: list | None, theme_name: str | None = None) -> Image.Image:
|
||||
"""classic vs modern (app/html_render.py) -- shared by render() and
|
||||
render_preview_png() so both honor render_style identically (weather
|
||||
once shipped with its preview endpoint bypassing render_style
|
||||
@@ -233,14 +186,17 @@ def _render_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int,
|
||||
dispatch point exists specifically so that bug can't happen here).
|
||||
palette_rgb is unused by the classic path (it never quantizes itself
|
||||
-- see module docstring), only threaded through for modern's own
|
||||
ordered_dither."""
|
||||
ordered_dither. theme_name is threaded through uniformly (every
|
||||
modern-style widget's dispatch takes one) but build_text ignores it
|
||||
-- see its own docstring for why (the text widget's font is a
|
||||
per-widget, user-authored choice, not theme-driven)."""
|
||||
if cfg.render_style == "modern":
|
||||
# Local import: html_render pulls in Playwright, a real headless-
|
||||
# Chromium dependency -- every other widget type, and this one's
|
||||
# own classic path, should never pay for it.
|
||||
from .. import html_render
|
||||
|
||||
return html_render.build_text(cfg, target_w, target_h, palette_rgb)
|
||||
return html_render.build_text(cfg, target_w, target_h, palette_rgb, theme_name)
|
||||
return _render_text(cfg, target_w, target_h)
|
||||
|
||||
|
||||
@@ -252,10 +208,11 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
cfg = db.get(TextWidgetConfig, widget.id)
|
||||
if cfg is None or not has_text(cfg.content):
|
||||
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"])
|
||||
return _render_dispatch(cfg, target_w, target_h, frame.palette_rgb)
|
||||
return _render_dispatch(cfg, target_w, target_h, frame.palette_rgb, frame.theme)
|
||||
|
||||
|
||||
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None) -> bytes:
|
||||
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None,
|
||||
theme_name: str | None = None) -> bytes:
|
||||
"""A normal browser-viewable PNG at full logical panel size --
|
||||
mirrors calendar_render.render_tasks_preview_png's relationship to
|
||||
render_tasks (the dialog's own preview endpoint always renders at
|
||||
@@ -264,7 +221,7 @@ def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: lis
|
||||
import io
|
||||
|
||||
target_w, target_h = logical_render_size(orientation)
|
||||
img = _render_dispatch(cfg, target_w, target_h, palette_rgb)
|
||||
img = _render_dispatch(cfg, target_w, target_h, palette_rgb, theme_name)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
|
||||
@@ -40,7 +40,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
from .. import html_render
|
||||
|
||||
return html_render.build(cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
|
||||
city_label=cfg.city_label or "")
|
||||
city_label=cfg.city_label or "", theme_name=frame.theme)
|
||||
|
||||
return weather_render.build(
|
||||
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
|
||||
|
||||
@@ -43,7 +43,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
# own classic path, should never pay for it.
|
||||
from .. import html_render
|
||||
|
||||
return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb)
|
||||
return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
|
||||
"whiteboard")
|
||||
return composed
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""app.html_render's shared ordered-dithering primitives -- ordered_dither
|
||||
and ordered_dither_regions -- exercised directly against synthetic
|
||||
images, no Chromium/Playwright involved (these two functions run purely
|
||||
on whatever Image render_html_to_image already handed back)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app import html_render
|
||||
from app.image_pipeline import DEFAULT_PALETTE_RGB
|
||||
|
||||
_PALETTE = set(DEFAULT_PALETTE_RGB)
|
||||
# A muddy hue nowhere near any of the 6 exact palette colors -- at the
|
||||
# "modern" style's tuned default amplitude (48), this should still snap
|
||||
# flatly to a single nearest ink (see theme_tokens.py's module docstring
|
||||
# for why 48 was chosen for icon/text legibility); only a much higher
|
||||
# amplitude (as a rich theme's accent_amplitude would use) stipples it
|
||||
# into a multi-ink approximation.
|
||||
_RICH_HUE = (168, 75, 42) # a terracotta-ish RGB, not one of the 6 inks
|
||||
|
||||
|
||||
def test_ordered_dither_output_is_exact_palette_colors():
|
||||
img = Image.new("RGB", (40, 30), (128, 128, 128))
|
||||
dithered = html_render.ordered_dither(img, None)
|
||||
assert set(dithered.getdata()) <= _PALETTE
|
||||
|
||||
|
||||
def test_ordered_dither_regions_outside_the_region_matches_plain_dither():
|
||||
"""Pixels outside every accent_regions rect must come out identical
|
||||
to a plain ordered_dither call at base_amplitude -- the region-aware
|
||||
variant must not perturb anything it wasn't asked to."""
|
||||
img = Image.new("RGB", (100, 80), (90, 140, 200))
|
||||
base_only = html_render.ordered_dither(img, None, amplitude=48.0)
|
||||
regions = html_render.ordered_dither_regions(
|
||||
img, None, base_amplitude=48.0, accent_regions=[((10, 10, 40, 30), 130.0)]
|
||||
)
|
||||
for x in range(100):
|
||||
for y in range(80):
|
||||
if 10 <= x < 40 and 10 <= y < 30:
|
||||
continue # inside the accent region -- expected to differ
|
||||
assert regions.getpixel((x, y)) == base_only.getpixel((x, y))
|
||||
|
||||
|
||||
def test_ordered_dither_regions_stipples_a_rich_hue_the_base_amplitude_would_flatten():
|
||||
"""The whole reason ordered_dither_regions exists: a rich accent hue
|
||||
dithered at the base (icon/text-safe) amplitude just snaps to one
|
||||
nearest ink, but the same hue in an accent region at a theme's higher
|
||||
accent_amplitude resolves to a believable multi-ink stipple instead --
|
||||
assert that difference directly, not just "some image came back"."""
|
||||
img = Image.new("RGB", (60, 60), _RICH_HUE)
|
||||
rect = (0, 0, 60, 60)
|
||||
|
||||
flat = html_render.ordered_dither(img, None, amplitude=48.0)
|
||||
richer = html_render.ordered_dither_regions(img, None, base_amplitude=48.0, accent_regions=[(rect, 130.0)])
|
||||
|
||||
assert len(set(flat.getdata())) == 1
|
||||
assert len(set(richer.getdata())) > 1
|
||||
assert set(richer.getdata()) <= _PALETTE
|
||||
|
||||
|
||||
def test_ordered_dither_regions_with_no_accent_regions_matches_plain_dither():
|
||||
img = Image.new("RGB", (30, 30), (50, 60, 70))
|
||||
assert list(html_render.ordered_dither_regions(img, None, base_amplitude=48.0).getdata()) == \
|
||||
list(html_render.ordered_dither(img, None, amplitude=48.0).getdata())
|
||||
@@ -0,0 +1,92 @@
|
||||
"""app.theme_tokens -- resolve_theme()'s fallback logic (does "classic"
|
||||
stay a byte-identical no-op vs. each widget kind's pre-theme-system
|
||||
look?) and the font table every preset resolves against. No rendering
|
||||
here -- see test_html_render.py for ordered_dither_regions and each
|
||||
widget's own test_widgets_*.py for dispatch-level "does a theme actually
|
||||
change the output" coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app import panel_style, theme_tokens
|
||||
|
||||
|
||||
def test_classic_weather_matches_historical_fixed_gradient():
|
||||
"""Weather's modern style never went through panel_style.THEME --
|
||||
its header was always this fixed blue gradient (see html_render.py's
|
||||
removed ACCENT_START/ACCENT_END). "classic" must reproduce it
|
||||
byte-for-byte so themes are additive, not a silent regression."""
|
||||
resolved = theme_tokens.resolve_theme("classic", "weather", None)
|
||||
assert resolved["accent_hex"] == "#1c4fd6"
|
||||
assert resolved["accent_hex_dark"] == "#6fa8ff"
|
||||
|
||||
|
||||
def test_classic_tasks_and_calendar_use_flat_theme_ink():
|
||||
"""Tasks/calendar's classic accent was always a flat single ink (no
|
||||
gradient) resolved through panel_style.THEME -- both ends of the
|
||||
"gradient" must be that same ink, not two different shades."""
|
||||
tasks = theme_tokens.resolve_theme("classic", "tasks", None)
|
||||
calendar = theme_tokens.resolve_theme("classic", "calendar", None)
|
||||
assert tasks["accent_hex"] == tasks["accent_hex_dark"]
|
||||
assert calendar["accent_hex"] == calendar["accent_hex_dark"]
|
||||
|
||||
from app.image_pipeline import DEFAULT_PALETTE_RGB
|
||||
|
||||
expected_tasks = "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.THEME_TASKS])
|
||||
expected_calendar = "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.THEME_CALENDAR])
|
||||
assert tasks["accent_hex"] == expected_tasks
|
||||
assert calendar["accent_hex"] == expected_calendar
|
||||
|
||||
|
||||
def test_classic_unmapped_widget_kind_falls_back_to_black():
|
||||
resolved = theme_tokens.resolve_theme("classic", "battery", None)
|
||||
from app.image_pipeline import DEFAULT_PALETTE_RGB
|
||||
|
||||
assert resolved["accent_hex"] == "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.BLACK])
|
||||
|
||||
|
||||
def test_unknown_theme_name_falls_back_to_classic():
|
||||
assert theme_tokens.resolve_theme("not-a-real-theme", "weather", None) == \
|
||||
theme_tokens.resolve_theme("classic", "weather", None)
|
||||
assert theme_tokens.resolve_theme(None, "weather", None) == \
|
||||
theme_tokens.resolve_theme("classic", "weather", None)
|
||||
|
||||
|
||||
def test_every_preset_resolves_without_error_for_every_widget_kind():
|
||||
widget_kinds = ["weather", "tasks", "calendar", "battery", "text", "static", "whiteboard"]
|
||||
for theme_name in theme_tokens.THEMES:
|
||||
for kind in widget_kinds:
|
||||
resolved = theme_tokens.resolve_theme(theme_name, kind, None)
|
||||
assert resolved["accent_hex"].startswith("#") and len(resolved["accent_hex"]) == 7
|
||||
assert resolved["accent_hex_dark"].startswith("#") and len(resolved["accent_hex_dark"]) == 7
|
||||
assert resolved["font_family"] in theme_tokens.FONT_FAMILIES
|
||||
assert Path(resolved["font_regular"]).exists()
|
||||
assert Path(resolved["font_bold"]).exists()
|
||||
|
||||
|
||||
def test_non_gradient_theme_has_equal_accent_stops():
|
||||
""""moss" is configured with gradient=False -- its two CSS gradient
|
||||
stops must be identical (a flat fill), unlike a gradient theme's."""
|
||||
resolved = theme_tokens.resolve_theme("moss", "tasks", None)
|
||||
assert resolved["gradient"] is False
|
||||
assert resolved["accent_hex"] == resolved["accent_hex_dark"]
|
||||
|
||||
|
||||
def test_gradient_theme_has_a_darker_second_stop():
|
||||
resolved = theme_tokens.resolve_theme("terracotta", "tasks", None)
|
||||
assert resolved["gradient"] is True
|
||||
assert resolved["accent_hex"] == "#a84b2a"
|
||||
assert resolved["accent_hex_dark"] != resolved["accent_hex"]
|
||||
|
||||
|
||||
def test_font_path_covers_every_family_and_style_combination():
|
||||
for family in theme_tokens.FONT_FAMILIES:
|
||||
for bold in (False, True):
|
||||
for italic in (False, True):
|
||||
assert theme_tokens.font_path(family, bold, italic).exists()
|
||||
|
||||
|
||||
def test_font_path_falls_back_to_default_family_for_unknown_name():
|
||||
assert theme_tokens.font_path("not-a-real-family", False, False) == \
|
||||
theme_tokens.font_path(theme_tokens.DEFAULT_FONT_FAMILY, False, False)
|
||||
@@ -208,3 +208,35 @@ def test_render_modern_month_falls_back_to_agenda_below_small_tier(db_session, m
|
||||
|
||||
img = widgets.calendar.render(db_session, frame, widget, 300, 192)
|
||||
assert img.size == (300, 192)
|
||||
|
||||
|
||||
def test_render_modern_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
|
||||
"""Same spy-on-resolve_theme approach as test_widgets_weather.py's
|
||||
equivalent test -- confirms calendar.py's render() passes frame.theme
|
||||
into calendar_html_render.build (proving the widget-level threading,
|
||||
not re-testing ordered_dither_regions itself, which
|
||||
test_html_render.py already covers)."""
|
||||
from app import calendar_html_render, theme_tokens
|
||||
|
||||
calls = []
|
||||
real_resolve = theme_tokens.resolve_theme
|
||||
|
||||
def _spy(theme_name, widget_kind, palette_rgb):
|
||||
calls.append((theme_name, widget_kind))
|
||||
return real_resolve(theme_name, widget_kind, palette_rgb)
|
||||
|
||||
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
|
||||
monkeypatch.setattr(calendar_html_render, "theme_tokens", theme_tokens)
|
||||
|
||||
frame, widget = _make_widget(db_session, view="agenda", render_style="modern")
|
||||
frame.theme = "moss"
|
||||
# calendar.py's render() takes widget_locked's write path (it resets
|
||||
# browse_offset on a normal wake), which commits and would otherwise
|
||||
# expire-and-reload frame from the DB, discarding this uncommitted
|
||||
# attribute change.
|
||||
db_session.commit()
|
||||
_stub_fetches(monkeypatch)
|
||||
_stub_render_html_to_image(monkeypatch)
|
||||
|
||||
widgets.calendar.render(db_session, frame, widget, 380, 300)
|
||||
assert ("moss", "calendar") in calls
|
||||
|
||||
@@ -153,3 +153,33 @@ def test_render_modern_style(db_session, monkeypatch):
|
||||
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_render_modern_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
|
||||
"""Same spy-on-resolve_theme approach as test_widgets_weather.py's
|
||||
equivalent test -- confirms tasks.py's render() passes frame.theme
|
||||
into html_render.build_tasks (proving the widget-level threading, not
|
||||
re-testing ordered_dither_regions itself, which test_html_render.py
|
||||
already covers)."""
|
||||
from PIL import Image
|
||||
|
||||
from app import html_render, theme_tokens
|
||||
|
||||
calls = []
|
||||
real_resolve = theme_tokens.resolve_theme
|
||||
|
||||
def _spy(theme_name, widget_kind, palette_rgb):
|
||||
calls.append((theme_name, widget_kind))
|
||||
return real_resolve(theme_name, widget_kind, palette_rgb)
|
||||
|
||||
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
|
||||
monkeypatch.setattr(html_render, "theme_tokens", theme_tokens)
|
||||
monkeypatch.setattr(html_render, "render_html_to_image",
|
||||
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
|
||||
|
||||
frame, widget = _make_widget(db_session, render_style="modern")
|
||||
frame.theme = "slate"
|
||||
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
|
||||
|
||||
widgets.tasks.render(db_session, frame, widget, 300, 200)
|
||||
assert ("slate", "tasks") in calls
|
||||
|
||||
@@ -187,3 +187,34 @@ def test_render_style_default_is_classic(db_session, monkeypatch):
|
||||
frame, widget = _make_widget(db_session, mode="current")
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.render_style == "classic"
|
||||
|
||||
|
||||
def test_render_modern_daily_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
|
||||
"""widgets/weather.py's render() must pass frame.theme all the way
|
||||
into html_render.build_daily's theme resolution -- spies on
|
||||
theme_tokens.resolve_theme (still delegating to the real
|
||||
implementation) rather than diffing final pixels, since the stubbed
|
||||
render_html_to_image below never actually executes the template's CSS
|
||||
(that's the whole point of stubbing out Chromium), so a theme's
|
||||
accent color has nothing to visibly change in the fake screenshot."""
|
||||
from app import theme_tokens
|
||||
|
||||
calls = []
|
||||
real_resolve = theme_tokens.resolve_theme
|
||||
|
||||
def _spy(theme_name, widget_kind, palette_rgb):
|
||||
calls.append((theme_name, widget_kind))
|
||||
return real_resolve(theme_name, widget_kind, palette_rgb)
|
||||
|
||||
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
|
||||
monkeypatch.setattr(html_render, "theme_tokens", theme_tokens)
|
||||
|
||||
daily = {"2026-07-31": {"high": 75, "low": 55, "category": "clear"}}
|
||||
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
|
||||
_stub_render_html_to_image(monkeypatch)
|
||||
|
||||
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", render_style="modern")
|
||||
frame.theme = "terracotta"
|
||||
widgets.weather.render(db_session, frame, widget, 200, 160)
|
||||
|
||||
assert ("terracotta", "weather") in calls
|
||||
|
||||
Reference in New Issue
Block a user