diff --git a/docs/PLUGIN_ELEMENT_STYLING.md b/docs/PLUGIN_ELEMENT_STYLING.md new file mode 100644 index 00000000..d6cc993a --- /dev/null +++ b/docs/PLUGIN_ELEMENT_STYLING.md @@ -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.`. | +| `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 `. +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). diff --git a/docs/README.md b/docs/README.md index 63128da6..c1b66aeb 100644 --- a/docs/README.md +++ b/docs/README.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 diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js index dbf3b613..5f995040 100644 --- a/web_interface/static/v3/js/widgets/style-editor.js +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -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', {