From 521f81272fe83b315546291cbc7dc5c1248076f0 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:54:23 -0400 Subject: [PATCH] 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 --- src/element_style.py | 205 +++++++++++- test/test_element_style.py | 157 ++++++++++ .../test_style_editor_extra_fields.py | 212 +++++++++++++ web_interface/static/v3/app.css | 30 +- .../static/v3/js/widgets/style-editor.js | 296 ++++++++++++------ 5 files changed, 799 insertions(+), 101 deletions(-) create mode 100644 test/web_interface/test_style_editor_extra_fields.py diff --git a/src/element_style.py b/src/element_style.py index 7fff25b3..c073b1d8 100644 --- a/src/element_style.py +++ b/src/element_style.py @@ -91,7 +91,7 @@ def _cache_put(key: Tuple[str, int], value: Tuple[Any, int]) -> None: _font_cache.popitem(last=False) # 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) @@ -106,6 +106,14 @@ class ElementStyle: user_forced: bool # font or size 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) @@ -412,6 +420,35 @@ def _element_block_from_spec(element_key: str, properties['text_color'] = color_prop 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 { 'type': 'object', '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, spec: Dict[str, Any]) -> Dict[str, Any]: - """Build one layout. offset block (x/y, default 0).""" + """Build one layout. block: x/y offsets, and scale if declared.""" axis = { 'type': 'integer', 'default': 0, '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 { 'type': 'object', 'title': spec.get('title', element_key), 'x-style-managed': True, 'additionalProperties': False, - 'properties': { - 'x_offset': dict(axis, title='X Offset'), - 'y_offset': dict(axis, title='Y Offset'), - }, + 'properties': properties, } @@ -458,6 +518,13 @@ def _nullable(prop: Dict[str, Any]) -> Dict[str, Any]: types = list(types) + ['null'] out['type'] = types 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 @@ -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: 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: customization = schema.get('properties', {}).get('customization') 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') if isinstance(color_spec, dict) and 'default' in color_spec: 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: elements[element_key] = defaults properties = customization.get('properties') 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(): if element_key in ('layout', 'modes') or element_key in elements: continue @@ -576,6 +668,8 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]: elements[element_key] = defaults except Exception as e: logger.warning("Error extracting style defaults from schema: %s", e) + if layout: + elements['layout'] = layout return {'customization': elements} @@ -612,6 +706,55 @@ def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]: 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: """Resolves per-element user styling against schema defaults. @@ -796,6 +939,16 @@ class ElementStyleResolver: except (TypeError, ValueError): 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 def _layout_axis(block: Dict[str, Any], element_key: str, axis: str) -> Any: @@ -832,6 +985,29 @@ class ElementStyleResolver: # -- 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, classic_size: int, classic_color: Optional[Tuple[int, int, int]], @@ -897,6 +1073,20 @@ class ElementStyleResolver: if mode_color is not None: 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 # the request only when a BDF snapped to its native strike. Callers # lay out from this value; reporting the request would reserve space @@ -910,6 +1100,9 @@ class ElementStyleResolver: font_size=realised_size, user_forced=user_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, diff --git a/test/test_element_style.py b/test/test_element_style.py index 2c25c632..b69afed4 100644 --- a/test/test_element_style.py +++ b/test/test_element_style.py @@ -824,3 +824,160 @@ class TestModeSaveRoundTrip: "font_size": 99}}}}}, defaults) ok, _errors = sm.validate_config_against_schema(merged, schema) 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"] diff --git a/test/web_interface/test_style_editor_extra_fields.py b/test/web_interface/test_style_editor_extra_fields.py new file mode 100644 index 00000000..ad823f4c --- /dev/null +++ b/test/web_interface/test_style_editor_extra_fields.py @@ -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" diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index 5108b19c..2f35fc41 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -1416,8 +1416,36 @@ button.bg-white { /* ---- Style editor widget -------------------------------------------- */ /* 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. */ +/* 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 { - 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 { padding: 0.2rem 0.4rem; diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js index 94030839..46a82938 100644 --- a/web_interface/static/v3/js/widgets/style-editor.js +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -1,13 +1,13 @@ /** * Style Editor Widget * - * One compact row per display element -- font, size, colour, X/Y nudge -- - * instead of the nested accordions the generic object renderer produces. A - * realistic scoreboard declares seven elements with layout offsets and three - * modes, which comes to 65 nested sections and five levels of clicking to - * reach one per-mode font size. + * One compact row per display element -- font, size, colour, alignment, + * visibility, X/Y nudge, scale -- instead of the nested accordions the + * generic object renderer produces. A realistic scoreboard declares seven + * elements with layout offsets and three modes, which comes to 65 nested + * 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 * renderer would produce (`customization.score_text.font`, @@ -16,13 +16,14 @@ * save/validate/merge pipeline is therefore untouched: no hidden JSON * blob, no new server-side parsing. * - * 2. It is driven entirely by the schema block it is handed. It renders - * whatever sub-fields each element declares, so fields added to the - * schema later appear here without touching this file. + * 2. Its columns come from the schema, not from a list in here. A column + * appears when any element declares that field, so a plugin adding a + * 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.`, whose fields mean "inherit" - * when blank. Blank must therefore post as empty (-> null), never as 0, or - * every mode would pin itself to the base the first time it was saved. + * 3. Mode tabs edit `customization.modes.`, whose fields mean + * "inherit" when blank. Blank must post as empty (-> null), never as 0, + * or every mode would pin itself to the base the first time it was saved. * * @module StyleEditorWidget */ @@ -78,6 +79,16 @@ 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, * else the schema default. @@ -96,16 +107,6 @@ 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. * `layout` and `modes` are containers, not elements. @@ -115,7 +116,7 @@ var order = schema['x-propertyOrder'] || Object.keys(props); return order.filter(function (k) { 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); }); if (current) { select.value = current; } - select.addEventListener('change', onChange); + if (onChange) { select.addEventListener('change', onChange); } 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) { var input = el('input', { type: 'number', @@ -154,6 +203,10 @@ }); if (prop && prop.minimum !== undefined) { input.min = prop.minimum; } 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; // writing 0 here would pin the mode to the base on the next save. if (current !== undefined && current !== null) { input.value = current; } @@ -175,10 +228,7 @@ var wrap = el('div', { class: 'flex items-center gap-1' }); var has = Array.isArray(current) && current.length >= 3; var channels = [0, 1, 2].map(function (i) { - var input = el('input', { - type: 'hidden', - name: name + '.' + i - }); + var input = el('input', { type: 'hidden', name: name + '.' + i }); if (has) { input.value = current[i]; } return input; }); @@ -192,6 +242,7 @@ } setSubmitted(!optional || has); + var clearBtn = null; var swatch = el('input', { type: 'color', class: 'style-editor-colour', @@ -208,7 +259,6 @@ wrap.appendChild(swatch); channels.forEach(function (c) { wrap.appendChild(c); }); - var clearBtn = null; if (optional) { // A mode colour must be able to go back to "inherit", which means // posting nothing at all -- an has no empty @@ -231,21 +281,86 @@ 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; - * `optional` marks a mode layer, where every field may be left blank. + * The columns a table needs, derived from the schema rather than fixed. + * + * 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) { var schema = opts.schema; var key = opts.key; - var prefix = opts.prefix; var value = opts.value; - var fonts = opts.fonts; var optional = opts.optional; var props = ((schema.properties || {})[key] || {}).properties || {}; + var layoutProps = ((schema.properties || {}).layout || {}).properties || {}; + var axes = (layoutProps[key] || {}).properties || {}; + var row = el('div', { class: 'style-editor-row grid items-center gap-2 py-1', 'data-element': key @@ -257,10 +372,11 @@ var sizeInput = null; var sizeNote = el('span', { class: 'text-xs text-gray-500 ml-1' }); + var fontSelect = null; function syncSize(select) { // 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; } var opt = select.options[select.selectedIndex]; var scalable = !opt || opt.dataset.scalable !== '0'; @@ -276,81 +392,73 @@ } } - var fontSelect = null; - if (props.font) { - fontSelect = fontControl(prefix + '.font', - effective(value, [key, 'font'], - props.font, optional), - fonts, optional, - function () { syncSize(fontSelect); }); - row.appendChild(fontSelect); - } else { - row.appendChild(el('span')); - } - - if (props.font_size) { - var cell = el('div', { class: 'flex items-center' }); - sizeInput = numberControl(prefix + '.font_size', - effective(value, [key, 'font_size'], - props.font_size, optional), - props.font_size, - optional ? 'inherit' : ''); - cell.appendChild(sizeInput); - cell.appendChild(sizeNote); - row.appendChild(cell); - } else { - row.appendChild(el('span')); - } - - 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')); + opts.columns.forEach(function (col) { + var inLayout = col.where === 'layout'; + var prop = inLayout ? axes[col.key] : props[col.key]; + if (!prop) { + // This element does not declare that field; keep the grid + // aligned with an empty cell. + row.appendChild(el('span')); + return; + } + var path = inLayout ? ['layout', key, col.key] : [key, col.key]; + 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' }); + cell.appendChild(node); + cell.appendChild(sizeNote); + node = cell; + } + row.appendChild(node); }); if (fontSelect) { syncSize(fontSelect); } return row; } - function header(schema) { - var cols = ['Element', 'Font', 'Size', 'Colour', 'X', 'Y']; + function header(columns) { var row = el('div', { class: 'style-editor-row style-editor-head grid gap-2 pb-1 mb-1 border-b border-gray-300' }); - cols.forEach(function (c) { - row.appendChild(el('div', { - class: 'text-xs font-semibold text-gray-500 uppercase', - text: c - })); - }); + ['Element'].concat(columns.map(function (c) { return c.label; })) + .forEach(function (label) { + row.appendChild(el('div', { + class: 'text-xs font-semibold text-gray-500 uppercase', + text: label + })); + }); return row; } function table(opts) { 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) { wrap.appendChild(elementRow({ schema: opts.schema, key: key, - prefix: opts.prefix + '.' + key, + columns: columns, + prefix: opts.prefix, layoutPrefix: opts.prefix + '.layout', value: opts.value, fonts: opts.fonts, @@ -364,7 +472,7 @@ window.LEDMatrixWidgets.register('style-editor', { name: 'Style Editor', - version: '1.0.0', + version: '1.1.0', render: function (container, config, value, options) { 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 source of truth, so there is no separate value to hand back. return null;