mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
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:
+141
-30
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user