mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
feat(web): style-editor widget -- a row per element instead of 65 accordions
Rendered element by element, a realistic scoreboard's customization block is 65 nested sections, and reaching one per-mode font size takes five levels of expanding. The widget collapses that to one compact row per element -- font, size, colour, X, Y -- with a tab per declared mode. It emits ordinary inputs under the same dotted names the generic renderer would produce, so the save/validate/merge pipeline is untouched: no hidden JSON blob and no new server-side parsing. It is driven entirely by the schema block it is handed, so fields added to the schema later appear without editing the widget. If it fails to load or throws, the generic nested rendering it replaces is left in place. Fixing two things the save path got wrong for nullable fields, found by posting what the widget actually emits: - The indexed-array recombiner (text_color.0/.1/.2 -> one list) compared the declared type to the string 'array', so a per-mode colour, typed ["array", "null"], was never reassembled and failed validation on save. _parse_form_value_with_schema had the same comparison. - A blank nullable field became [] rather than None, which then failed the minItems the colour array declares. Null is the inherit sentinel, so it has to survive. And two things the widget itself got wrong, found by looking at it: - An unset base control fell back to the select's first option, so an untouched scoreboard claimed every element used 10x20.bdf -- and the size box then locked itself to that bitmap font's fixed size. Base controls now show the schema default; mode controls stay blank, because blank there means inherit. - Elements arrived alphabetised (Detail and Odds above Score). Flask's JSON provider sorts keys, so declaration order has to be stated explicitly; expand_style_elements now emits x-propertyOrder, which the generic renderer already honoured too. Size is disabled and shown as fixed for a bitmap font, using the scalable/native_size the font catalog now reports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -304,6 +304,12 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
expanded = copy.deepcopy(schema)
|
||||
customization = expanded['properties']['customization']
|
||||
customization.setdefault('type', 'object')
|
||||
# One composite editor for the whole block. Rendered element by
|
||||
# element, a realistic scoreboard is 65 nested accordions and five
|
||||
# levels of clicking to reach one per-mode font size; the widget
|
||||
# collapses that to a row per element. setdefault, so a plugin that
|
||||
# names its own widget keeps it.
|
||||
customization.setdefault('x-widget', 'style-editor')
|
||||
props = customization.setdefault('properties', {})
|
||||
layout_props: Dict[str, Any] = {}
|
||||
|
||||
@@ -335,6 +341,15 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
props.setdefault('modes',
|
||||
_modes_block(declaration, modes))
|
||||
|
||||
# Declaration order, stated explicitly. Python preserves it in the
|
||||
# dict, but the config form serialises the schema to JSON with
|
||||
# Flask's provider, which sorts keys -- so without this the elements
|
||||
# reach the browser alphabetised, and a scoreboard lists Detail and
|
||||
# Odds above Score.
|
||||
order = [k for k in declaration if isinstance(declaration.get(k), dict)]
|
||||
order += [k for k in ('layout', 'modes') if k in props]
|
||||
customization.setdefault('x-propertyOrder', order)
|
||||
|
||||
return expanded
|
||||
except Exception as e:
|
||||
logger.warning("Error expanding x-style-elements: %s", e)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""The style editor's form data must survive the real save route.
|
||||
|
||||
The widget posts ordinary dotted field names so the existing pipeline needs no
|
||||
new parsing -- but per-mode override fields are typed ``["integer", "null"]``
|
||||
and ``["array", "null"]``, and two places in that pipeline compared the
|
||||
declared type to a bare string:
|
||||
|
||||
- the indexed-array recombiner (``text_color.0/.1/.2`` -> one list) skipped
|
||||
anything whose type was not exactly ``'array'``, so a per-mode colour was
|
||||
never reassembled;
|
||||
- ``_parse_form_value_with_schema`` did the same, and turned a blank nullable
|
||||
field into ``[]`` rather than ``None`` -- which then failed the ``minItems``
|
||||
the colour array declares.
|
||||
|
||||
Both would have surfaced as "saving a scoreboard's per-mode colour fails
|
||||
validation", with nothing in the widget to suggest why. This posts what the
|
||||
widget really emits and asserts on what lands in config, then on what the
|
||||
resolver makes of it.
|
||||
"""
|
||||
|
||||
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", "recent"],
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Score",
|
||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||
"size": {"default": 10, "min": 4, "max": 16},
|
||||
"color": {"default": [255, 255, 255]},
|
||||
"offsets": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _SaveOk:
|
||||
status = type("S", (), {"value": "success"})()
|
||||
message = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post(tmp_path):
|
||||
"""POST form data to the real save route; yields the stored config."""
|
||||
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(form):
|
||||
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form)
|
||||
return resp, store.get("demo", {})
|
||||
|
||||
try:
|
||||
yield _post, pdir
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(a.api_v3, k, v)
|
||||
|
||||
|
||||
BASE_FORM = {
|
||||
"enabled": "true",
|
||||
"customization.score_text.font": "PressStart2P-Regular.ttf",
|
||||
"customization.score_text.font_size": "12",
|
||||
"customization.score_text.text_color.0": "255",
|
||||
"customization.score_text.text_color.1": "200",
|
||||
"customization.score_text.text_color.2": "0",
|
||||
"customization.layout.score_text.x_offset": "0",
|
||||
"customization.layout.score_text.y_offset": "0",
|
||||
}
|
||||
|
||||
# A mode the user left alone: every field blank. Blank colours are simply
|
||||
# absent, because the widget disables those inputs rather than posting "".
|
||||
BLANK_MODE = {
|
||||
"customization.modes.recent.score_text.font": "",
|
||||
"customization.modes.recent.score_text.font_size": "",
|
||||
"customization.modes.recent.layout.score_text.x_offset": "",
|
||||
"customization.modes.recent.layout.score_text.y_offset": "",
|
||||
}
|
||||
|
||||
|
||||
class TestSaveRoundTrip:
|
||||
def test_the_base_element_saves(self, post):
|
||||
_post, _ = post
|
||||
resp, cfg = _post(dict(BASE_FORM))
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert cfg["customization"]["score_text"] == {
|
||||
"font": "PressStart2P-Regular.ttf",
|
||||
"font_size": 12,
|
||||
"text_color": [255, 200, 0],
|
||||
}
|
||||
|
||||
def test_a_blank_mode_saves_as_null_not_zero(self, post):
|
||||
"""Null is the inherit sentinel; 0 or [] would pin the mode."""
|
||||
_post, _ = post
|
||||
resp, cfg = _post(dict(BASE_FORM, **BLANK_MODE))
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
recent = cfg["customization"]["modes"]["recent"]
|
||||
assert recent["score_text"] == {"font": None, "font_size": None,
|
||||
"text_color": None}
|
||||
assert recent["layout"]["score_text"] == {"x_offset": None,
|
||||
"y_offset": None}
|
||||
|
||||
def test_a_partly_set_mode_keeps_the_rest_inheriting(self, post):
|
||||
_post, _ = post
|
||||
resp, cfg = _post(dict(
|
||||
BASE_FORM,
|
||||
**{"customization.modes.live.score_text.font": "",
|
||||
"customization.modes.live.score_text.font_size": "16",
|
||||
"customization.modes.live.layout.score_text.x_offset": "",
|
||||
"customization.modes.live.layout.score_text.y_offset": "-3"}))
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
live = cfg["customization"]["modes"]["live"]
|
||||
assert live["score_text"]["font_size"] == 16
|
||||
assert live["score_text"]["font"] is None
|
||||
assert live["score_text"]["text_color"] is None
|
||||
assert live["layout"]["score_text"] == {"x_offset": None,
|
||||
"y_offset": -3}
|
||||
|
||||
def test_a_per_mode_colour_is_reassembled_into_a_list(self, post):
|
||||
"""The recombiner used to skip this: its type is ["array", "null"],
|
||||
not "array", so the three indexed inputs were never joined."""
|
||||
_post, _ = post
|
||||
resp, cfg = _post(dict(
|
||||
BASE_FORM,
|
||||
**{"customization.modes.live.score_text.text_color.0": "0",
|
||||
"customization.modes.live.score_text.text_color.1": "255",
|
||||
"customization.modes.live.score_text.text_color.2": "0"}))
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"]
|
||||
== [0, 255, 0])
|
||||
|
||||
def test_all_blank_colour_channels_mean_inherit(self, post):
|
||||
"""A hand-written form (or an older widget) can still post three
|
||||
empty strings; they must not become [] and fail minItems."""
|
||||
_post, _ = post
|
||||
resp, cfg = _post(dict(
|
||||
BASE_FORM,
|
||||
**{"customization.modes.live.score_text.text_color.0": "",
|
||||
"customization.modes.live.score_text.text_color.1": "",
|
||||
"customization.modes.live.score_text.text_color.2": ""}))
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"]
|
||||
is None)
|
||||
|
||||
|
||||
class TestWhatTheDisplayThenRenders:
|
||||
"""The point of the round trip: what the resolver makes of what was saved."""
|
||||
|
||||
def _styles(self, cfg, pdir):
|
||||
from src.element_style import (ElementStyleResolver,
|
||||
defaults_from_schema_file)
|
||||
defaults = defaults_from_schema_file(str(pdir / "config_schema.json"))
|
||||
out = {}
|
||||
for mode in (None, "live", "recent"):
|
||||
r = ElementStyleResolver(cfg, defaults, mode=mode)
|
||||
style = r.style("score_text",
|
||||
classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=10, classic_color=(255, 255, 255))
|
||||
out[mode] = (style.font_size, style.color, r.offset("score_text"))
|
||||
return out
|
||||
|
||||
def test_a_mode_override_reaches_the_display_and_the_rest_inherits(self, post):
|
||||
_post, pdir = post
|
||||
_resp, cfg = _post(dict(
|
||||
BASE_FORM, **BLANK_MODE,
|
||||
**{"customization.modes.live.score_text.font": "",
|
||||
"customization.modes.live.score_text.font_size": "16",
|
||||
"customization.modes.live.layout.score_text.x_offset": "",
|
||||
"customization.modes.live.layout.score_text.y_offset": "-3"}))
|
||||
styles = self._styles(cfg, pdir)
|
||||
assert styles[None] == (12, (255, 200, 0), (0, 0))
|
||||
# live overrides size and y, and inherits the colour it never set
|
||||
assert styles["live"] == (16, (255, 200, 0), (0, -3))
|
||||
assert styles["recent"] == (12, (255, 200, 0), (0, 0))
|
||||
@@ -4635,6 +4635,29 @@ def _is_field_required(key_path, schema):
|
||||
# Sentinel object to indicate a field should be skipped (not set in config)
|
||||
_SKIP_FIELD = object()
|
||||
|
||||
def _schema_type_is(prop, wanted):
|
||||
"""Whether a schema property is of ``wanted`` type.
|
||||
|
||||
JSON Schema allows a union (``["array", "null"]``), which the per-element
|
||||
style system uses for its per-mode override fields: null there means
|
||||
"inherit the base", so the type genuinely is "an array or nothing". A
|
||||
bare ``prop.get('type') == 'array'`` reads False for those, which meant
|
||||
the indexed colour inputs a form posts as ``...text_color.0/.1/.2`` were
|
||||
never recombined into a list.
|
||||
"""
|
||||
if not isinstance(prop, dict):
|
||||
return False
|
||||
declared = prop.get('type')
|
||||
if isinstance(declared, list):
|
||||
return wanted in declared
|
||||
return declared == wanted
|
||||
|
||||
|
||||
def _schema_allows_null(prop):
|
||||
"""Whether a schema property's declared type includes null."""
|
||||
return _schema_type_is(prop, 'null')
|
||||
|
||||
|
||||
def _parse_form_value_with_schema(value, key_path, schema):
|
||||
"""
|
||||
Parse a form value using schema information to determine correct type.
|
||||
@@ -4655,11 +4678,17 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
|
||||
# Handle None/empty values
|
||||
if value is None or (isinstance(value, str) and value.strip() == ''):
|
||||
# A nullable field left blank means null, not an empty container.
|
||||
# This is the inherit sentinel for per-mode style overrides: an
|
||||
# empty list there would read as "the user chose no colour" rather
|
||||
# than "follow the base element".
|
||||
if _schema_allows_null(prop):
|
||||
return None
|
||||
# If schema says it's an array, return empty array instead of None
|
||||
if prop and prop.get('type') == 'array':
|
||||
if prop and _schema_type_is(prop, 'array'):
|
||||
return []
|
||||
# If schema says it's an object, return empty dict instead of None
|
||||
if prop and prop.get('type') == 'object':
|
||||
if prop and _schema_type_is(prop, 'object'):
|
||||
return {}
|
||||
# If it's an optional string field, preserve empty string instead of None
|
||||
if prop and prop.get('type') == 'string':
|
||||
@@ -4696,7 +4725,7 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
return False
|
||||
|
||||
# Handle arrays based on schema
|
||||
if prop and prop.get('type') == 'array':
|
||||
if prop and _schema_type_is(prop, 'array'):
|
||||
# Try parsing as JSON first (handles "[1,2,3]" format)
|
||||
if stripped.startswith('['):
|
||||
try:
|
||||
@@ -5135,7 +5164,7 @@ def save_plugin_config():
|
||||
if last_part.isdigit():
|
||||
# Get schema property for the base path to verify it's an array
|
||||
base_prop = _get_schema_property(schema, base_path)
|
||||
if base_prop and base_prop.get('type') == 'array':
|
||||
if base_prop and _schema_type_is(base_prop, 'array'):
|
||||
# This is an array index field
|
||||
index = int(last_part)
|
||||
if base_path not in array_fields:
|
||||
@@ -5150,6 +5179,16 @@ def save_plugin_config():
|
||||
# Sort by index and extract values
|
||||
index_values.sort(key=lambda x: x[0])
|
||||
values = [v for _, v in index_values]
|
||||
# Every channel blank on a nullable field means "unset", not
|
||||
# an empty array: joining them would produce ", , ", which
|
||||
# parses to [] and then fails the minItems the array
|
||||
# declares. This is how a per-mode colour override says
|
||||
# "inherit the base colour".
|
||||
base_prop_for_null = _get_schema_property(schema, base_path)
|
||||
if (_schema_allows_null(base_prop_for_null)
|
||||
and all(str(v).strip() == '' for v in values)):
|
||||
_set_nested_value(plugin_config, base_path, None)
|
||||
continue
|
||||
# Combine values into comma-separated string for parsing
|
||||
combined_value = ', '.join(str(v) for v in values)
|
||||
# Parse as array using schema
|
||||
@@ -6713,6 +6752,18 @@ def get_fonts_catalog():
|
||||
# Check if this is a system font (cannot be deleted)
|
||||
is_system = catalog_key.lower() in SYSTEM_FONTS
|
||||
|
||||
# BDF files are fixed-size bitmap strikes: FreeType
|
||||
# accepts only the pixel size baked into the file. The
|
||||
# UI needs to know that before offering a size control,
|
||||
# or it offers a number that cannot take effect.
|
||||
native_size = None
|
||||
if font_type == 'bdf':
|
||||
try:
|
||||
from src.element_style import _read_bdf_native_size
|
||||
native_size = _read_bdf_native_size(str(filepath))
|
||||
except Exception:
|
||||
native_size = None
|
||||
|
||||
catalog[catalog_key] = {
|
||||
'filename': filename,
|
||||
'family_name': family_name,
|
||||
@@ -6720,6 +6771,8 @@ def get_fonts_catalog():
|
||||
'path': relative_path,
|
||||
'type': font_type,
|
||||
'is_system': is_system,
|
||||
'scalable': font_type != 'bdf',
|
||||
'native_size': native_size,
|
||||
'metadata': metadata if metadata else None
|
||||
}
|
||||
|
||||
|
||||
@@ -1412,3 +1412,57 @@ button.bg-white {
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 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. */
|
||||
.style-editor-row {
|
||||
grid-template-columns: minmax(7rem, 1.4fr) minmax(8rem, 2fr) 5.5rem 4.5rem 4rem 4rem;
|
||||
}
|
||||
.style-editor-row .form-control {
|
||||
padding: 0.2rem 0.4rem;
|
||||
height: auto;
|
||||
}
|
||||
.style-editor-row input[type="number"] {
|
||||
width: 100%;
|
||||
}
|
||||
.style-editor-row input[type="number"]:disabled {
|
||||
background-color: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.style-editor-colour {
|
||||
width: 2rem;
|
||||
height: 1.6rem;
|
||||
padding: 0;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.style-editor-tab {
|
||||
border: 1px solid #d1d5db;
|
||||
background-color: #f9fafb;
|
||||
color: #374151;
|
||||
}
|
||||
.style-editor-tab.is-active {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
.style-editor-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
/* Stack rather than squeeze: six columns on a phone is unreadable. */
|
||||
.style-editor-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.style-editor-head {
|
||||
display: none;
|
||||
}
|
||||
.style-editor-label {
|
||||
grid-column: 1 / -1;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Two 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`,
|
||||
* `customization.score_text.text_color.0`, `customization.layout.
|
||||
* score_text.x_offset`, `customization.modes.live....`). The whole
|
||||
* 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.
|
||||
*
|
||||
* Mode tabs edit `customization.modes.<mode>`, 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.
|
||||
*
|
||||
* @module StyleEditorWidget
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[StyleEditor] LEDMatrixWidgets registry not found. Load registry.js first.');
|
||||
return;
|
||||
}
|
||||
|
||||
var FONT_CACHE = null;
|
||||
var FONT_INFLIGHT = null;
|
||||
|
||||
/** The font catalog, fetched once per page. */
|
||||
function loadFonts() {
|
||||
if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); }
|
||||
if (FONT_INFLIGHT) { return FONT_INFLIGHT; }
|
||||
FONT_INFLIGHT = fetch('/api/v3/fonts/catalog')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (payload) {
|
||||
var catalog = (payload && payload.data && payload.data.catalog) || {};
|
||||
FONT_CACHE = Object.keys(catalog).map(function (key) {
|
||||
var entry = catalog[key];
|
||||
return {
|
||||
filename: entry.filename,
|
||||
label: entry.display_name || entry.filename,
|
||||
scalable: entry.scalable !== false,
|
||||
nativeSize: entry.native_size || null
|
||||
};
|
||||
}).sort(function (a, b) { return a.label.localeCompare(b.label); });
|
||||
return FONT_CACHE;
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.warn('[StyleEditor] could not load the font catalog', e);
|
||||
FONT_CACHE = [];
|
||||
return FONT_CACHE;
|
||||
});
|
||||
return FONT_INFLIGHT;
|
||||
}
|
||||
|
||||
function el(tag, attrs, children) {
|
||||
var node = document.createElement(tag);
|
||||
Object.keys(attrs || {}).forEach(function (k) {
|
||||
if (k === 'class') { node.className = attrs[k]; }
|
||||
else if (k === 'text') { node.textContent = attrs[k]; }
|
||||
else if (attrs[k] !== null && attrs[k] !== undefined) {
|
||||
node.setAttribute(k, attrs[k]);
|
||||
}
|
||||
});
|
||||
(children || []).forEach(function (c) { node.appendChild(c); });
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a control should show: the configured value if there is one,
|
||||
* else the schema default.
|
||||
*
|
||||
* The base tab must fall back to the default rather than leaving the
|
||||
* control empty. An empty <select> shows its first option, which for
|
||||
* fonts is whatever sorts first alphabetically -- so an untouched
|
||||
* scoreboard claimed every element used 10x20.bdf, and the size control
|
||||
* then locked itself to that bitmap font's fixed size. A mode tab is the
|
||||
* opposite: blank there means inherit, so it must stay blank.
|
||||
*/
|
||||
function effective(value, path, prop, optional) {
|
||||
var v = at(value, path);
|
||||
if (v !== undefined && v !== null) { return v; }
|
||||
if (optional) { return undefined; }
|
||||
return prop ? prop.default : undefined;
|
||||
}
|
||||
|
||||
/** Walk a nested value object by path segments. */
|
||||
function at(value, path) {
|
||||
var cur = value;
|
||||
for (var i = 0; i < path.length; i++) {
|
||||
if (cur === null || typeof cur !== 'object') { return undefined; }
|
||||
cur = cur[path[i]];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element blocks in a customization schema, in declared order.
|
||||
* `layout` and `modes` are containers, not elements.
|
||||
*/
|
||||
function elementKeys(schema) {
|
||||
var props = (schema && schema.properties) || {};
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
function titleOf(schema, key) {
|
||||
var prop = (schema.properties || {})[key] || {};
|
||||
return prop.title || key.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
// ---- individual controls -------------------------------------------
|
||||
|
||||
function fontControl(name, current, fonts, optional, onChange) {
|
||||
var select = el('select', {
|
||||
name: name,
|
||||
class: 'form-control text-sm style-editor-font'
|
||||
});
|
||||
if (optional) {
|
||||
select.appendChild(el('option', { value: '', text: 'Inherit' }));
|
||||
}
|
||||
fonts.forEach(function (f) {
|
||||
var opt = el('option', { value: f.filename, text: f.label });
|
||||
opt.dataset.scalable = f.scalable ? '1' : '0';
|
||||
opt.dataset.nativeSize = f.nativeSize || '';
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (current) { select.value = current; }
|
||||
select.addEventListener('change', onChange);
|
||||
return select;
|
||||
}
|
||||
|
||||
function numberControl(name, current, prop, placeholder) {
|
||||
var input = el('input', {
|
||||
type: 'number',
|
||||
name: name,
|
||||
class: 'form-control text-sm style-editor-size',
|
||||
placeholder: placeholder || ''
|
||||
});
|
||||
if (prop && prop.minimum !== undefined) { input.min = prop.minimum; }
|
||||
if (prop && prop.maximum !== undefined) { input.max = prop.maximum; }
|
||||
// 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; }
|
||||
return input;
|
||||
}
|
||||
|
||||
function toHex(rgb) {
|
||||
if (!Array.isArray(rgb) || rgb.length < 3) { return '#ffffff'; }
|
||||
return '#' + rgb.slice(0, 3).map(function (c) {
|
||||
var v = Math.max(0, Math.min(255, parseInt(c, 10) || 0));
|
||||
return ('0' + v.toString(16)).slice(-2);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function colourControl(name, current, optional) {
|
||||
// Three number inputs carry the value (name.0/.1/.2 is what the save
|
||||
// path recombines); the colour swatch is the human control and just
|
||||
// drives them.
|
||||
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
|
||||
});
|
||||
if (has) { input.value = current[i]; }
|
||||
return input;
|
||||
});
|
||||
|
||||
// A disabled input is not submitted at all, which is how an unset
|
||||
// optional colour says nothing rather than posting three empty
|
||||
// strings. The server copes with those too, but not posting them is
|
||||
// both clearer and one less thing depending on that.
|
||||
function setSubmitted(on) {
|
||||
channels.forEach(function (c) { c.disabled = !on; });
|
||||
}
|
||||
setSubmitted(!optional || has);
|
||||
|
||||
var swatch = el('input', {
|
||||
type: 'color',
|
||||
class: 'style-editor-colour',
|
||||
value: has ? toHex(current) : '#ffffff'
|
||||
});
|
||||
swatch.addEventListener('input', function () {
|
||||
var hex = swatch.value;
|
||||
[1, 3, 5].forEach(function (start, i) {
|
||||
channels[i].value = parseInt(hex.substr(start, 2), 16);
|
||||
});
|
||||
setSubmitted(true);
|
||||
if (clearBtn) { clearBtn.classList.remove('hidden'); }
|
||||
});
|
||||
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 <input type=color> has no empty
|
||||
// state of its own.
|
||||
clearBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'text-xs text-gray-500 hover:text-gray-800 style-editor-clear',
|
||||
title: 'Inherit the colour above',
|
||||
text: '×'
|
||||
});
|
||||
if (!has) { clearBtn.classList.add('hidden'); }
|
||||
clearBtn.addEventListener('click', function () {
|
||||
channels.forEach(function (c) { c.value = ''; });
|
||||
setSubmitted(false);
|
||||
swatch.value = '#ffffff';
|
||||
clearBtn.classList.add('hidden');
|
||||
});
|
||||
wrap.appendChild(clearBtn);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---- rows ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 row = el('div', {
|
||||
class: 'style-editor-row grid items-center gap-2 py-1',
|
||||
'data-element': key
|
||||
});
|
||||
row.appendChild(el('div', {
|
||||
class: 'text-sm text-gray-700 style-editor-label',
|
||||
text: titleOf(schema, key)
|
||||
}));
|
||||
|
||||
var sizeInput = null;
|
||||
var sizeNote = el('span', { class: 'text-xs text-gray-500 ml-1' });
|
||||
|
||||
function syncSize(select) {
|
||||
// A BDF font has exactly one usable pixel size; offering a free
|
||||
// number there is offering something that cannot take effect.
|
||||
if (!sizeInput || !select) { return; }
|
||||
var opt = select.options[select.selectedIndex];
|
||||
var scalable = !opt || opt.dataset.scalable !== '0';
|
||||
if (scalable) {
|
||||
sizeInput.disabled = false;
|
||||
sizeInput.title = '';
|
||||
sizeNote.textContent = '';
|
||||
} else {
|
||||
sizeInput.disabled = true;
|
||||
sizeInput.value = opt.dataset.nativeSize || '';
|
||||
sizeInput.title = 'This is a bitmap font; it renders at one fixed size.';
|
||||
sizeNote.textContent = 'fixed';
|
||||
}
|
||||
}
|
||||
|
||||
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'));
|
||||
});
|
||||
|
||||
if (fontSelect) { syncSize(fontSelect); }
|
||||
return row;
|
||||
}
|
||||
|
||||
function header(schema) {
|
||||
var cols = ['Element', 'Font', 'Size', 'Colour', 'X', 'Y'];
|
||||
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
|
||||
}));
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function table(opts) {
|
||||
var wrap = el('div', { class: 'style-editor-table' });
|
||||
wrap.appendChild(header(opts.schema));
|
||||
elementKeys(opts.schema).forEach(function (key) {
|
||||
wrap.appendChild(elementRow({
|
||||
schema: opts.schema,
|
||||
key: key,
|
||||
prefix: opts.prefix + '.' + key,
|
||||
layoutPrefix: opts.prefix + '.layout',
|
||||
value: opts.value,
|
||||
fonts: opts.fonts,
|
||||
optional: opts.optional
|
||||
}));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---- widget ----------------------------------------------------------
|
||||
|
||||
window.LEDMatrixWidgets.register('style-editor', {
|
||||
name: 'Style Editor',
|
||||
version: '1.0.0',
|
||||
|
||||
render: function (container, config, value, options) {
|
||||
var schema = (config && config.schema) || {};
|
||||
var base = (options && options.name) || 'customization';
|
||||
var current = value || {};
|
||||
|
||||
container.innerHTML = '';
|
||||
var root = el('div', { class: 'style-editor' });
|
||||
container.appendChild(root);
|
||||
|
||||
loadFonts().then(function (fonts) {
|
||||
var modeProps = ((schema.properties || {}).modes || {}).properties || {};
|
||||
var modes = Object.keys(modeProps);
|
||||
|
||||
var panels = {};
|
||||
var tabs = null;
|
||||
if (modes.length) {
|
||||
tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' });
|
||||
root.appendChild(tabs);
|
||||
}
|
||||
|
||||
function panel(id, node) {
|
||||
panels[id] = node;
|
||||
node.classList.add('style-editor-panel');
|
||||
root.appendChild(node);
|
||||
}
|
||||
|
||||
function show(id) {
|
||||
Object.keys(panels).forEach(function (k) {
|
||||
panels[k].hidden = (k !== id);
|
||||
});
|
||||
if (!tabs) { return; }
|
||||
Array.prototype.forEach.call(tabs.children, function (b) {
|
||||
b.classList.toggle('is-active', b.dataset.panel === id);
|
||||
});
|
||||
}
|
||||
|
||||
function tab(id, label) {
|
||||
if (!tabs) { return; }
|
||||
var b = el('button', {
|
||||
type: 'button',
|
||||
class: 'style-editor-tab text-sm px-3 py-1 rounded',
|
||||
text: label
|
||||
});
|
||||
b.dataset.panel = id;
|
||||
b.addEventListener('click', function () { show(id); });
|
||||
tabs.appendChild(b);
|
||||
}
|
||||
|
||||
panel('__base__', table({
|
||||
schema: schema, prefix: base, value: current,
|
||||
fonts: fonts, optional: false
|
||||
}));
|
||||
tab('__base__', 'All modes');
|
||||
|
||||
modes.forEach(function (mode) {
|
||||
var modeSchema = modeProps[mode] || {};
|
||||
var modeValue = at(current, ['modes', mode]) || {};
|
||||
var node = el('div');
|
||||
node.appendChild(el('p', {
|
||||
class: 'text-xs text-gray-500 mb-2',
|
||||
text: 'Anything left blank follows the "All modes" tab.'
|
||||
}));
|
||||
node.appendChild(table({
|
||||
schema: modeSchema,
|
||||
prefix: base + '.modes.' + mode,
|
||||
value: modeValue,
|
||||
fonts: fonts,
|
||||
optional: true
|
||||
}));
|
||||
panel(mode, node);
|
||||
tab(mode, modeSchema.title || mode);
|
||||
});
|
||||
|
||||
show('__base__');
|
||||
});
|
||||
},
|
||||
|
||||
getValue: function (fieldId) {
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[StyleEditor] widget registered');
|
||||
})();
|
||||
@@ -1002,6 +1002,7 @@
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/style-editor.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>
|
||||
|
||||
@@ -71,6 +71,54 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% elif obj_widget == 'style-editor' %}
|
||||
{# Composite per-element style editor. It renders its own inputs
|
||||
with the same dotted names the generic renderer would produce
|
||||
(customization.score_text.font, ...text_color.0, ...), so the
|
||||
save/validate/merge pipeline is untouched -- no hidden JSON
|
||||
blob, no new server-side parsing. It falls back to the normal
|
||||
nested rendering if the widget cannot be loaded. #}
|
||||
{% set obj_value = value if value is not none else {} %}
|
||||
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
|
||||
<div id="{{ field_id }}_container" class="style-editor-container mt-1"></div>
|
||||
<div id="{{ field_id }}_fallback">
|
||||
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
function initWidget() {
|
||||
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
||||
var widget = window.LEDMatrixWidgets.get('style-editor');
|
||||
if (!widget) { setTimeout(initWidget, 50); return; }
|
||||
var container = document.getElementById('{{ field_id }}_container');
|
||||
var fallback = document.getElementById('{{ field_id }}_fallback');
|
||||
if (!container) { return; }
|
||||
var value = {{ obj_value|tojson|safe }};
|
||||
var config = { schema: {{ prop|tojson|safe }} };
|
||||
try {
|
||||
widget.render(container, config, value, {
|
||||
fieldId: '{{ field_id }}',
|
||||
name: '{{ full_key }}',
|
||||
pluginId: '{{ plugin_id }}'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[style-editor] render failed', e);
|
||||
container.remove();
|
||||
return;
|
||||
}
|
||||
// The widget owns these fields now; drop the generic
|
||||
// rendering so the form does not post both.
|
||||
if (fallback) { fallback.remove(); }
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initWidget);
|
||||
} else {
|
||||
setTimeout(initWidget, 50);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% elif prop.properties %}
|
||||
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user