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:
@@ -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