feat(element-style): visibility, alignment and scale per element

Completes the customization vocabulary: hide an element, align it, and
resize a logo, alongside the font/size/colour/offset that already existed.
All three per mode.

They resolve to "change nothing" until the user asks for something -- True,
None and 1.0 -- rather than to whatever the schema declares. That is the
same invariant the font fields keep: a caller that honours them still
renders an untouched config exactly as it did before they existed. A
schema default therefore does not count as a choice, which matters because
the save flow writes that default into config either way.

scale sits in the layout block with the offsets rather than in the element
block, because it is geometry: a logo has a scale and no font. The widget's
columns come from the schema, so a logo row shows visibility, offsets and
scale and no empty font cell.

Two bugs found by the tests rather than by reading:

- A nullable enum needs null in its enum list, not just in its type. The
  mode copy of `align` defaulted to null and then failed its own schema, so
  a plugin declaring any enum field with modes could not save at all. Six
  tests failed on this before any of them reached what they were testing.
- defaults_from_schema only ever extracted font/font_size/text_color, so
  the schema defaults for the new fields were invisible to the resolver and
  a declared default read as a user choice.

Widget: the table scrolls horizontally and pins the element-name column.
Nine columns do not fit the config panel, and clipping them hid the offsets
entirely while scrolling them made every row anonymous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-10 17:54:23 -04:00
co-authored by Claude Opus 5
parent 90d6d91c27
commit 521f81272f
5 changed files with 799 additions and 101 deletions
+199 -6
View File
@@ -91,7 +91,7 @@ def _cache_put(key: Tuple[str, int], value: Tuple[Any, int]) -> None:
_font_cache.popitem(last=False) _font_cache.popitem(last=False)
# Config keys a style element block carries, in schema/UI order. # Config keys a style element block carries, in schema/UI order.
_STYLE_KEYS = ('font', 'font_size', 'text_color') _STYLE_KEYS = ('font', 'font_size', 'text_color', 'visible', 'align')
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -106,6 +106,14 @@ class ElementStyle:
user_forced: bool # font or size genuinely overridden user_forced: bool # font or size genuinely overridden
user_forced_color: bool # color genuinely overridden user_forced_color: bool # color genuinely overridden
# The three below default to "change nothing", so a caller that ignores
# them renders exactly as it did before they existed, and a caller that
# honours them sees a neutral value until the user actually asks for
# something. That is what keeps an untouched config byte-identical.
visible: bool = True # False hides the element entirely
align: Optional[str] = None # 'left'|'center'|'right'; None = caller's own
scale: float = 1.0 # size multiplier for images/logos
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Font loading (cwd-independent, cached) # Font loading (cwd-independent, cached)
@@ -412,6 +420,35 @@ def _element_block_from_spec(element_key: str,
properties['text_color'] = color_prop properties['text_color'] = color_prop
order.append('text_color') order.append('text_color')
# ``"visible": true`` is accepted as shorthand for
# ``{"default": true}`` -- the common case is a plugin saying only that
# the element can be hidden.
visible_spec = spec.get('visible')
if visible_spec is True or isinstance(visible_spec, dict):
default = True
if isinstance(visible_spec, dict):
default = bool(visible_spec.get('default', True))
properties['visible'] = {
'type': 'boolean',
'title': 'Show',
'default': default,
'x-widget': 'toggle-switch',
}
order.append('visible')
align_spec = spec.get('align')
if align_spec is True or isinstance(align_spec, dict):
align_prop: Dict[str, Any] = {
'type': 'string',
'title': 'Align',
'enum': list(_ALIGNMENTS),
'x-advanced': True,
}
if isinstance(align_spec, dict) and 'default' in align_spec:
align_prop['default'] = align_spec['default']
properties['align'] = align_prop
order.append('align')
return { return {
'type': 'object', 'type': 'object',
'title': spec.get('title', element_key), 'title': spec.get('title', element_key),
@@ -424,21 +461,44 @@ def _element_block_from_spec(element_key: str,
def _offset_block_from_spec(element_key: str, def _offset_block_from_spec(element_key: str,
spec: Dict[str, Any]) -> Dict[str, Any]: spec: Dict[str, Any]) -> Dict[str, Any]:
"""Build one layout.<element> offset block (x/y, default 0).""" """Build one layout.<element> block: x/y offsets, and scale if declared."""
axis = { axis = {
'type': 'integer', 'type': 'integer',
'default': 0, 'default': 0,
'x-advanced': True, 'x-advanced': True,
} }
properties: Dict[str, Any] = {
'x_offset': dict(axis, title='X Offset'),
'y_offset': dict(axis, title='Y Offset'),
}
# scale sits here rather than in the element block because it is
# geometry, like the offsets: a logo has a scale and no font, and the
# renderer applies both when it places the thing.
scale_spec = spec.get('scale')
if scale_spec is True or isinstance(scale_spec, dict):
scale_prop: Dict[str, Any] = {
'type': 'number',
'title': 'Scale',
'description': 'Size multiplier; 1 is the shipped size.',
'default': 1.0,
'minimum': 0.1,
'maximum': 10.0,
'x-advanced': True,
}
if isinstance(scale_spec, dict):
for key, prop_key in (('default', 'default'),
('min', 'minimum'), ('max', 'maximum')):
if key in scale_spec:
scale_prop[prop_key] = scale_spec[key]
properties['scale'] = scale_prop
return { return {
'type': 'object', 'type': 'object',
'title': spec.get('title', element_key), 'title': spec.get('title', element_key),
'x-style-managed': True, 'x-style-managed': True,
'additionalProperties': False, 'additionalProperties': False,
'properties': { 'properties': properties,
'x_offset': dict(axis, title='X Offset'),
'y_offset': dict(axis, title='Y Offset'),
},
} }
@@ -458,6 +518,13 @@ def _nullable(prop: Dict[str, Any]) -> Dict[str, Any]:
types = list(types) + ['null'] types = list(types) + ['null']
out['type'] = types out['type'] = types
out['default'] = None out['default'] = None
# An enum constrains the value independently of the type, so widening
# the type is not enough: null has to be an allowed choice too, or the
# default this function just set fails its own schema. That is not a
# corner case -- the save flow writes the default into config, so a
# plugin declaring an enum field with modes could not save at all.
if isinstance(out.get('enum'), list) and None not in out['enum']:
out['enum'] = list(out['enum']) + [None]
return out return out
@@ -534,6 +601,9 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
Elements with no declared defaults are omitted. Never raises. Elements with no declared defaults are omitted. Never raises.
""" """
elements: Dict[str, Dict[str, Any]] = {} elements: Dict[str, Dict[str, Any]] = {}
# Layout defaults live alongside the elements under the reserved
# 'layout' key, mirroring the config shape, so one dict carries both.
layout: Dict[str, Dict[str, Any]] = {}
try: try:
customization = schema.get('properties', {}).get('customization') customization = schema.get('properties', {}).get('customization')
if not isinstance(customization, dict): if not isinstance(customization, dict):
@@ -554,11 +624,33 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
color_spec = spec.get('color') color_spec = spec.get('color')
if isinstance(color_spec, dict) and 'default' in color_spec: if isinstance(color_spec, dict) and 'default' in color_spec:
defaults['text_color'] = list(color_spec['default']) defaults['text_color'] = list(color_spec['default'])
visible_spec = spec.get('visible')
if visible_spec is True:
defaults['visible'] = True
elif isinstance(visible_spec, dict) and 'default' in visible_spec:
defaults['visible'] = bool(visible_spec['default'])
align_spec = spec.get('align')
if isinstance(align_spec, dict) and 'default' in align_spec:
defaults['align'] = align_spec['default']
scale_spec = spec.get('scale')
if isinstance(scale_spec, dict) and 'default' in scale_spec:
layout.setdefault(element_key, {})['scale'] = scale_spec['default']
elif scale_spec is True:
layout.setdefault(element_key, {})['scale'] = 1.0
if defaults: if defaults:
elements[element_key] = defaults elements[element_key] = defaults
properties = customization.get('properties') properties = customization.get('properties')
if isinstance(properties, dict): if isinstance(properties, dict):
layout_block = properties.get('layout')
if isinstance(layout_block, dict):
for element_key, block in (
layout_block.get('properties') or {}).items():
if not isinstance(block, dict):
continue
scale_prop = (block.get('properties') or {}).get('scale')
if isinstance(scale_prop, dict) and 'default' in scale_prop:
layout.setdefault(element_key, {})['scale'] = scale_prop['default']
for element_key, block in properties.items(): for element_key, block in properties.items():
if element_key in ('layout', 'modes') or element_key in elements: if element_key in ('layout', 'modes') or element_key in elements:
continue continue
@@ -576,6 +668,8 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
elements[element_key] = defaults elements[element_key] = defaults
except Exception as e: except Exception as e:
logger.warning("Error extracting style defaults from schema: %s", e) logger.warning("Error extracting style defaults from schema: %s", e)
if layout:
elements['layout'] = layout
return {'customization': elements} return {'customization': elements}
@@ -612,6 +706,55 @@ def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
return None return None
def _coerce_bool(value: Any, default: bool) -> bool:
"""A real bool, or ``default``. Accepts the strings a form may post."""
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ('true', 'yes', 'on', '1'):
return True
if lowered in ('false', 'no', 'off', '0'):
return False
return default
_ALIGNMENTS = ('left', 'center', 'right')
def _coerce_align(value: Any) -> Optional[str]:
"""One of left/center/right, or None for anything else.
None means "no preference", which is what an unset value resolves to --
the caller keeps whatever alignment it already did.
"""
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in _ALIGNMENTS:
return lowered
if lowered in ('centre', 'middle'): # the spelling users try
return 'center'
return None
def _coerce_scale(value: Any, default: float) -> float:
"""A positive size multiplier, or ``default``.
Clamped rather than merely validated: a scale of 0 or a negative one is
a zero-or-inverted image, and the panel is 32 pixels tall -- a typo
should cost a wrong size, not a crash inside PIL.
"""
if isinstance(value, bool) or value is None:
return default
try:
scale = float(value)
except (TypeError, ValueError):
return default
if scale <= 0:
return default
return min(scale, 10.0)
class ElementStyleResolver: class ElementStyleResolver:
"""Resolves per-element user styling against schema defaults. """Resolves per-element user styling against schema defaults.
@@ -796,6 +939,16 @@ class ElementStyleResolver:
except (TypeError, ValueError): except (TypeError, ValueError):
return 0 return 0
@staticmethod
def _layout_element(block: Dict[str, Any],
element_key: str) -> Dict[str, Any]:
"""``block['layout'][element]``, or {} if absent anywhere."""
layout = block.get('layout', {})
if not isinstance(layout, dict):
return {}
element = layout.get(element_key, {})
return element if isinstance(element, dict) else {}
@staticmethod @staticmethod
def _layout_axis(block: Dict[str, Any], element_key: str, def _layout_axis(block: Dict[str, Any], element_key: str,
axis: str) -> Any: axis: str) -> Any:
@@ -832,6 +985,29 @@ class ElementStyleResolver:
# -- resolution internals ----------------------------------------------- # -- resolution internals -----------------------------------------------
def _forced(self, element_config: Dict[str, Any],
element_defaults: Dict[str, Any],
mode_config: Dict[str, Any], key: str) -> Any:
"""A field's value if the user genuinely chose one, else None.
Same two-layer rule the font/size/colour resolution uses: a mode
value counts whenever it is set, a base value only when it differs
from the schema default (the save flow writes that default in
whether or not the user touched it). Returning None for "not
chosen" lets the caller substitute a neutral value, which is how
an untouched config keeps rendering exactly as before.
"""
mode_value = mode_config.get(key)
if mode_value is not None:
return mode_value
configured = element_config.get(key)
if configured is None:
return None
if key in element_defaults and configured == element_defaults[key]:
return None
return configured
def _resolve(self, element_key: str, classic_font: str, def _resolve(self, element_key: str, classic_font: str,
classic_size: int, classic_size: int,
classic_color: Optional[Tuple[int, int, int]], classic_color: Optional[Tuple[int, int, int]],
@@ -897,6 +1073,20 @@ class ElementStyleResolver:
if mode_color is not None: if mode_color is not None:
color, color_forced = mode_color, True color, color_forced = mode_color, True
visible = self._forced(element_config, element_defaults,
mode_config, 'visible')
align = self._forced(element_config, element_defaults,
mode_config, 'align')
# scale is geometry, so it lives with the offsets rather than in the
# element block -- a logo has a scale and no font.
layout_defaults = self._defaults.get('layout', {})
scale = self._forced(
self._layout_element(self._customization(), element_key),
layout_defaults.get(element_key, {})
if isinstance(layout_defaults, dict) else {},
self._layout_element(self._mode_block(mode), element_key),
'scale')
# font_size reports what was actually realised, which differs from # font_size reports what was actually realised, which differs from
# the request only when a BDF snapped to its native strike. Callers # the request only when a BDF snapped to its native strike. Callers
# lay out from this value; reporting the request would reserve space # lay out from this value; reporting the request would reserve space
@@ -910,6 +1100,9 @@ class ElementStyleResolver:
font_size=realised_size, font_size=realised_size,
user_forced=user_forced, user_forced=user_forced,
user_forced_color=bool(color_forced), user_forced_color=bool(color_forced),
visible=_coerce_bool(visible, True),
align=_coerce_align(align),
scale=_coerce_scale(scale, 1.0),
) )
def _classic_style(self, classic_font: str, classic_size: int, def _classic_style(self, classic_font: str, classic_size: int,
+157
View File
@@ -824,3 +824,160 @@ class TestModeSaveRoundTrip:
"font_size": 99}}}}}, defaults) "font_size": 99}}}}}, defaults)
ok, _errors = sm.validate_config_against_schema(merged, schema) ok, _errors = sm.validate_config_against_schema(merged, schema)
assert not ok assert not ok
EXTRA_SCHEMA = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"x-style-modes": ["live"],
"x-style-elements": {
"score_text": {
"title": "Score",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 10},
"color": {"default": [255, 255, 255]},
"visible": True,
"align": {"default": "center"},
"offsets": True,
},
"home_logo": {
"title": "Home Logo",
"offsets": True,
"visible": True,
"scale": {"default": 1.0, "min": 0.25, "max": 4},
},
},
},
},
}
@pytest.fixture
def extra_schema_path(tmp_path):
path = tmp_path / "extra_schema.json"
path.write_text(json.dumps(EXTRA_SCHEMA), encoding="utf-8")
return str(path)
class TestVisibleAlignScale:
"""visible / align / scale all resolve to "change nothing" until asked.
That is the same invariant the font fields keep: a caller that honours
these must still render an untouched config exactly as it did before
they existed, so the neutral values are True / None / 1.0 rather than
whatever the schema happens to declare.
"""
def _style(self, config, mode=None, schema=None):
defaults = defaults_from_schema_file(schema) if schema else {}
return ElementStyleResolver(config, defaults, mode=mode).style(
"score_text", classic_font="PressStart2P-Regular.ttf",
classic_size=10, classic_color=(255, 255, 255))
def test_an_untouched_config_is_neutral(self, extra_schema_path):
st = self._style({}, schema=extra_schema_path)
assert (st.visible, st.align, st.scale) == (True, None, 1.0)
def test_a_schema_default_is_not_a_choice(self, extra_schema_path):
"""align defaults to 'center' in the schema, and the save flow writes
that into config -- which must not read as the user asking for it."""
config = {"customization": {"score_text": {"align": "center",
"visible": True}}}
st = self._style(config, schema=extra_schema_path)
assert st.align is None, "the plugin keeps its own alignment"
assert st.visible is True
def test_hiding_an_element(self, extra_schema_path):
config = {"customization": {"score_text": {"visible": False}}}
assert self._style(config, schema=extra_schema_path).visible is False
def test_a_real_alignment_choice_comes_through(self, extra_schema_path):
config = {"customization": {"score_text": {"align": "right"}}}
assert self._style(config, schema=extra_schema_path).align == "right"
@pytest.mark.parametrize("written,expected", [
("left", "left"), ("RIGHT", "right"), (" center ", "center"),
("centre", "center"), ("middle", "center"),
("sideways", None), ("", None), (5, None), (None, None),
])
def test_alignment_coercion(self, written, expected, extra_schema_path):
config = {"customization": {"score_text": {"align": written}}}
assert self._style(config, schema=extra_schema_path).align == expected
@pytest.mark.parametrize("written,expected", [
(2, 2.0), (0.5, 0.5), ("1.5", 1.5),
(0, 1.0), (-3, 1.0), # nonsense degrades, never inverts
(999, 10.0), # clamped: the panel is 32px tall
("huge", 1.0), (None, 1.0), (True, 1.0),
])
def test_scale_coercion(self, written, expected, extra_schema_path):
config = {"customization": {"layout": {"score_text": {"scale": written}}}}
assert self._style(config, schema=extra_schema_path).scale == expected
def test_scale_reads_from_the_layout_block(self, extra_schema_path):
"""scale is geometry, so it sits with the offsets -- a logo has a
scale and no font."""
config = {"customization": {"layout": {"score_text": {"scale": 2}}}}
assert self._style(config, schema=extra_schema_path).scale == 2.0
@pytest.mark.parametrize("field,value,attr,expected", [
("visible", False, "visible", False),
("align", "right", "align", "right"),
])
def test_a_mode_overrides_them(self, field, value, attr, expected,
extra_schema_path):
config = {"customization": {"modes": {
"live": {"score_text": {field: value}}}}}
st = self._style(config, mode="live", schema=extra_schema_path)
assert getattr(st, attr) == expected
def test_a_mode_overrides_scale(self, extra_schema_path):
config = {"customization": {
"layout": {"score_text": {"scale": 2}},
"modes": {"live": {"layout": {"score_text": {"scale": 3}}}}}}
assert self._style(config, mode="live",
schema=extra_schema_path).scale == 3.0
def test_an_unset_mode_field_inherits_the_base(self, extra_schema_path):
config = {"customization": {
"score_text": {"visible": False},
"modes": {"live": {"score_text": {"align": "right"}}}}}
st = self._style(config, mode="live", schema=extra_schema_path)
assert st.visible is False and st.align == "right"
class TestExtraFieldSchema:
def _props(self):
return expand_style_elements(
EXTRA_SCHEMA)["properties"]["customization"]["properties"]
def test_only_declared_subfields_are_emitted(self):
"""home_logo declares no font, so it gets no font control."""
assert list(self._props()["home_logo"]["properties"]) == ["visible"]
def test_a_text_element_gets_the_text_fields(self):
assert list(self._props()["score_text"]["properties"]) == [
"font", "font_size", "text_color", "visible", "align"]
def test_scale_is_emitted_with_the_offsets(self):
layout = self._props()["layout"]["properties"]
assert list(layout["home_logo"]["properties"]) == [
"x_offset", "y_offset", "scale"]
assert list(layout["score_text"]["properties"]) == [
"x_offset", "y_offset"], "scale is opt-in"
def test_declared_scale_bounds_are_kept(self):
scale = self._props()["layout"]["properties"]["home_logo"]["properties"]["scale"]
assert (scale["minimum"], scale["maximum"]) == (0.25, 4)
def test_align_offers_only_valid_choices(self):
align = self._props()["score_text"]["properties"]["align"]
assert align["enum"] == ["left", "center", "right"]
def test_the_mode_copies_are_nullable(self):
live = self._props()["modes"]["properties"]["live"]["properties"]
assert live["score_text"]["properties"]["visible"]["default"] is None
assert "null" in live["score_text"]["properties"]["visible"]["type"]
scale = live["layout"]["properties"]["home_logo"]["properties"]["scale"]
assert scale["default"] is None and "null" in scale["type"]
@@ -0,0 +1,212 @@
"""visible / align / scale, from the form the widget renders to the style the
display resolves.
These three are the difference between "restyle the text" and "lay the card
out", and each has a way of going quietly wrong in a form post:
- ``visible`` is a boolean, and an unchecked checkbox posts nothing at all,
so the widget carries the value in a hidden field. The save path has to
turn those strings into real booleans, or config.json grows "false"
strings that are truthy everywhere they are read.
- ``align`` is an enum, so an invalid value must not reach the renderer.
- ``scale`` is a float living in the layout block rather than the element
block, because a logo has a scale and no font.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"customization": {
"type": "object",
"x-style-modes": ["live"],
"x-style-elements": {
"score_text": {
"title": "Score",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 10, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"visible": True,
"align": {"default": "center"},
"offsets": True,
},
"home_logo": {
"title": "Home Logo",
"offsets": True,
"visible": True,
"scale": {"default": 1.0, "min": 0.25, "max": 4},
},
},
},
},
}
class _SaveOk:
status = type("S", (), {"value": "success"})()
message = None
@pytest.fixture
def post(tmp_path):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": {"enabled": True}}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return _SaveOk()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(extra):
form = {
"enabled": "true",
"customization.score_text.font": "PressStart2P-Regular.ttf",
"customization.score_text.font_size": "10",
"customization.score_text.text_color.0": "255",
"customization.score_text.text_color.1": "255",
"customization.score_text.text_color.2": "255",
"customization.score_text.visible": "true",
"customization.score_text.align": "center",
"customization.layout.score_text.x_offset": "0",
"customization.layout.score_text.y_offset": "0",
"customization.home_logo.visible": "true",
"customization.layout.home_logo.x_offset": "0",
"customization.layout.home_logo.y_offset": "0",
"customization.layout.home_logo.scale": "1",
}
form.update(extra)
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form)
return resp, store.get("demo", {})
def _style(cfg, element="score_text", mode=None):
from src.element_style import (ElementStyleResolver,
defaults_from_schema_file)
defaults = defaults_from_schema_file(str(pdir / "config_schema.json"))
return ElementStyleResolver(cfg, defaults, mode=mode).style(
element, classic_font="PressStart2P-Regular.ttf", classic_size=10,
classic_color=(255, 255, 255))
try:
yield _post, _style
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
class TestVisible:
def test_a_checked_box_stores_a_real_boolean(self, post):
_post, _ = post
resp, cfg = _post({})
assert resp.status_code == 200, resp.get_json()
stored = cfg["customization"]["score_text"]["visible"]
assert stored is True, f"stored {stored!r}, not a bool"
def test_hiding_an_element_survives_the_round_trip(self, post):
_post, _style = post
resp, cfg = _post({"customization.score_text.visible": "false"})
assert resp.status_code == 200, resp.get_json()
assert cfg["customization"]["score_text"]["visible"] is False
assert _style(cfg).visible is False
def test_the_default_is_not_read_as_a_choice(self, post):
"""The save flow writes the schema default in either way."""
_post, _style = post
_resp, cfg = _post({})
assert _style(cfg).visible is True
class TestAlign:
def test_a_choice_reaches_the_display(self, post):
_post, _style = post
resp, cfg = _post({"customization.score_text.align": "right"})
assert resp.status_code == 200, resp.get_json()
assert _style(cfg).align == "right"
def test_the_schema_default_resolves_to_no_preference(self, post):
"""'center' is what the schema declares, so the plugin keeps doing
whatever it already did rather than being told to centre."""
_post, _style = post
_resp, cfg = _post({})
assert _style(cfg).align is None
def test_an_invalid_alignment_is_rejected_on_save(self, post):
_post, _ = post
resp, _cfg = _post({"customization.score_text.align": "sideways"})
assert resp.status_code == 400
class TestScale:
def test_a_logo_scale_saves_into_the_layout_block(self, post):
_post, _style = post
resp, cfg = _post({"customization.layout.home_logo.scale": "2.5"})
assert resp.status_code == 200, resp.get_json()
assert cfg["customization"]["layout"]["home_logo"]["scale"] == 2.5
assert _style(cfg, "home_logo").scale == 2.5
def test_a_scale_outside_the_declared_range_is_rejected(self, post):
_post, _ = post
resp, _cfg = _post({"customization.layout.home_logo.scale": "99"})
assert resp.status_code == 400
def test_an_element_without_a_declared_scale_stays_neutral(self, post):
_post, _style = post
_resp, cfg = _post({})
assert _style(cfg, "score_text").scale == 1.0
class TestPerMode:
def test_a_mode_can_hide_what_the_base_shows(self, post):
_post, _style = post
resp, cfg = _post({"customization.modes.live.score_text.visible": "false",
"customization.modes.live.score_text.align": "",
"customization.modes.live.score_text.font": "",
"customization.modes.live.score_text.font_size": ""})
assert resp.status_code == 200, resp.get_json()
assert _style(cfg).visible is True
assert _style(cfg, mode="live").visible is False
def test_a_blank_mode_boolean_means_inherit(self, post):
_post, _style = post
resp, cfg = _post({"customization.score_text.visible": "false",
"customization.modes.live.score_text.visible": ""})
assert resp.status_code == 200, resp.get_json()
assert cfg["customization"]["modes"]["live"]["score_text"]["visible"] is None
assert _style(cfg, mode="live").visible is False, "inherits the base"
+29 -1
View File
@@ -1416,8 +1416,36 @@ button.bg-white {
/* ---- Style editor widget -------------------------------------------- */ /* ---- Style editor widget -------------------------------------------- */
/* One row per display element: label, font, size, colour, X, Y. The grid /* One row per display element: label, font, size, colour, X, Y. The grid
is declared once here so the header and the rows cannot drift apart. */ is declared once here so the header and the rows cannot drift apart. */
/* The plugin config panel is narrower than nine columns of controls, and a
clipped table hides the offsets entirely -- scroll the table rather than
crushing the controls or dropping columns. */
.style-editor {
overflow-x: auto;
}
.style-editor-table {
min-width: max-content;
}
/* Scrolled right, the element names would otherwise scroll away and the
row you are editing becomes anonymous. */
.style-editor-label,
.style-editor-head > :first-child {
position: sticky;
left: 0;
z-index: 1;
background-color: #ffffff;
}
.style-editor-head > :first-child {
background-color: #f9fafb;
}
.style-editor-row { .style-editor-row {
grid-template-columns: minmax(7rem, 1.4fr) minmax(8rem, 2fr) 5.5rem 4.5rem 4rem 4rem; /* Set by the widget: the column count depends on what the plugin
declares, so the track list cannot be fixed here. */
grid-template-columns: var(--style-editor-columns,
minmax(7rem, 1.4fr) minmax(8rem, 2fr) 5rem 4.5rem 5rem 5rem);
}
.style-editor-check {
width: 1rem;
height: 1rem;
} }
.style-editor-row .form-control { .style-editor-row .form-control {
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
@@ -1,13 +1,13 @@
/** /**
* Style Editor Widget * Style Editor Widget
* *
* One compact row per display element -- font, size, colour, X/Y nudge -- * One compact row per display element -- font, size, colour, alignment,
* instead of the nested accordions the generic object renderer produces. A * visibility, X/Y nudge, scale -- instead of the nested accordions the
* realistic scoreboard declares seven elements with layout offsets and three * generic object renderer produces. A realistic scoreboard declares seven
* modes, which comes to 65 nested sections and five levels of clicking to * elements with layout offsets and three modes, which comes to 65 nested
* reach one per-mode font size. * sections and five levels of clicking to reach one per-mode font size.
* *
* Two things about this widget are load-bearing: * Three things about this widget are load-bearing:
* *
* 1. It emits ordinary inputs with the same dotted names the generic * 1. It emits ordinary inputs with the same dotted names the generic
* renderer would produce (`customization.score_text.font`, * renderer would produce (`customization.score_text.font`,
@@ -16,13 +16,14 @@
* save/validate/merge pipeline is therefore untouched: no hidden JSON * save/validate/merge pipeline is therefore untouched: no hidden JSON
* blob, no new server-side parsing. * blob, no new server-side parsing.
* *
* 2. It is driven entirely by the schema block it is handed. It renders * 2. Its columns come from the schema, not from a list in here. A column
* whatever sub-fields each element declares, so fields added to the * appears when any element declares that field, so a plugin adding a
* schema later appear here without touching this file. * field to its schema gets a control without this file changing, and a
* logo that declares only offsets and a scale gets no empty font cell.
* *
* Mode tabs edit `customization.modes.<mode>`, whose fields mean "inherit" * 3. Mode tabs edit `customization.modes.<mode>`, whose fields mean
* when blank. Blank must therefore post as empty (-> null), never as 0, or * "inherit" when blank. Blank must post as empty (-> null), never as 0,
* every mode would pin itself to the base the first time it was saved. * or every mode would pin itself to the base the first time it was saved.
* *
* @module StyleEditorWidget * @module StyleEditorWidget
*/ */
@@ -78,6 +79,16 @@
return node; return node;
} }
/** Walk a nested value object by path segments. */
function at(value, path) {
var cur = value;
for (var i = 0; i < path.length; i++) {
if (cur === null || typeof cur !== 'object') { return undefined; }
cur = cur[path[i]];
}
return cur;
}
/** /**
* What a control should show: the configured value if there is one, * What a control should show: the configured value if there is one,
* else the schema default. * else the schema default.
@@ -96,16 +107,6 @@
return prop ? prop.default : undefined; return prop ? prop.default : undefined;
} }
/** Walk a nested value object by path segments. */
function at(value, path) {
var cur = value;
for (var i = 0; i < path.length; i++) {
if (cur === null || typeof cur !== 'object') { return undefined; }
cur = cur[path[i]];
}
return cur;
}
/** /**
* Element blocks in a customization schema, in declared order. * Element blocks in a customization schema, in declared order.
* `layout` and `modes` are containers, not elements. * `layout` and `modes` are containers, not elements.
@@ -115,7 +116,7 @@
var order = schema['x-propertyOrder'] || Object.keys(props); var order = schema['x-propertyOrder'] || Object.keys(props);
return order.filter(function (k) { return order.filter(function (k) {
return k !== 'layout' && k !== 'modes' return k !== 'layout' && k !== 'modes'
&& props[k] && props[k].type === 'object' && props[k].properties; && props[k] && props[k].properties;
}); });
} }
@@ -141,10 +142,58 @@
select.appendChild(opt); select.appendChild(opt);
}); });
if (current) { select.value = current; } if (current) { select.value = current; }
select.addEventListener('change', onChange); if (onChange) { select.addEventListener('change', onChange); }
return select; return select;
} }
function enumControl(name, current, choices, optional) {
var select = el('select', {
name: name,
class: 'form-control text-sm style-editor-enum'
});
if (optional) {
select.appendChild(el('option', { value: '', text: 'Inherit' }));
}
choices.forEach(function (c) {
select.appendChild(el('option', {
value: c,
text: String(c).replace(/_/g, ' ')
}));
});
if (current !== undefined && current !== null) { select.value = current; }
return select;
}
function booleanControl(name, current, optional) {
if (optional) {
// Three states, not two: on, off, and "follow the base". A bare
// checkbox cannot say the third, and an unchecked box would read
// as "hide this in live mode" rather than "no preference".
return enumControl(name, boolAsString(current),
['true', 'false'], true);
}
// An unchecked checkbox posts nothing at all, so the hidden field
// carries the value and the checkbox drives it. The save path turns
// "true"/"false" into a real boolean.
var wrap = el('div', { class: 'flex items-center' });
var hidden = el('input', { type: 'hidden', name: name });
var box = el('input', { type: 'checkbox', class: 'style-editor-check' });
box.checked = current !== false;
hidden.value = box.checked ? 'true' : 'false';
box.addEventListener('change', function () {
hidden.value = box.checked ? 'true' : 'false';
});
wrap.appendChild(box);
wrap.appendChild(hidden);
return wrap;
}
function boolAsString(value) {
if (value === true) { return 'true'; }
if (value === false) { return 'false'; }
return undefined;
}
function numberControl(name, current, prop, placeholder) { function numberControl(name, current, prop, placeholder) {
var input = el('input', { var input = el('input', {
type: 'number', type: 'number',
@@ -154,6 +203,10 @@
}); });
if (prop && prop.minimum !== undefined) { input.min = prop.minimum; } if (prop && prop.minimum !== undefined) { input.min = prop.minimum; }
if (prop && prop.maximum !== undefined) { input.max = prop.maximum; } if (prop && prop.maximum !== undefined) { input.max = prop.maximum; }
if (prop && prop.type
&& String(prop.type).indexOf('number') !== -1) {
input.step = 'any';
}
// Blank stays blank. For a mode field that is the inherit sentinel; // Blank stays blank. For a mode field that is the inherit sentinel;
// writing 0 here would pin the mode to the base on the next save. // writing 0 here would pin the mode to the base on the next save.
if (current !== undefined && current !== null) { input.value = current; } if (current !== undefined && current !== null) { input.value = current; }
@@ -175,10 +228,7 @@
var wrap = el('div', { class: 'flex items-center gap-1' }); var wrap = el('div', { class: 'flex items-center gap-1' });
var has = Array.isArray(current) && current.length >= 3; var has = Array.isArray(current) && current.length >= 3;
var channels = [0, 1, 2].map(function (i) { var channels = [0, 1, 2].map(function (i) {
var input = el('input', { var input = el('input', { type: 'hidden', name: name + '.' + i });
type: 'hidden',
name: name + '.' + i
});
if (has) { input.value = current[i]; } if (has) { input.value = current[i]; }
return input; return input;
}); });
@@ -192,6 +242,7 @@
} }
setSubmitted(!optional || has); setSubmitted(!optional || has);
var clearBtn = null;
var swatch = el('input', { var swatch = el('input', {
type: 'color', type: 'color',
class: 'style-editor-colour', class: 'style-editor-colour',
@@ -208,7 +259,6 @@
wrap.appendChild(swatch); wrap.appendChild(swatch);
channels.forEach(function (c) { wrap.appendChild(c); }); channels.forEach(function (c) { wrap.appendChild(c); });
var clearBtn = null;
if (optional) { if (optional) {
// A mode colour must be able to go back to "inherit", which means // A mode colour must be able to go back to "inherit", which means
// posting nothing at all -- an <input type=color> has no empty // posting nothing at all -- an <input type=color> has no empty
@@ -231,21 +281,86 @@
return wrap; return wrap;
} }
// ---- rows ------------------------------------------------------------ // ---- columns ---------------------------------------------------------
var COLUMN_ORDER = ['font', 'font_size', 'text_color', 'align', 'visible',
'x_offset', 'y_offset', 'scale'];
var COLUMN_LABELS = {
font: 'Font', font_size: 'Size', text_color: 'Colour',
align: 'Align', visible: 'Show',
x_offset: 'X', y_offset: 'Y', scale: 'Scale'
};
var COLUMN_WIDTHS = {
font: 'minmax(8rem, 2fr)', text_color: '4.5rem', align: '6rem',
visible: '3.5rem'
};
/** /**
* One element's row. `prefix` is the dotted path the inputs post under; * The columns a table needs, derived from the schema rather than fixed.
* `optional` marks a mode layer, where every field may be left blank. *
* Two sources: the sub-fields elements declare (font, font_size,
* text_color, visible, align) and the sub-fields their layout blocks
* declare (x_offset, y_offset, scale).
*/ */
function columnsFor(schema) {
var props = schema.properties || {};
var layoutProps = (props.layout || {}).properties || {};
var seen = {};
elementKeys(schema).forEach(function (key) {
Object.keys((props[key] || {}).properties || {}).forEach(
function (f) { seen[f] = 'element'; });
Object.keys((layoutProps[key] || {}).properties || {}).forEach(
function (f) { seen[f] = 'layout'; });
});
var known = COLUMN_ORDER.filter(function (f) { return seen[f]; });
// Anything the schema declares that this file has never heard of
// still gets a column, rather than silently vanishing.
var extra = Object.keys(seen).filter(function (f) {
return COLUMN_ORDER.indexOf(f) === -1;
}).sort();
return known.concat(extra).map(function (f) {
return {
key: f,
where: seen[f],
label: COLUMN_LABELS[f] || f.replace(/_/g, ' ')
};
});
}
/** Build the control for one cell from its schema property. */
function control(opts) {
var prop = opts.prop;
var declared = prop.type;
var types = Array.isArray(declared) ? declared : [declared];
if (opts.key === 'font' || prop['x-widget'] === 'font-selector') {
return fontControl(opts.name, opts.current, opts.fonts,
opts.optional, opts.onFontChange);
}
if (types.indexOf('boolean') !== -1) {
return booleanControl(opts.name, opts.current, opts.optional);
}
if (types.indexOf('array') !== -1) {
return colourControl(opts.name, opts.current, opts.optional);
}
if (Array.isArray(prop.enum)) {
return enumControl(opts.name, opts.current, prop.enum,
opts.optional);
}
return numberControl(opts.name, opts.current, prop,
opts.optional ? 'inherit' : '');
}
function elementRow(opts) { function elementRow(opts) {
var schema = opts.schema; var schema = opts.schema;
var key = opts.key; var key = opts.key;
var prefix = opts.prefix;
var value = opts.value; var value = opts.value;
var fonts = opts.fonts;
var optional = opts.optional; var optional = opts.optional;
var props = ((schema.properties || {})[key] || {}).properties || {}; var props = ((schema.properties || {})[key] || {}).properties || {};
var layoutProps = ((schema.properties || {}).layout || {}).properties || {};
var axes = (layoutProps[key] || {}).properties || {};
var row = el('div', { var row = el('div', {
class: 'style-editor-row grid items-center gap-2 py-1', class: 'style-editor-row grid items-center gap-2 py-1',
'data-element': key 'data-element': key
@@ -257,10 +372,11 @@
var sizeInput = null; var sizeInput = null;
var sizeNote = el('span', { class: 'text-xs text-gray-500 ml-1' }); var sizeNote = el('span', { class: 'text-xs text-gray-500 ml-1' });
var fontSelect = null;
function syncSize(select) { function syncSize(select) {
// A BDF font has exactly one usable pixel size; offering a free // A BDF font has exactly one usable pixel size; offering a free
// number there is offering something that cannot take effect. // number there offers something that cannot take effect.
if (!sizeInput || !select) { return; } if (!sizeInput || !select) { return; }
var opt = select.options[select.selectedIndex]; var opt = select.options[select.selectedIndex];
var scalable = !opt || opt.dataset.scalable !== '0'; var scalable = !opt || opt.dataset.scalable !== '0';
@@ -276,68 +392,51 @@
} }
} }
var fontSelect = null; opts.columns.forEach(function (col) {
if (props.font) { var inLayout = col.where === 'layout';
fontSelect = fontControl(prefix + '.font', var prop = inLayout ? axes[col.key] : props[col.key];
effective(value, [key, 'font'], if (!prop) {
props.font, optional), // This element does not declare that field; keep the grid
fonts, optional, // aligned with an empty cell.
function () { syncSize(fontSelect); });
row.appendChild(fontSelect);
} else {
row.appendChild(el('span')); row.appendChild(el('span'));
return;
} }
var path = inLayout ? ['layout', key, col.key] : [key, col.key];
if (props.font_size) { var base = inLayout ? opts.layoutPrefix + '.' + key
: opts.prefix + '.' + key;
var node = control({
key: col.key,
prop: prop,
name: base + '.' + col.key,
current: effective(value, path, prop, optional),
optional: optional,
fonts: opts.fonts,
onFontChange: function () { syncSize(fontSelect); }
});
if (col.key === 'font') { fontSelect = node; }
if (col.key === 'font_size') {
sizeInput = node;
var cell = el('div', { class: 'flex items-center' }); var cell = el('div', { class: 'flex items-center' });
sizeInput = numberControl(prefix + '.font_size', cell.appendChild(node);
effective(value, [key, 'font_size'],
props.font_size, optional),
props.font_size,
optional ? 'inherit' : '');
cell.appendChild(sizeInput);
cell.appendChild(sizeNote); cell.appendChild(sizeNote);
row.appendChild(cell); node = cell;
} else {
row.appendChild(el('span'));
} }
row.appendChild(node);
if (props.text_color) {
row.appendChild(colourControl(prefix + '.text_color',
effective(value, [key, 'text_color'],
props.text_color, optional),
optional));
} else {
row.appendChild(el('span'));
}
// Offsets live in a sibling `layout` block, not inside the element,
// which is exactly why this widget takes the whole customization
// object rather than being registered per element.
var layoutProps = ((schema.properties || {}).layout || {}).properties || {};
var axes = (layoutProps[key] || {}).properties || {};
['x_offset', 'y_offset'].forEach(function (axis) {
if (!axes[axis]) { row.appendChild(el('span')); return; }
row.appendChild(numberControl(
opts.layoutPrefix + '.' + key + '.' + axis,
effective(value, ['layout', key, axis], axes[axis], optional),
axes[axis],
optional ? 'inherit' : '0'));
}); });
if (fontSelect) { syncSize(fontSelect); } if (fontSelect) { syncSize(fontSelect); }
return row; return row;
} }
function header(schema) { function header(columns) {
var cols = ['Element', 'Font', 'Size', 'Colour', 'X', 'Y'];
var row = el('div', { var row = el('div', {
class: 'style-editor-row style-editor-head grid gap-2 pb-1 mb-1 border-b border-gray-300' class: 'style-editor-row style-editor-head grid gap-2 pb-1 mb-1 border-b border-gray-300'
}); });
cols.forEach(function (c) { ['Element'].concat(columns.map(function (c) { return c.label; }))
.forEach(function (label) {
row.appendChild(el('div', { row.appendChild(el('div', {
class: 'text-xs font-semibold text-gray-500 uppercase', class: 'text-xs font-semibold text-gray-500 uppercase',
text: c text: label
})); }));
}); });
return row; return row;
@@ -345,12 +444,21 @@
function table(opts) { function table(opts) {
var wrap = el('div', { class: 'style-editor-table' }); var wrap = el('div', { class: 'style-editor-table' });
wrap.appendChild(header(opts.schema)); var columns = columnsFor(opts.schema);
// Sized here rather than in CSS: the column count depends on what
// the plugin declared.
wrap.style.gridTemplateColumns = '';
wrap.style.setProperty('--style-editor-columns',
'minmax(7rem, 1.4fr) ' + columns.map(function (c) {
return COLUMN_WIDTHS[c.key] || '5rem';
}).join(' '));
wrap.appendChild(header(columns));
elementKeys(opts.schema).forEach(function (key) { elementKeys(opts.schema).forEach(function (key) {
wrap.appendChild(elementRow({ wrap.appendChild(elementRow({
schema: opts.schema, schema: opts.schema,
key: key, key: key,
prefix: opts.prefix + '.' + key, columns: columns,
prefix: opts.prefix,
layoutPrefix: opts.prefix + '.layout', layoutPrefix: opts.prefix + '.layout',
value: opts.value, value: opts.value,
fonts: opts.fonts, fonts: opts.fonts,
@@ -364,7 +472,7 @@
window.LEDMatrixWidgets.register('style-editor', { window.LEDMatrixWidgets.register('style-editor', {
name: 'Style Editor', name: 'Style Editor',
version: '1.0.0', version: '1.1.0',
render: function (container, config, value, options) { render: function (container, config, value, options) {
var schema = (config && config.schema) || {}; var schema = (config && config.schema) || {};
@@ -443,7 +551,7 @@
}); });
}, },
getValue: function (fieldId) { getValue: function () {
// The inputs are ordinary named form fields; the form itself is // The inputs are ordinary named form fields; the form itself is
// the source of truth, so there is no separate value to hand back. // the source of truth, so there is no separate value to hand back.
return null; return null;