From e86dc9c27b4ebb0b2ebe13b21a75538e7a2b06aa Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:01:01 -0400 Subject: [PATCH] feat(element-style): adopt hand-written customization blocks, and widen the font list Nineteen plugins spell their style elements out longhand instead of declaring them -- football's block is 701 lines for seven elements -- and predate this system entirely. Core now recognises that shape, so they pick up the row-per-element editor and the real font picker on a core update rather than on a plugin release. Checked against every published schema: 21 plugins adopt, and the defaults of each still validate against the schema generated for it. Detection requires *every* field in a block to be one this system understands. A looser "has at least one style field" rule sweeps in baseball's `count`, which carries a text_color beside geometry that means nothing here. That distinction took three attempts to test: the first two assertions passed under both rules, because an over-eager rule leaves a fontless block looking untouched and only surfaces as an extra row in the editor. The hardcoded font enum is replaced rather than extended. Football lists five of the thirty-five installed fonts, which is why a font a user uploads can never appear in one. It is not a curated safe set -- it omits some twenty other faces that fit the declared size cap just as well -- it is the fonts that happened to exist when it was written. Widening it does need a guard, though, and not the one the schema already has: a bitmap font ignores font_size and renders at its size baked into the file, so `maximum: 16` cannot stop a 27px face. The picker now filters out fixed-size fonts taller than the element's own declared ceiling, which drops exactly the four that would overflow a 32px panel and keeps the other thirty. Per-mode overrides stay opt-in: core cannot invent a plugin's display modes, so `x-style-modes` remains the one line that unlocks them. Their layout half covers every positionable element rather than only those with a style block -- the two namespaces do not line up in a hand-written schema, and football positions six things (logos, timeouts, possession) that have no style block at all. Co-Authored-By: Claude Opus 5 --- src/element_style.py | 195 +++++++++++++++++- test/test_element_style.py | 188 ++++++++++++++++- .../static/v3/js/widgets/font-selector.js | 16 ++ .../static/v3/js/widgets/style-editor.js | 29 ++- 4 files changed, 423 insertions(+), 5 deletions(-) diff --git a/src/element_style.py b/src/element_style.py index bc7f8968..122855b8 100644 --- a/src/element_style.py +++ b/src/element_style.py @@ -307,7 +307,9 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]: return schema declaration = customization.get('x-style-elements') if not isinstance(declaration, dict) or not declaration: - return schema + # No compact declaration: the plugin may still have hand-written + # its style blocks longhand, which nineteen of them do. + return _adopt_handwritten_block(schema, customization) expanded = copy.deepcopy(schema) customization = expanded['properties']['customization'] @@ -370,6 +372,7 @@ def _element_block_from_spec(element_key: str, properties: Dict[str, Any] = {} order = [] + size_spec = spec.get('size') if isinstance(spec.get('size'), dict) else None font_spec = spec.get('font') if isinstance(font_spec, dict): font_prop: Dict[str, Any] = { @@ -381,6 +384,13 @@ def _element_block_from_spec(element_key: str, # text box the user had to type a filename into. 'x-widget': 'font-selector', } + # A bitmap font ignores font_size and renders at its own baked-in + # size, so the size ceiling has to be enforced when picking the + # font, not when setting the size. + max_size = (size_spec or {}).get('max') if isinstance( + spec.get('size'), dict) else None + if isinstance(max_size, (int, float)): + font_prop['x-options'] = {'maxFixedSize': max_size} if 'default' in font_spec: font_prop['default'] = font_spec['default'] if isinstance(font_spec.get('enum'), list): @@ -388,7 +398,6 @@ def _element_block_from_spec(element_key: str, properties['font'] = font_prop order.append('font') - size_spec = spec.get('size') if isinstance(size_spec, dict): size_prop: Dict[str, Any] = { 'type': 'integer', @@ -587,6 +596,188 @@ def _modes_block(declaration: Dict[str, Any], } +#: The sub-fields that make a customization sub-object a style element. +#: Checked against every published schema: 68 blocks across 19 plugins match +#: exactly, and nothing else does -- favorite_result_colors, baseball's +#: bases/outs/player_card, jellyfin's progress_bar and the stocks blocks all +#: carry other fields and are correctly left alone. +_STYLE_BLOCK_FIELDS = frozenset(_STYLE_KEYS) + + +def _looks_like_style_block(block: Any) -> bool: + """Whether a hand-written customization sub-object is a style element. + + Deliberately strict: every field must be one this system understands. + A looser rule ("has at least one style field") would sweep in blocks + like baseball's ``count``, which happens to carry a text_color next to + geometry that means nothing here. + """ + if not isinstance(block, dict): + return False + props = block.get('properties') + if not isinstance(props, dict) or not props: + return False + return set(props) <= _STYLE_BLOCK_FIELDS + + +def _detect_style_blocks(customization: Dict[str, Any]) -> list: + """Element keys in a hand-written customization block, in declared order.""" + props = customization.get('properties') + if not isinstance(props, dict): + return [] + return [key for key, block in props.items() + if key not in ('layout', 'modes') and _looks_like_style_block(block)] + + +def _upgrade_font_property(block: Dict[str, Any]) -> None: + """Point a hand-written font field at the font picker, in place. + + These fields ship a hardcoded ``enum`` -- football lists five of the + thirty-five installed fonts -- which is why a font a user uploads can + never appear in one. The enum is replaced rather than extended: it is + not a curated safe set (it omits some twenty other faces that fit + just as well), it is the fonts that happened to exist when it was + written. + + The size ceiling the block already declares is carried across as + ``maxFixedSize``, because a bitmap font ignores font_size and renders + at its own baked-in size -- so widening the list without that would + offer faces that overflow the panel no matter what size is set. + """ + props = block.get('properties') + if not isinstance(props, dict): + return + font_prop = props.get('font') + if not isinstance(font_prop, dict): + return + + font_prop.pop('enum', None) + font_prop['x-widget'] = 'font-selector' + + size_prop = props.get('font_size') + maximum = size_prop.get('maximum') if isinstance(size_prop, dict) else None + if isinstance(maximum, (int, float)): + options = font_prop.setdefault('x-options', {}) + if isinstance(options, dict): + options.setdefault('maxFixedSize', maximum) + + +def _nullable_block(block: Dict[str, Any], title: Optional[str] = None, + description: Optional[str] = None) -> Dict[str, Any]: + """A copy of an element block with every field optional. + + The per-mode counterpart of a hand-written block: same fields, all + nullable and defaulting to null, which is this system's "inherit". + """ + out = copy.deepcopy(block) + out['properties'] = {k: _nullable(v) + for k, v in (out.get('properties') or {}).items()} + out['x-style-managed'] = True + if title: + out['title'] = title + if description is not None: + out['description'] = description + return out + + +def _modes_block_from_properties(props: Dict[str, Any], element_keys: list, + modes: Any) -> Dict[str, Any]: + """``customization.modes`` built from already-expanded element blocks. + + The compact declaration has ``_modes_block``; this is the same thing for + a plugin that hand-wrote its blocks, so both forms get per-mode overrides + from one declaration line. + """ + layout_source = (props.get('layout') or {}).get('properties') or {} + mode_props: Dict[str, Any] = {} + for mode in modes: + if not isinstance(mode, str) or not mode: + continue + element_props: Dict[str, Any] = {} + layout_props: Dict[str, Any] = {} + for key in element_keys: + block = props.get(key) + if isinstance(block, dict): + element_props[key] = _nullable_block( + block, + description='Leave blank to use the settings above for ' + 'this mode.') + # Every layout element, not just those with a style block. The two + # namespaces do not line up in a hand-written schema -- football + # styles 'score_text' but positions 'score', and positions logos, + # timeouts and possession that have no style block at all. Keying + # this off the style elements would have given six of its eleven + # positionable things no per-mode offset. + for key, layout_block in layout_source.items(): + if isinstance(layout_block, dict): + layout_props[key] = _nullable_block(layout_block) + if layout_props: + element_props['layout'] = { + 'type': 'object', + 'title': 'Layout Offsets', + 'x-advanced': True, + 'additionalProperties': False, + 'properties': layout_props, + } + mode_props[mode] = { + 'type': 'object', + 'title': mode.replace('_', ' ').title(), + 'x-style-managed': True, + 'additionalProperties': False, + 'properties': element_props, + } + return { + 'type': 'object', + 'title': 'Per-Mode Overrides', + 'description': 'Override the settings above for one display mode. ' + 'Anything left blank follows the settings above.', + 'x-advanced': True, + 'additionalProperties': False, + 'properties': mode_props, + } + + +def _adopt_handwritten_block(schema: Dict[str, Any], + customization: Dict[str, Any]) -> Dict[str, Any]: + """Give a hand-written customization block the same treatment as a + declared one, without the plugin rewriting its schema. + + Nineteen plugins spell their style elements out longhand -- football's + block is 701 lines for seven elements -- and predate every part of this + system. Recognising that shape lets them pick up the row-per-element + editor and the real font picker on a core update, with no plugin + release. What they do not get for free is per-mode overrides and the + visible/align/scale fields, because core cannot invent a plugin's list + of display modes: adding ``x-style-modes`` is the one line that unlocks + the rest. + """ + element_keys = _detect_style_blocks(customization) + if not element_keys: + return schema + + expanded = copy.deepcopy(schema) + customization = expanded['properties']['customization'] + customization.setdefault('x-widget', 'style-editor') + props = customization['properties'] + + for key in element_keys: + _upgrade_font_property(props[key]) + + modes = customization.get('x-style-modes') + if isinstance(modes, list) and modes: + props.setdefault('modes', + _modes_block_from_properties(props, element_keys, + modes)) + + # Stated explicitly because the config form serialises the schema with + # Flask's JSON provider, which sorts keys -- without this the elements + # reach the browser alphabetised. + order = list(element_keys) + order += [k for k in props if k not in order] + customization.setdefault('x-propertyOrder', order) + return expanded + + def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]: """Extract per-element style defaults from a config schema dict. diff --git a/test/test_element_style.py b/test/test_element_style.py index 692c5e64..4131a94b 100644 --- a/test/test_element_style.py +++ b/test/test_element_style.py @@ -177,10 +177,25 @@ class TestExpandStyleElements: expand_style_elements(STYLE_ELEMENTS_SCHEMA) assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before - def test_no_declaration_returns_same_object(self): - assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA + def test_a_schema_with_nothing_to_expand_is_returned_as_is(self): + """No needless deep copy when there is nothing to do.""" empty = {"properties": {}} assert expand_style_elements(empty) is empty + no_style = {"properties": {"customization": {"type": "object", + "properties": {"favorite_result_colors": {"type": "object", + "properties": {"win_color": {"type": "array"}}}}}}} + assert expand_style_elements(no_style) is no_style + + def test_a_hand_written_block_is_adopted(self): + """Nineteen plugins spell their style elements out longhand rather + than declaring them, and predate this system entirely. They pick up + the editor and the font picker on a core update rather than on a + plugin release.""" + expanded = expand_style_elements(MANUAL_SCHEMA) + assert expanded is not MANUAL_SCHEMA + customization = expanded["properties"]["customization"] + assert customization["x-widget"] == "style-editor" + assert "x-style-elements" not in MANUAL_SCHEMA["properties"]["customization"] def test_garbage_input_never_raises(self): bad = {"properties": {"customization": {"x-style-elements": "nope"}}} @@ -1073,3 +1088,172 @@ class TestAliasedLookup: config, defaults_from_schema_file(extra_schema_path) ).style("score_text") assert st.scale == 2.0 + +HANDWRITTEN = { + "type": "object", + "properties": { + "customization": { + "type": "object", + "title": "Display Customization", + "properties": { + "score_text": { + "type": "object", + "title": "Game Score", + "properties": { + "font": { + "type": "string", + "enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf", + "5by7.regular.ttf", "5x7.bdf", "4x6.bdf"], + "default": "PressStart2P-Regular.ttf", + }, + "font_size": {"type": "integer", "minimum": 4, + "maximum": 16, "default": 10}, + "text_color": {"type": "array", "minItems": 3, + "maxItems": 3, "default": [255, 255, 255]}, + }, + }, + "favorite_result_colors": { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": False}, + "win_color": {"type": "array", "default": [0, 255, 0]}, + }, + }, + # baseball's 'count' shape: one field this system knows, + # next to geometry that means nothing to it. + "count": { + "type": "object", + "properties": { + "text_color": {"type": "array", "default": [0, 255, 0]}, + "y_offset": {"type": "integer", "default": 2}, + }, + }, + "layout": { + "type": "object", + "properties": { + "score": {"type": "object", "properties": { + "x_offset": {"type": "integer", "default": 0}, + "y_offset": {"type": "integer", "default": 0}}}, + "home_logo": {"type": "object", "properties": { + "x_offset": {"type": "integer", "default": 0}}}, + }, + }, + }, + }, + }, +} + + +class TestHandWrittenAdoption: + """Nineteen plugins spell their style elements out longhand -- football's + block is 701 lines for seven elements -- and predate this system. Core + recognises that shape so they pick up the editor and the real font picker + on a core update rather than on a plugin release. + """ + + def _customization(self, schema=None): + return expand_style_elements( + schema or HANDWRITTEN)["properties"]["customization"] + + def test_the_block_gets_the_composite_editor(self): + assert self._customization()["x-widget"] == "style-editor" + + def test_the_declared_order_is_stated_explicitly(self): + """Flask's JSON provider sorts keys, so without this the elements + reach the browser alphabetised.""" + order = self._customization()["x-propertyOrder"] + assert order[0] == "score_text" + + def test_a_block_that_is_not_styling_is_left_alone(self): + """favorite_result_colors, baseball's bases/outs/player_card and the + stocks blocks all carry fields this system knows nothing about.""" + block = self._customization()["properties"]["favorite_result_colors"] + assert "x-style-managed" not in block + assert block["properties"]["win_color"]["default"] == [0, 255, 0] + + def test_a_block_that_merely_shares_a_field_is_left_alone(self): + """The reason detection requires *every* field to be one this system + understands. baseball's 'count' carries a text_color beside a + y_offset that means nothing here. + + Asserted on the element list rather than on the block itself: an + over-eager rule leaves a fontless block looking untouched, and only + shows up as an extra row in the editor and an extra per-mode + override group. + """ + schema = copy.deepcopy(HANDWRITTEN) + schema["properties"]["customization"]["x-style-modes"] = ["live"] + live = expand_style_elements(schema)["properties"]["customization"][ + "properties"]["modes"]["properties"]["live"]["properties"] + assert "score_text" in live + assert "count" not in live, ( + "'count' is not a style element -- it shares one field and " + "carries geometry this system does not understand") + assert "favorite_result_colors" not in live + + def test_the_hardcoded_font_list_is_replaced_by_the_picker(self): + """The reason an uploaded font could never appear in one of these.""" + font = self._customization()["properties"]["score_text"]["properties"]["font"] + assert "enum" not in font + assert font["x-widget"] == "font-selector" + + def test_the_picker_inherits_the_declared_size_ceiling(self): + """A bitmap font ignores font_size and renders at its own baked-in + size, so a field capped at 16 must not offer a 27px face.""" + font = self._customization()["properties"]["score_text"]["properties"]["font"] + assert font["x-options"]["maxFixedSize"] == 16 + + def test_the_users_existing_font_choice_stays_valid(self): + font = self._customization()["properties"]["score_text"]["properties"]["font"] + assert font["default"] == "PressStart2P-Regular.ttf" + assert font["type"] == "string" + + def test_the_input_schema_is_not_mutated(self): + expand_style_elements(HANDWRITTEN) + original = HANDWRITTEN["properties"]["customization"] + assert "x-widget" not in original + assert "enum" in original["properties"]["score_text"]["properties"]["font"] + + def test_no_style_blocks_means_no_expansion(self): + schema = {"properties": {"customization": {"type": "object", + "properties": {"favorite_result_colors": {"type": "object", + "properties": {"win_color": {"type": "array"}}}}}}} + assert expand_style_elements(schema) is schema + + +class TestAdoptedModes: + """Per-mode overrides stay opt-in: core cannot invent a plugin's list of + display modes. Declaring x-style-modes is the one line that unlocks them + for a hand-written block.""" + + def _live(self): + schema = copy.deepcopy(HANDWRITTEN) + schema["properties"]["customization"]["x-style-modes"] = ["live", "recent"] + return expand_style_elements(schema)["properties"]["customization"][ + "properties"]["modes"]["properties"]["live"]["properties"] + + def test_modes_are_not_invented(self): + assert "modes" not in self._customization_props() + + def _customization_props(self): + return expand_style_elements(HANDWRITTEN)["properties"]["customization"]["properties"] + + def test_declaring_modes_generates_them(self): + assert "score_text" in self._live() + + def test_mode_fields_are_nullable(self): + size = self._live()["score_text"]["properties"]["font_size"] + assert size["default"] is None and "null" in size["type"] + + def test_every_positionable_element_gets_per_mode_offsets(self): + """The two namespaces do not line up in a hand-written schema: this + one styles 'score_text' but positions 'score', and positions a logo + that has no style block at all. Keying the layout off the style + elements would have left both without a per-mode offset.""" + layout = self._live()["layout"]["properties"] + assert set(layout) == {"score", "home_logo"} + + def test_the_mode_font_picker_keeps_the_ceiling(self): + font = self._live()["score_text"]["properties"]["font"] + assert font["x-options"]["maxFixedSize"] == 16 + assert "null" in font["type"] diff --git a/web_interface/static/v3/js/widgets/font-selector.js b/web_interface/static/v3/js/widgets/font-selector.js index bf1f887c..cd0ee139 100644 --- a/web_interface/static/v3/js/widgets/font-selector.js +++ b/web_interface/static/v3/js/widgets/font-selector.js @@ -174,6 +174,12 @@ const xOptions = config['x-options'] || config['x_options'] || {}; const placeholder = xOptions.placeholder || 'Select a font...'; const filterTypes = xOptions.filterTypes || null; // e.g., ['ttf', 'bdf'] + // Bitmap (BDF) fonts render at the one size baked into the file + // and ignore the size setting entirely, so a field that caps size + // at 16 can still be handed a 27px face. maxFixedSize hides the + // ones that cannot honour the cap, rather than offering a choice + // that silently overflows the panel. + const maxFixedSize = Number(xOptions.maxFixedSize) || null; const showPreview = xOptions.showPreview === true; const disabled = xOptions.disabled === true; const required = xOptions.required === true; @@ -204,6 +210,16 @@ return filterTypes.some(t => t.toLowerCase() === fontType); }); } + if (maxFixedSize) { + filteredFonts = filteredFonts.filter(font => { + // A scalable face can always meet the cap. + if (font.scalable !== false) { return true; } + // An unknown native size is not evidence it is too + // big; keep it rather than hiding a usable font. + if (!font.native_size) { return true; } + return font.native_size <= maxFixedSize; + }); + } // Build select HTML let html = `
`; diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js index 46a82938..f095f234 100644 --- a/web_interface/static/v3/js/widgets/style-editor.js +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -127,6 +127,27 @@ // ---- individual controls ------------------------------------------- + /** + * Fonts this field can actually honour. + * + * A bitmap (BDF) face renders at the one size baked into the file and + * ignores the size setting, so an element that caps size at 16 can + * still be handed a 27px face and overflow a 32px panel. The cap comes + * from the element's own font_size.maximum, so this hides exactly the + * faces that cannot meet the limit the plugin already declared -- + * rather than a list curated by hand, which is what these fields used + * to carry and why an uploaded font could never appear in one. + */ + function usableFonts(fonts, maxFixedSize) { + if (!maxFixedSize) { return fonts; } + return fonts.filter(function (f) { + if (f.scalable !== false) { return true; } + // An unknown native size is not evidence it is too big. + if (!f.nativeSize) { return true; } + return f.nativeSize <= maxFixedSize; + }); + } + function fontControl(name, current, fonts, optional, onChange) { var select = el('select', { name: name, @@ -334,7 +355,10 @@ var types = Array.isArray(declared) ? declared : [declared]; if (opts.key === 'font' || prop['x-widget'] === 'font-selector') { - return fontControl(opts.name, opts.current, opts.fonts, + var xOptions = prop['x-options'] || prop['x_options'] || {}; + var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null; + return fontControl(opts.name, opts.current, + usableFonts(opts.fonts, cap), opts.optional, opts.onFontChange); } if (types.indexOf('boolean') !== -1) { @@ -411,6 +435,9 @@ current: effective(value, path, prop, optional), optional: optional, fonts: opts.fonts, + // The element's declared size ceiling is what a fixed-size + // font has to fit under. + maxFixedSize: (props.font_size || {}).maximum || null, onFontChange: function () { syncSize(fontSelect); } }); if (col.key === 'font') { fontSelect = node; }