feat(element-style): per-mode style and offset overrides

Lets one element be styled differently per situation -- a scoreboard's
live / upcoming / recent cards, weather's current / hourly / daily
screens -- under customization.modes.<mode>.

The mode is bound at construction rather than passed per call. That is
what makes this cheap to adopt: SportsUpcoming and SportsRecent are
already separate instances with distinct SKIN_MODE values, so binding
once makes every existing style()/offset_value() call site mode-aware
without editing any of them. A per-call mode argument exists for the rare
host that renders more than one mode.

The two layers answer different questions, deliberately:

- The base layer keeps the existing "differs from the schema default"
  rule, because the save flow writes the full default object into
  config.json whether or not the user touched it.
- A mode layer is pure override -- its fields default to None, so
  presence is intent. Nothing writes into it unasked, so there is nothing
  for the stricter rule to protect against.

None therefore means inherit, and has to stay distinct from 0: a mode
y_offset of 0 means "sit at the base position", not "no preference".
This is the same distinction scroll_card.switch_* draws with "inherit".

A malformed mode value falls back to the resolved base value rather than
to the caller's default -- caught by the degradation tests, which is what
they are for: resolving the mode first let one bad string in a mode block
silently discard a good base offset.

With no modes block, and for every existing caller, resolution is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-10 11:10:35 -04:00
co-authored by Claude Opus 5
parent 2cfbc1e969
commit 9998aaf51c
2 changed files with 316 additions and 30 deletions
+141 -30
View File
@@ -523,10 +523,27 @@ class ElementStyleResolver:
from the schema default (see module docstring); otherwise ``style()``
returns the caller's classic values verbatim, keeping untouched configs
byte-identical to pre-customization rendering.
**Modes.** A plugin that displays the same element in more than one
situation — a scoreboard's live / upcoming / recent cards, weather's
current / hourly / daily screens — can let the user style each one
separately under ``customization.modes.<mode>``. The mode is normally
bound once at construction rather than passed per call, because the
natural owner already knows it: SportsUpcoming and SportsRecent are
distinct instances with distinct ``SKIN_MODE`` values, so binding here
makes every existing call site mode-aware without touching one of them.
A mode layer is pure override. Its fields default to ``None``, which
means *inherit*, and any non-None value wins over the base element.
That is why ``None`` and a real value must stay distinguishable: a mode
offset of ``0`` means "sit at the base position", not "no preference"
— the same distinction ``scroll_card.switch_*`` draws with its
``"inherit"`` sentinel.
"""
def __init__(self, config: Optional[Dict[str, Any]],
defaults: Optional[Dict[str, Any]] = None):
defaults: Optional[Dict[str, Any]] = None,
mode: Optional[str] = None):
# Keep the exact object for identity-based invalidation, even if the
# caller hands us something odd; reads are guarded.
self._config = config
@@ -536,8 +553,14 @@ class ElementStyleResolver:
element_defaults = {}
self._defaults: Dict[str, Any] = (
element_defaults if isinstance(element_defaults, dict) else {})
self._mode = mode if isinstance(mode, str) and mode else None
self._memo: Dict[Any, ElementStyle] = {}
@property
def mode(self) -> Optional[str]:
"""The mode bound at construction, if any."""
return self._mode
# -- internal accessors -------------------------------------------------
def _customization(self) -> Dict[str, Any]:
@@ -553,12 +576,34 @@ class ElementStyleResolver:
defaults = self._defaults.get(element_key, {})
return defaults if isinstance(defaults, dict) else {}
def _mode_block(self, mode: Optional[str]) -> Dict[str, Any]:
"""``customization.modes.<mode>``, or {} when there is no such block."""
if not mode:
return {}
modes = self._customization().get('modes', {})
if not isinstance(modes, dict):
return {}
block = modes.get(mode, {})
return block if isinstance(block, dict) else {}
def _mode_element_config(self, element_key: str,
mode: Optional[str]) -> Dict[str, Any]:
element = self._mode_block(mode).get(element_key, {})
return element if isinstance(element, dict) else {}
def _effective_mode(self, mode: Any) -> Optional[str]:
"""A per-call mode overrides the bound one; anything else uses it."""
if isinstance(mode, str) and mode:
return mode
return self._mode
# -- public API ---------------------------------------------------------
def style(self, element_key: str,
classic_font: str = _FALLBACK_FONT_NAME,
classic_size: int = 8,
classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle:
classic_color: Optional[Tuple[int, int, int]] = None,
mode: Optional[str] = None) -> ElementStyle:
"""Resolve one element's style. Never raises.
Args:
@@ -570,14 +615,19 @@ class ElementStyleResolver:
classic_color: Classic RGB color, or None when the caller only
cares about the font (``.color`` then falls back to the
schema default color, else white).
mode: Overrides the mode bound at construction for this call.
Rarely needed — a host that renders one mode should bind it
once instead.
Returns:
ElementStyle with the loaded font face, RGB color, (x, y)
offset, and the ``user_forced`` / ``user_forced_color`` flags.
"""
effective_mode = self._effective_mode(mode)
try:
memo_key = (element_key, classic_font, classic_size,
_normalize_color(classic_color) or classic_color)
_normalize_color(classic_color) or classic_color,
effective_mode)
memoized = self._memo.get(memo_key)
if memoized is not None:
return memoized
@@ -586,7 +636,8 @@ class ElementStyleResolver:
try:
resolved = self._resolve(element_key, classic_font,
classic_size, classic_color)
classic_size, classic_color,
effective_mode)
except Exception as e:
logger.warning("Error resolving style for element '%s': %s — "
"using classic style", element_key, e)
@@ -596,41 +647,45 @@ class ElementStyleResolver:
self._memo[memo_key] = resolved
return resolved
def offset(self, element_key: str) -> Tuple[int, int]:
def offset(self, element_key: str,
mode: Optional[str] = None) -> Tuple[int, int]:
"""The user's ``customization.layout.<element>`` (x, y) pixel
offset, defaulting to (0, 0). Never raises."""
return (self.offset_value(element_key, 'x_offset', 0),
self.offset_value(element_key, 'y_offset', 0))
return (self.offset_value(element_key, 'x_offset', 0, mode),
self.offset_value(element_key, 'y_offset', 0, mode))
def offset_value(self, element_key: str, axis: str, default: int = 0) -> int:
def offset_value(self, element_key: str, axis: str, default: int = 0,
mode: Optional[str] = None) -> int:
"""One ``customization.layout.<element>.<axis>`` value as an int.
``axis`` is usually ``'x_offset'`` / ``'y_offset'`` but any key is
honored (e.g. the scoreboards' ``'away_x_offset'``). Numeric
strings are coerced; anything else degrades to ``default``. Never
raises.
When a mode is in play, ``customization.modes.<mode>.layout`` is
consulted first and wins if it carries a non-None value for this
axis — ``None`` there means inherit the base offset, which is what
lets a mode nudge one element without restating the rest.
"""
try:
layout = self._customization().get('layout', {})
if not isinstance(layout, dict):
return int(default)
element = layout.get(element_key, {})
if not isinstance(element, dict):
return int(default)
value = element.get(axis, default)
if isinstance(value, bool):
return int(default)
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str):
try:
return int(float(value))
except (TypeError, ValueError):
logger.warning(
"Invalid layout offset for %s.%s: %r, using %s",
element_key, axis, value, default)
return int(default)
return int(default)
# Base first, so it is the thing a malformed mode value falls
# back to. Resolving the mode first and passing the caller's
# default would let one bad string in a mode block silently
# discard a perfectly good base offset.
base = self._layout_axis(self._customization(), element_key, axis)
base_value = (int(default) if base is None
else self._coerce_offset(base, default,
element_key, axis))
effective_mode = self._effective_mode(mode)
if effective_mode:
override = self._layout_axis(
self._mode_block(effective_mode), element_key, axis)
if override is not None:
return self._coerce_offset(override, base_value,
element_key, axis)
return base_value
except Exception as e:
logger.warning("Error reading layout offset %s.%s: %s",
element_key, axis, e)
@@ -639,13 +694,56 @@ class ElementStyleResolver:
except (TypeError, ValueError):
return 0
@staticmethod
def _layout_axis(block: Dict[str, Any], element_key: str,
axis: str) -> Any:
"""``block['layout'][element][axis]``, or None if absent anywhere."""
layout = block.get('layout', {})
if not isinstance(layout, dict):
return None
element = layout.get(element_key, {})
if not isinstance(element, dict):
return None
return element.get(axis)
@staticmethod
def _coerce_offset(value: Any, default: int, element_key: str,
axis: str) -> int:
"""A pixel offset as an int; anything nonsensical is ``default``.
A bool degrades rather than counting as 1/0 -- it is the more
correct reading of a pixel offset, and matches what the shared
resolver has always done relative to the classic inline read.
"""
if isinstance(value, bool):
return int(default)
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str):
try:
return int(float(value))
except (TypeError, ValueError):
logger.warning("Invalid layout offset for %s.%s: %r, using %s",
element_key, axis, value, default)
return int(default)
return int(default)
# -- resolution internals -----------------------------------------------
def _resolve(self, element_key: str, classic_font: str,
classic_size: int,
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
classic_color: Optional[Tuple[int, int, int]],
mode: Optional[str] = None) -> ElementStyle:
element_config = self._element_config(element_key)
element_defaults = self._element_defaults(element_key)
mode_config = self._mode_element_config(element_key, mode)
# The two layers answer different questions. The base layer asks
# "does this differ from the schema default?", because the save flow
# writes the full default object into config.json whether or not the
# user touched it. The mode layer asks only "is it set?", because its
# schema default is None -- there is nothing for a stray write to
# make look deliberate.
# Font family: forced only when it differs from the schema default
# (falling back to the classic font as the reference when the
@@ -668,6 +766,15 @@ class ElementStyleResolver:
font_name = configured_font if font_forced else classic_font
font_size = configured_size if size_forced else self._coerce_size(
classic_size, 8)
# Mode overrides sit on top of whatever the base layer settled on.
mode_font = mode_config.get('font')
if isinstance(mode_font, str) and mode_font:
font_name, font_forced = mode_font, True
mode_size = self._coerce_size(mode_config.get('font_size'), None)
if mode_size is not None:
font_size, size_forced = mode_size, True
user_forced = bool(font_forced or size_forced)
# Color: forced only when it differs from the schema default (or,
@@ -684,6 +791,10 @@ class ElementStyleResolver:
color = (_normalize_color(classic_color) or default_color
or (255, 255, 255))
mode_color = _normalize_color(mode_config.get('text_color'))
if mode_color is not None:
color, color_forced = mode_color, True
# font_size reports what was actually realised, which differs from
# the request only when a BDF snapped to its native strike. Callers
# lay out from this value; reporting the request would reserve space
@@ -692,7 +803,7 @@ class ElementStyleResolver:
return ElementStyle(
font=font,
color=color,
offset=self.offset(element_key),
offset=self.offset(element_key, mode),
font_name=font_name,
font_size=realised_size,
user_forced=user_forced,
+175
View File
@@ -506,3 +506,178 @@ class TestFontCacheIsBounded:
assert load_font("PressStart2P-Regular.ttf", 1) is oldest
finally:
es._font_cache.clear()
class TestModes:
"""Per-mode overrides: one element styled differently per situation.
The motivating case is a scoreboard, where the score wants a bigger font
on a live card than on an upcoming one. Live/Upcoming/Recent are already
separate instances with distinct SKIN_MODE values, so the mode is bound
to the resolver rather than threaded through every call site.
A mode layer is pure override: its fields default to None, meaning
inherit. That is why None and 0 must stay distinct -- a mode y_offset of
0 means "sit at the base position", not "no preference".
"""
def _resolver(self, config, mode=None, schema=None):
defaults = defaults_from_schema_file(schema) if schema else {}
return ElementStyleResolver(config, defaults, mode=mode)
def test_no_mode_block_resolves_exactly_as_before(self, style_schema_path):
config = {"customization": {"title_text": {"font_size": 12}}}
plain = self._resolver(config, schema=style_schema_path)
moded = self._resolver(config, mode="live", schema=style_schema_path)
a = plain.style("title_text", classic_size=8)
b = moded.style("title_text", classic_size=8)
assert (a.font_name, a.font_size, a.color) == (b.font_name, b.font_size,
b.color)
def test_a_mode_overrides_the_base_size(self, style_schema_path):
config = {"customization": {
"title_text": {"font_size": 12},
"modes": {"live": {"title_text": {"font_size": 16}}}}}
r = self._resolver(config, mode="live", schema=style_schema_path)
assert r.style("title_text", classic_size=8).font_size == 16
assert r.style("title_text", classic_size=8).user_forced is True
def test_a_different_mode_is_unaffected(self, style_schema_path):
config = {"customization": {
"title_text": {"font_size": 12},
"modes": {"live": {"title_text": {"font_size": 16}}}}}
r = self._resolver(config, mode="upcoming", schema=style_schema_path)
assert r.style("title_text", classic_size=8).font_size == 12
def test_two_resolvers_share_a_config_and_differ_by_mode(
self, style_schema_path):
"""The SportsUpcoming / SportsRecent case: same config dict, two
instances, two answers."""
config = {"customization": {
"title_text": {"font_size": 12},
"modes": {"live": {"title_text": {"font_size": 16}},
"recent": {"title_text": {"font_size": 6}}}}}
live = self._resolver(config, mode="live", schema=style_schema_path)
recent = self._resolver(config, mode="recent", schema=style_schema_path)
assert live.style("title_text", classic_size=8).font_size == 16
assert recent.style("title_text", classic_size=8).font_size == 6
def test_a_mode_overrides_font_and_colour(self, style_schema_path):
config = {"customization": {
"modes": {"live": {"title_text": {"font": "4x6-font.ttf",
"text_color": [1, 2, 3]}}}}}
st = self._resolver(config, mode="live",
schema=style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_color=(255, 255, 255))
assert st.font_name == "4x6-font.ttf"
assert st.color == (1, 2, 3)
assert st.user_forced and st.user_forced_color
def test_an_unset_mode_field_inherits_rather_than_resetting(
self, style_schema_path):
"""Only font_size is overridden; the colour must survive."""
config = {"customization": {
"title_text": {"text_color": [9, 9, 9]},
"modes": {"live": {"title_text": {"font_size": 16}}}}}
st = self._resolver(config, mode="live",
schema=style_schema_path).style(
"title_text", classic_color=(255, 255, 255))
assert st.font_size == 16
assert st.color == (9, 9, 9)
def test_a_null_mode_field_means_inherit(self, style_schema_path):
config = {"customization": {
"title_text": {"font_size": 12},
"modes": {"live": {"title_text": {"font_size": None}}}}}
st = self._resolver(config, mode="live",
schema=style_schema_path).style("title_text")
assert st.font_size == 12
def test_a_per_call_mode_overrides_the_bound_one(self, style_schema_path):
config = {"customization": {"modes": {
"live": {"title_text": {"font_size": 16}},
"recent": {"title_text": {"font_size": 6}}}}}
r = self._resolver(config, mode="live", schema=style_schema_path)
assert r.style("title_text").font_size == 16
assert r.style("title_text", mode="recent").font_size == 6
def test_the_memo_does_not_leak_between_modes(self, style_schema_path):
config = {"customization": {"modes": {
"live": {"title_text": {"font_size": 16}},
"recent": {"title_text": {"font_size": 6}}}}}
r = self._resolver(config, mode="live", schema=style_schema_path)
assert r.style("title_text").font_size == 16
assert r.style("title_text", mode="recent").font_size == 6
assert r.style("title_text").font_size == 16
def test_mode_is_exposed(self):
assert ElementStyleResolver({}, {}, mode="live").mode == "live"
assert ElementStyleResolver({}, {}).mode is None
class TestModeOffsets:
def _r(self, config, mode=None):
return ElementStyleResolver(config, {}, mode=mode)
def test_a_mode_offset_wins(self):
config = {"customization": {
"layout": {"score": {"y_offset": -2}},
"modes": {"live": {"layout": {"score": {"y_offset": 5}}}}}}
assert self._r(config, "live").offset_value("score", "y_offset") == 5
def test_an_absent_mode_offset_inherits_the_base(self):
config = {"customization": {
"layout": {"score": {"y_offset": -2}},
"modes": {"live": {"layout": {"score": {"x_offset": 1}}}}}}
r = self._r(config, "live")
assert r.offset_value("score", "y_offset") == -2
assert r.offset_value("score", "x_offset") == 1
def test_an_explicit_zero_is_an_override_not_an_absence(self):
"""The reason None is the inherit sentinel: 0 has to mean something."""
config = {"customization": {
"layout": {"score": {"y_offset": -2}},
"modes": {"live": {"layout": {"score": {"y_offset": 0}}}}}}
assert self._r(config, "live").offset_value("score", "y_offset") == 0
def test_a_null_mode_offset_inherits(self):
config = {"customization": {
"layout": {"score": {"y_offset": -2}},
"modes": {"live": {"layout": {"score": {"y_offset": None}}}}}}
assert self._r(config, "live").offset_value("score", "y_offset") == -2
def test_offset_pair_is_mode_aware(self):
config = {"customization": {
"layout": {"score": {"x_offset": 1, "y_offset": 2}},
"modes": {"live": {"layout": {"score": {"y_offset": 9}}}}}}
assert self._r(config, "live").offset("score") == (1, 9)
def test_an_arbitrary_axis_is_mode_aware(self):
"""The scoreboards use away_x_offset / home_x_offset."""
config = {"customization": {
"layout": {"records": {"away_x_offset": 3}},
"modes": {"recent": {"layout": {"records": {"away_x_offset": 7}}}}}}
assert self._r(config, "recent").offset_value(
"records", "away_x_offset") == 7
@pytest.mark.parametrize("modes", [
None, "nonsense", {"live": "nonsense"}, {"live": {"layout": 5}},
{"live": {"layout": {"score": {"y_offset": "bad"}}}},
])
def test_garbage_modes_degrade_to_the_base(self, modes):
config = {"customization": {"layout": {"score": {"y_offset": -2}},
"modes": modes}}
assert self._r(config, "live").offset_value("score", "y_offset") == -2
@pytest.mark.parametrize("modes", [
None, "nonsense", {"live": "nonsense"},
{"live": {"title_text": "nonsense"}},
{"live": {"title_text": {"font_size": "huge", "text_color": "red"}}},
])
def test_garbage_mode_styles_degrade(self, modes, style_schema_path):
config = {"customization": {"title_text": {"font_size": 12},
"modes": modes}}
st = ElementStyleResolver(
config, defaults_from_schema_file(style_schema_path),
mode="live").style("title_text", classic_size=8)
assert st.font_size == 12