mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 18:46:37 +00:00
docs(plugins): how to declare styleable elements; harden the widget's lookups
The plugin-author guide for the compact x-style-elements declaration -- what each key does, how to read values back without breaking the "user-forced only when it differs from the default" rule, and why a hand-written block needs no changes to be adopted. Also clears the static-analysis findings on style-editor.js. Every lookup in that file is keyed by something out of a schema or a saved config, so a key of __proto__ or constructor would walk the prototype chain and hand back a function instead of a schema; reads now go through an own-property helper. The panel registry became a list, and the flagged vars moved to their function roots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
# Per-element styling for plugin authors
|
||||
|
||||
Users want to change the font, size and colour of individual things on screen,
|
||||
nudge them a few pixels, hide the ones they do not care about, and scale a logo
|
||||
down. This is the one system that does that, and a plugin joins it by
|
||||
**declaring elements in its `config_schema.json`** — not by writing a style
|
||||
resolver, a font cache or a web form.
|
||||
|
||||
The short version:
|
||||
|
||||
```jsonc
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"title": "Display Customization",
|
||||
"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,
|
||||
"visible": true,
|
||||
"align": true
|
||||
},
|
||||
"home_logo": { "title": "Home logo", "offsets": true, "scale": true }
|
||||
},
|
||||
"x-style-modes": ["live", "upcoming", "recent"]
|
||||
}
|
||||
```
|
||||
|
||||
That is the whole declaration. The core expands it into a full JSON Schema, the
|
||||
web UI renders a compact style editor with a row per element, and the values
|
||||
land in `config.json` under the keys you named.
|
||||
|
||||
## What each key does
|
||||
|
||||
| Key | Effect |
|
||||
|---|---|
|
||||
| `font` | Font picker listing every shipped **and user-uploaded** font. |
|
||||
| `size` | Number field. `min`/`max` also cap which fixed-size fonts are offered. |
|
||||
| `color` | Colour swatch; stored as `[r, g, b]`. |
|
||||
| `offsets` | X/Y nudge, stored under `customization.layout.<element>`. |
|
||||
| `visible` | Show/hide toggle. |
|
||||
| `align` | `left` / `center` / `right`. |
|
||||
| `scale` | Size multiplier, for logos and images. Also under `layout`. |
|
||||
|
||||
`x-style-modes` is optional. Declare it and every element gains a per-mode
|
||||
override tab — a scoreboard can then style its live, upcoming and recent cards
|
||||
separately. **A mode field left blank means "inherit", not zero.**
|
||||
|
||||
## Reading the values
|
||||
|
||||
Every plugin inherits `BasePlugin.styles`, which finds your `config_schema.json`
|
||||
on its own:
|
||||
|
||||
```python
|
||||
style = self.styles.style(
|
||||
"score_text",
|
||||
classic_font="PressStart2P-Regular.ttf", # what you shipped
|
||||
classic_size=10,
|
||||
classic_color=(255, 255, 255),
|
||||
)
|
||||
if style.visible:
|
||||
draw.text((x + style.offset[0], y + style.offset[1]),
|
||||
text, font=style.font, fill=style.color)
|
||||
```
|
||||
|
||||
For a specific mode, use `self.styles_for("recent")`, or set
|
||||
`STYLE_MODE = "recent"` on the class and keep calling `self.styles`.
|
||||
|
||||
### The one rule that matters
|
||||
|
||||
**Pass your shipped values as the `classic_*` arguments.** The resolver returns
|
||||
them verbatim unless the user actually changed something, which is what keeps an
|
||||
untouched install rendering byte-identically. It can tell the difference because
|
||||
a value only counts as user-forced when it *differs from the schema default* —
|
||||
the save path writes the full default object into `config.json` on every save,
|
||||
so "present in config" proves nothing.
|
||||
|
||||
Never compare against the default yourself; that rule lives in exactly one place.
|
||||
|
||||
### Stateless readers
|
||||
|
||||
For helpers handed a config dict rather than a plugin instance:
|
||||
|
||||
```python
|
||||
from src.element_style import (element_color, element_visible,
|
||||
element_align, element_scale, layout_offset)
|
||||
|
||||
colour = element_color(config, "score_text", (255, 255, 255), mode)
|
||||
shown = element_visible(config, "records", True, mode)
|
||||
dy = layout_offset(config, "score", "y_offset", 0, mode)
|
||||
```
|
||||
|
||||
## Sports scoreboards
|
||||
|
||||
`SportsCoreSharedMixin` wires most of this up already. Two things to know:
|
||||
|
||||
* **Name your draws.** `_draw_text_with_outline(..., element="score_text")`
|
||||
resolves the colour by name *and* honours the visibility toggle. Without it
|
||||
the colour has to be guessed from the identity of the font object, which
|
||||
cannot tell two elements apart when they share a face — the case every
|
||||
bitmap font is in.
|
||||
* **Modes are free.** Live/upcoming/recent are separate instances, so setting
|
||||
`SKIN_MODE` on each is enough; no call site passes a mode.
|
||||
|
||||
## Adopting an existing hand-written block
|
||||
|
||||
If your schema already spells out `font` / `font_size` / `text_color` per
|
||||
element longhand, **you do not need to change anything**. The core recognises
|
||||
that shape and upgrades it in place: the style editor, the real font picker
|
||||
(including uploaded fonts) and per-mode overrides all appear on a core update.
|
||||
Add `x-style-modes` if you want the mode tabs.
|
||||
|
||||
## Fonts, and why size is sometimes locked
|
||||
|
||||
32 of the 35 shipped fonts are fixed-strike BDF bitmaps: they render at exactly
|
||||
one pixel size and ignore `font_size`. The picker knows which, and the editor
|
||||
locks the size field to the native size and labels it `fixed`. A font too tall
|
||||
for the `max` you declared is not offered at all.
|
||||
|
||||
Uploaded fonts (Fonts tab) land in `assets/fonts/` and appear in the picker
|
||||
automatically.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Declare `x-style-elements` (and `x-style-modes` if you have modes).
|
||||
2. Read through `self.styles`, passing your shipped values as `classic_*`.
|
||||
3. Honour `style.visible`, `style.offset` and `style.scale` where they apply.
|
||||
4. Confirm an untouched config renders identically:
|
||||
`python scripts/check_plugin.py --plugin <id>`.
|
||||
5. Monorepo plugins: bump `manifest.json` and run `python update_registry.py`.
|
||||
|
||||
See also: [docs/PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md),
|
||||
[docs/FONT_MANAGER.md](FONT_MANAGER.md).
|
||||
@@ -45,6 +45,8 @@ Going deeper:
|
||||
|
||||
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
|
||||
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
|
||||
- [PLUGIN_ELEMENT_STYLING.md](PLUGIN_ELEMENT_STYLING.md) — let users restyle,
|
||||
move, hide and scale individual elements (per display mode, if you have them)
|
||||
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
|
||||
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
|
||||
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
|
||||
|
||||
@@ -39,6 +39,28 @@
|
||||
var FONT_CACHE = null;
|
||||
var FONT_INFLIGHT = null;
|
||||
|
||||
var hasOwn = Object.prototype.hasOwnProperty;
|
||||
|
||||
/**
|
||||
* Read one own property, by a key that came from data.
|
||||
*
|
||||
* Every lookup in here is keyed by something out of a schema or a saved
|
||||
* config -- an element name, a mode name, a field name. A key of
|
||||
* `__proto__` or `constructor` would otherwise walk up the prototype
|
||||
* chain and hand back a function instead of a schema, so reads go
|
||||
* through here and misses come back undefined.
|
||||
*/
|
||||
function own(obj, key) {
|
||||
if (!obj || typeof obj !== 'object') { return undefined; }
|
||||
return hasOwn.call(obj, key) ? obj[key] : undefined;
|
||||
}
|
||||
|
||||
/** `own`, but always an object -- for `(x || {}).properties` chains. */
|
||||
function ownObj(obj, key) {
|
||||
var found = own(obj, key);
|
||||
return (found && typeof found === 'object') ? found : {};
|
||||
}
|
||||
|
||||
/** The font catalog, fetched once per page. */
|
||||
function loadFonts() {
|
||||
if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); }
|
||||
@@ -51,7 +73,7 @@
|
||||
.then(function (payload) {
|
||||
var catalog = (payload && payload.data && payload.data.catalog) || {};
|
||||
FONT_CACHE = Object.keys(catalog).map(function (key) {
|
||||
var entry = catalog[key];
|
||||
var entry = ownObj(catalog, key);
|
||||
return {
|
||||
filename: entry.filename,
|
||||
label: entry.display_name || entry.filename,
|
||||
@@ -74,10 +96,11 @@
|
||||
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]);
|
||||
var v = own(attrs, k);
|
||||
if (k === 'class') { node.className = v; }
|
||||
else if (k === 'text') { node.textContent = v; }
|
||||
else if (v !== null && v !== undefined) {
|
||||
node.setAttribute(k, v);
|
||||
}
|
||||
});
|
||||
(children || []).forEach(function (c) { node.appendChild(c); });
|
||||
@@ -87,9 +110,10 @@
|
||||
/** Walk a nested value object by path segments. */
|
||||
function at(value, path) {
|
||||
var cur = value;
|
||||
for (var i = 0; i < path.length; i++) {
|
||||
var i;
|
||||
for (i = 0; i < path.length; i++) {
|
||||
if (cur === null || typeof cur !== 'object') { return undefined; }
|
||||
cur = cur[path[i]];
|
||||
cur = own(cur, path[i]);
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
@@ -121,12 +145,12 @@
|
||||
var order = schema['x-propertyOrder'] || Object.keys(props);
|
||||
return order.filter(function (k) {
|
||||
return k !== 'layout' && k !== 'modes'
|
||||
&& props[k] && props[k].properties;
|
||||
&& own(props, k) && ownObj(props, k).properties;
|
||||
});
|
||||
}
|
||||
|
||||
function titleOf(schema, key) {
|
||||
var prop = (schema.properties || {})[key] || {};
|
||||
var prop = ownObj(schema.properties || {}, key);
|
||||
return prop.title || key.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
@@ -257,11 +281,16 @@
|
||||
// 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;
|
||||
});
|
||||
var startValues = has ? current.slice(0, 3) : [];
|
||||
var channels = startValues.concat([null, null, null])
|
||||
.slice(0, 3)
|
||||
.map(function (channelValue, i) {
|
||||
var input = el('input', {
|
||||
type: 'hidden', name: name + '.' + i
|
||||
});
|
||||
if (has) { input.value = channelValue; }
|
||||
return input;
|
||||
});
|
||||
|
||||
// A disabled input is not submitted at all, which is how an unset
|
||||
// optional colour says nothing rather than posting three empty
|
||||
@@ -280,8 +309,8 @@
|
||||
});
|
||||
swatch.addEventListener('input', function () {
|
||||
var hex = swatch.value;
|
||||
[1, 3, 5].forEach(function (start, i) {
|
||||
channels[i].value = parseInt(hex.substr(start, 2), 16);
|
||||
channels.forEach(function (channel, i) {
|
||||
channel.value = parseInt(hex.substr(1 + i * 2, 2), 16);
|
||||
});
|
||||
setSubmitted(true);
|
||||
if (clearBtn) { clearBtn.classList.remove('hidden'); }
|
||||
@@ -334,15 +363,17 @@
|
||||
*/
|
||||
function columnsFor(schema) {
|
||||
var props = schema.properties || {};
|
||||
var layoutProps = (props.layout || {}).properties || {};
|
||||
var seen = {};
|
||||
var layoutProps = ownObj(props, 'layout').properties || {};
|
||||
// No prototype to inherit from, so a field literally named
|
||||
// "constructor" is a column like any other.
|
||||
var seen = Object.create(null);
|
||||
elementKeys(schema).forEach(function (key) {
|
||||
Object.keys((props[key] || {}).properties || {}).forEach(
|
||||
Object.keys(ownObj(props, key).properties || {}).forEach(
|
||||
function (f) { seen[f] = 'element'; });
|
||||
Object.keys((layoutProps[key] || {}).properties || {}).forEach(
|
||||
Object.keys(ownObj(layoutProps, key).properties || {}).forEach(
|
||||
function (f) { seen[f] = 'layout'; });
|
||||
});
|
||||
var known = COLUMN_ORDER.filter(function (f) { return seen[f]; });
|
||||
var known = COLUMN_ORDER.filter(function (f) { return own(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) {
|
||||
@@ -351,8 +382,8 @@
|
||||
return known.concat(extra).map(function (f) {
|
||||
return {
|
||||
key: f,
|
||||
where: seen[f],
|
||||
label: COLUMN_LABELS[f] || f.replace(/_/g, ' ')
|
||||
where: own(seen, f),
|
||||
label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ')
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -362,10 +393,10 @@
|
||||
var prop = opts.prop;
|
||||
var declared = prop.type;
|
||||
var types = Array.isArray(declared) ? declared : [declared];
|
||||
var xOptions = prop['x-options'] || prop['x_options'] || {};
|
||||
var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null;
|
||||
|
||||
if (opts.key === 'font' || prop['x-widget'] === 'font-selector') {
|
||||
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.current),
|
||||
opts.optional, opts.onFontChange);
|
||||
@@ -390,9 +421,9 @@
|
||||
var value = opts.value;
|
||||
var optional = opts.optional;
|
||||
|
||||
var props = ((schema.properties || {})[key] || {}).properties || {};
|
||||
var layoutProps = ((schema.properties || {}).layout || {}).properties || {};
|
||||
var axes = (layoutProps[key] || {}).properties || {};
|
||||
var props = ownObj(schema.properties || {}, key).properties || {};
|
||||
var layoutProps = ownObj(schema.properties || {}, 'layout').properties || {};
|
||||
var axes = ownObj(layoutProps, key).properties || {};
|
||||
|
||||
var row = el('div', {
|
||||
class: 'style-editor-row grid items-center gap-2 py-1',
|
||||
@@ -427,7 +458,8 @@
|
||||
|
||||
opts.columns.forEach(function (col) {
|
||||
var inLayout = col.where === 'layout';
|
||||
var prop = inLayout ? axes[col.key] : props[col.key];
|
||||
var prop = inLayout ? own(axes, col.key) : own(props, col.key);
|
||||
var cell;
|
||||
if (!prop) {
|
||||
// This element does not declare that field; keep the grid
|
||||
// aligned with an empty cell.
|
||||
@@ -452,7 +484,7 @@
|
||||
if (col.key === 'font') { fontSelect = node; }
|
||||
if (col.key === 'font_size') {
|
||||
sizeInput = node;
|
||||
var cell = el('div', { class: 'flex items-center' });
|
||||
cell = el('div', { class: 'flex items-center' });
|
||||
cell.appendChild(node);
|
||||
cell.appendChild(sizeNote);
|
||||
node = cell;
|
||||
@@ -520,10 +552,12 @@
|
||||
container.appendChild(root);
|
||||
|
||||
loadFonts().then(function (fonts) {
|
||||
var modeProps = ((schema.properties || {}).modes || {}).properties || {};
|
||||
var modeProps = ownObj(schema.properties || {}, 'modes').properties || {};
|
||||
var modes = Object.keys(modeProps);
|
||||
|
||||
var panels = {};
|
||||
// A list, not a keyed object: the ids are mode names out of
|
||||
// the schema, and nothing here needs a lookup by key.
|
||||
var panels = [];
|
||||
var tabs = null;
|
||||
if (modes.length) {
|
||||
tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' });
|
||||
@@ -531,14 +565,14 @@
|
||||
}
|
||||
|
||||
function panel(id, node) {
|
||||
panels[id] = node;
|
||||
panels.push({ id: id, node: node });
|
||||
node.classList.add('style-editor-panel');
|
||||
root.appendChild(node);
|
||||
}
|
||||
|
||||
function show(id) {
|
||||
Object.keys(panels).forEach(function (k) {
|
||||
panels[k].hidden = (k !== id);
|
||||
panels.forEach(function (p) {
|
||||
p.node.hidden = (p.id !== id);
|
||||
});
|
||||
if (!tabs) { return; }
|
||||
Array.prototype.forEach.call(tabs.children, function (b) {
|
||||
@@ -565,7 +599,7 @@
|
||||
tab('__base__', 'All modes');
|
||||
|
||||
modes.forEach(function (mode) {
|
||||
var modeSchema = modeProps[mode] || {};
|
||||
var modeSchema = ownObj(modeProps, mode);
|
||||
var modeValue = at(current, ['modes', mode]) || {};
|
||||
var node = el('div');
|
||||
node.appendChild(el('p', {
|
||||
|
||||
Reference in New Issue
Block a user