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>
5.2 KiB
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:
"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:
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:
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_MODEon 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
- Declare
x-style-elements(andx-style-modesif you have modes). - Read through
self.styles, passing your shipped values asclassic_*. - Honour
style.visible,style.offsetandstyle.scalewhere they apply. - Confirm an untouched config renders identically:
python scripts/check_plugin.py --plugin <id>. - Monorepo plugins: bump
manifest.jsonand runpython update_registry.py.
See also: docs/PLUGIN_CONFIGURATION_GUIDE.md, docs/FONT_MANAGER.md.