mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
+193
-2
@@ -307,7 +307,9 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
return schema
|
return schema
|
||||||
declaration = customization.get('x-style-elements')
|
declaration = customization.get('x-style-elements')
|
||||||
if not isinstance(declaration, dict) or not declaration:
|
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)
|
expanded = copy.deepcopy(schema)
|
||||||
customization = expanded['properties']['customization']
|
customization = expanded['properties']['customization']
|
||||||
@@ -370,6 +372,7 @@ def _element_block_from_spec(element_key: str,
|
|||||||
properties: Dict[str, Any] = {}
|
properties: Dict[str, Any] = {}
|
||||||
order = []
|
order = []
|
||||||
|
|
||||||
|
size_spec = spec.get('size') if isinstance(spec.get('size'), dict) else None
|
||||||
font_spec = spec.get('font')
|
font_spec = spec.get('font')
|
||||||
if isinstance(font_spec, dict):
|
if isinstance(font_spec, dict):
|
||||||
font_prop: Dict[str, Any] = {
|
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.
|
# text box the user had to type a filename into.
|
||||||
'x-widget': 'font-selector',
|
'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:
|
if 'default' in font_spec:
|
||||||
font_prop['default'] = font_spec['default']
|
font_prop['default'] = font_spec['default']
|
||||||
if isinstance(font_spec.get('enum'), list):
|
if isinstance(font_spec.get('enum'), list):
|
||||||
@@ -388,7 +398,6 @@ def _element_block_from_spec(element_key: str,
|
|||||||
properties['font'] = font_prop
|
properties['font'] = font_prop
|
||||||
order.append('font')
|
order.append('font')
|
||||||
|
|
||||||
size_spec = spec.get('size')
|
|
||||||
if isinstance(size_spec, dict):
|
if isinstance(size_spec, dict):
|
||||||
size_prop: Dict[str, Any] = {
|
size_prop: Dict[str, Any] = {
|
||||||
'type': 'integer',
|
'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]:
|
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Extract per-element style defaults from a config schema dict.
|
"""Extract per-element style defaults from a config schema dict.
|
||||||
|
|
||||||
|
|||||||
+186
-2
@@ -177,10 +177,25 @@ class TestExpandStyleElements:
|
|||||||
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
||||||
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
|
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
|
||||||
|
|
||||||
def test_no_declaration_returns_same_object(self):
|
def test_a_schema_with_nothing_to_expand_is_returned_as_is(self):
|
||||||
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA
|
"""No needless deep copy when there is nothing to do."""
|
||||||
empty = {"properties": {}}
|
empty = {"properties": {}}
|
||||||
assert expand_style_elements(empty) is empty
|
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):
|
def test_garbage_input_never_raises(self):
|
||||||
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
|
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
|
||||||
@@ -1073,3 +1088,172 @@ class TestAliasedLookup:
|
|||||||
config, defaults_from_schema_file(extra_schema_path)
|
config, defaults_from_schema_file(extra_schema_path)
|
||||||
).style("score_text")
|
).style("score_text")
|
||||||
assert st.scale == 2.0
|
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"]
|
||||||
|
|||||||
@@ -174,6 +174,12 @@
|
|||||||
const xOptions = config['x-options'] || config['x_options'] || {};
|
const xOptions = config['x-options'] || config['x_options'] || {};
|
||||||
const placeholder = xOptions.placeholder || 'Select a font...';
|
const placeholder = xOptions.placeholder || 'Select a font...';
|
||||||
const filterTypes = xOptions.filterTypes || null; // e.g., ['ttf', 'bdf']
|
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 showPreview = xOptions.showPreview === true;
|
||||||
const disabled = xOptions.disabled === true;
|
const disabled = xOptions.disabled === true;
|
||||||
const required = xOptions.required === true;
|
const required = xOptions.required === true;
|
||||||
@@ -204,6 +210,16 @@
|
|||||||
return filterTypes.some(t => t.toLowerCase() === fontType);
|
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
|
// Build select HTML
|
||||||
let html = `<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">`;
|
let html = `<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">`;
|
||||||
|
|||||||
@@ -127,6 +127,27 @@
|
|||||||
|
|
||||||
// ---- individual controls -------------------------------------------
|
// ---- 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) {
|
function fontControl(name, current, fonts, optional, onChange) {
|
||||||
var select = el('select', {
|
var select = el('select', {
|
||||||
name: name,
|
name: name,
|
||||||
@@ -334,7 +355,10 @@
|
|||||||
var types = Array.isArray(declared) ? declared : [declared];
|
var types = Array.isArray(declared) ? declared : [declared];
|
||||||
|
|
||||||
if (opts.key === 'font' || prop['x-widget'] === 'font-selector') {
|
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);
|
opts.optional, opts.onFontChange);
|
||||||
}
|
}
|
||||||
if (types.indexOf('boolean') !== -1) {
|
if (types.indexOf('boolean') !== -1) {
|
||||||
@@ -411,6 +435,9 @@
|
|||||||
current: effective(value, path, prop, optional),
|
current: effective(value, path, prop, optional),
|
||||||
optional: optional,
|
optional: optional,
|
||||||
fonts: opts.fonts,
|
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); }
|
onFontChange: function () { syncSize(fontSelect); }
|
||||||
});
|
});
|
||||||
if (col.key === 'font') { fontSelect = node; }
|
if (col.key === 'font') { fontSelect = node; }
|
||||||
|
|||||||
Reference in New Issue
Block a user