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;