mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
* feat(element-style): universal per-element style resolver One shared implementation of the three things every customizable plugin re-invented: a font loader, the customization.layout x/y-offset reader, and the "did the user actually override this font?" check. The override check is the load-bearing piece: the web UI's save flow writes full schema defaults into config.json on every save, and the plugin manager merges defaults again before instantiation, so key presence never means user intent. The resolver compares against the plugin's own schema defaults (via schema_manager), degrading to caller-supplied classic defaults when unavailable. This retires the hand-maintained _CLASSIC_FONT_DEFAULTS dicts that shipped broken twice. The loader is a superset of the four per-plugin variants: alias resolution (baseball), truetype for TTF/OTF/BDF (FreeType loads BDF at native size), .pil sidecar fallback for BDF (football), fallback font, PIL default — never raises. Also introduces the text_color convention ([r,g,b], absent = keep the plugin's hardcoded color). BasePlugin gains a lazy style_resolver property (invalidated on config change) and element_style() sugar; standalone helpers receive the resolver from their owning plugin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * feat(element-style): x-style-elements schema expansion + color provenance Plugins can now declare styleable display elements once, compactly, on their customization schema ("x-style-elements") instead of hand-copying the ~50-line font/font_size/text_color/offset property blocks (currently duplicated 52x across the plugin monorepo). SchemaManager.load_schema expands declarations before caching, so the config form, save path, validation, and defaults generation all see the same shape; the single expansion implementation lives in src.element_style and is also applied by defaults_from_schema_file, keeping the web UI's view and a plugin's raw-schema-file view of the defaults provably identical (parity test). Generated blocks use only widgets the config form already renders (font-selector, color-picker, number inputs) and update x-propertyOrder when present (the template only renders listed keys). Expansion is idempotent, never mutates its input or the cached/on-disk schema, and a hand-written block for the same element always wins. Color gets the same provenance rule as fonts: the web form always posts the RGB inputs, so a saved config carries the schema-default color whether or not the user touched it — the resolver now only honors a color that DIFFERS from the schema default, and keeps the plugin's classic (possibly state-dependent, e.g. gold-on-touchdown) color otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * feat(web): live plugin preview on the config page Adds POST /api/v3/plugins/preview: renders a plugin headlessly with the CANDIDATE (unsaved) config and returns a PNG — so users can see exactly what the panel will show before saving, at their real panel size (from display.hardware) or a chosen test size. Two extractions make it drift-proof rather than parallel-implemented: - dev_server's _render_once moves to src/plugin_system/testing/render_service.py (pure PIL via VisualTestDisplayManager, install_deps=False always — safe in the web process, which never touches display hardware); the dev server now wraps it. - save_plugin_config's ~350-line form->config conversion is extracted verbatim as parse_plugin_config_form and shared by the preview endpoint, so preview and save can never interpret the form differently. update() is skipped by default (no network on the request thread); plugins with a test/harness.json get their mock-data fixture primed instead, and ?skip_update=0 opts into a live update. The config page gains a Live Preview panel (HTMX hx-include of the existing form, size selector, pixelated img fragment) — works for every plugin with zero per-plugin code, including the x-style-elements font/size/color fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(web): expand x-style-elements in the config-page form render too pages_v3's plugin-config partial loads config_schema.json directly from disk rather than through SchemaManager.load_schema, so declared style elements expanded everywhere EXCEPT the form the user actually sees. Found live on the devpi: the API served the expanded schema and the save path validated against it, but the config page rendered no font/color/offset fields. Apply the same (idempotent) expansion here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(web): preview size selector — htmx caches hx-post, use hx-vals Found live on the devpi: selecting a preview size did nothing because htmx snapshots the request path when it processes the button, so the size dropdown's onchange mutation of hx-post never took effect. The size now travels as a __preview_size=WxH form field attached via hx-vals (evaluated at request time); the endpoint honors it (query args still win for API callers) and strips it before form parsing so it can't leak into the candidate config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(web): allowlist plugin_id in the preview endpoint's dir lookup Same defense as the dev server's find_plugin_dir (and pages_v3's existing pattern): plugin_id arrives in request input and is used to build filesystem paths — reject anything outside ^[a-zA-Z0-9_-]{1,64}$ before touching the filesystem. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix: harden preview + resolver edges found in self-review - extract_schema_defaults now matches SchemaManager's array handling ([] for arrays without defaults, [item] for item-level defaults) — the documented parity guarantee was false for array-typed properties; parity test extended to cover them. - render_plugin_once calls plugin cleanup() in a finally (image captured first — cleanup may clear the canvas): preview instances could leak sessions/threads per request in the long-running web process. - Preview JSON path deep-merges the candidate onto the saved config, matching the form path — a shallow update() silently dropped saved sibling values in any nested section the candidate touched. - Preview render is bounded (15s, 504 on timeout, daemonized worker): a hanging plugin display() no longer pins a web worker forever. - ElementStyleResolver.is_for(config): public staleness check for callers that cache a resolver, instead of poking _config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(render-service): adopt the dev server's exception-detail policy Port c6962701's convention into the shared render service (which supersedes the inline _render_once it patched): full tracebacks to the server log via exc_info, only the exception class name to the client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
553 lines
23 KiB
Python
553 lines
23 KiB
Python
"""Universal per-element style resolution for plugin customization.
|
|
|
|
Plugins expose per-element user customization under ``config['customization']``:
|
|
|
|
"customization": {
|
|
"score_text": {"font": "PressStart2P-Regular.ttf", "font_size": 10,
|
|
"text_color": [255, 255, 255]},
|
|
"layout": {"score": {"x_offset": 2, "y_offset": 0}}
|
|
}
|
|
|
|
Before this module, every plugin re-implemented the same three pieces —
|
|
a font loader, an x/y-offset reader, and (for adaptive layout mode) a
|
|
"did the user actually override this?" check. The loaders diverged four
|
|
ways across the sports plugins and music, the offset reader was copied
|
|
twice, and the override check is subtle enough that it shipped broken
|
|
twice: the web UI's save flow (schema_manager.merge_with_defaults) writes
|
|
the FULL schema default object into config.json on every save, and the
|
|
plugin manager merges defaults into ``config`` again before instantiation,
|
|
so a key being *present* never means the user set it. The only correct
|
|
test is "present AND different from the schema default", which requires
|
|
knowing the schema defaults — previously a hand-maintained dict per plugin.
|
|
|
|
This module is that logic, once:
|
|
|
|
resolver = ElementStyleResolver(config, schema_defaults)
|
|
style = resolver.style('score_text', classic_font='PressStart2P-Regular.ttf',
|
|
classic_size=10)
|
|
style.font # loaded PIL font, ready for draw.text
|
|
style.user_forced # True only for a genuine user override
|
|
dx, dy = resolver.offset('score')
|
|
|
|
``BasePlugin.element_style()`` wires this up automatically (schema defaults
|
|
come from the plugin's own config_schema.json via the schema manager).
|
|
Standalone helper classes (e.g. a plugin's GameRenderer) should receive a
|
|
resolver from their owning plugin rather than build one themselves.
|
|
|
|
Deliberately pure PIL + stdlib: no imports from the plugin system or web
|
|
layer, so it is usable from any renderer and trivially testable.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, Optional, Tuple, Union
|
|
|
|
from PIL import ImageFont
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Font-family aliases accepted in customization configs. Filenames pass
|
|
# through unchanged. (Supersedes the per-plugin copies in the baseball
|
|
# plugin; keep names in sync with the web UI's /fonts/catalog so the
|
|
# font-selector widget and this loader agree.)
|
|
FONT_ALIASES: Dict[str, str] = {
|
|
"press_start": "PressStart2P-Regular.ttf",
|
|
"four_by_six": "4x6-font.ttf",
|
|
"five_by_seven": "5x7.bdf",
|
|
}
|
|
|
|
DEFAULT_FONTS_DIR = os.path.join("assets", "fonts")
|
|
DEFAULT_FALLBACK_FONT = "PressStart2P-Regular.ttf"
|
|
|
|
PILFont = Union[ImageFont.FreeTypeFont, ImageFont.ImageFont]
|
|
|
|
|
|
def resolve_font_name(font_name: str) -> str:
|
|
"""Resolve a font family alias to its filename, leaving filenames as-is."""
|
|
return FONT_ALIASES.get(font_name, font_name)
|
|
|
|
|
|
def extract_schema_defaults(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Nested defaults dict from a JSON Schema (mirrors
|
|
SchemaManager.extract_defaults_from_schema, kept here so this module
|
|
stays importable without the plugin system — the parity test in
|
|
test_element_style.py guards against drift).
|
|
|
|
An object property carrying its own ``default`` short-circuits recursion,
|
|
and array-typed properties without one default to ``[]`` (or a
|
|
single-item list when the items schema declares a default), matching
|
|
the schema manager's behavior exactly.
|
|
"""
|
|
defaults: Dict[str, Any] = {}
|
|
for key, prop in (schema.get("properties") or {}).items():
|
|
if not isinstance(prop, dict):
|
|
continue
|
|
if "default" in prop:
|
|
defaults[key] = prop["default"]
|
|
elif prop.get("type") == "object" and "properties" in prop:
|
|
nested = extract_schema_defaults(prop)
|
|
if nested:
|
|
defaults[key] = nested
|
|
elif prop.get("type") == "array" and "items" in prop:
|
|
items = prop.get("items")
|
|
if isinstance(items, dict) and "default" in items and \
|
|
not (items.get("type") == "object" and "properties" in items):
|
|
defaults[key] = [items["default"]]
|
|
else:
|
|
defaults[key] = []
|
|
return defaults
|
|
|
|
|
|
def defaults_from_schema_file(schema_path: str) -> Dict[str, Any]:
|
|
"""Schema defaults straight from a plugin's own config_schema.json.
|
|
|
|
Plugins that hand a resolver to standalone helper classes should build
|
|
it with this, pointed at their own schema file — it works identically
|
|
in production, the test harness, and the dev server, unlike the plugin
|
|
manager's schema manager (absent under mocks). x-style-elements
|
|
declarations are expanded first, so declared elements' defaults are
|
|
included exactly as the web UI's schema manager sees them. Returns {}
|
|
on any error.
|
|
"""
|
|
try:
|
|
with open(schema_path, "r", encoding="utf-8") as f:
|
|
schema = json.load(f)
|
|
if isinstance(schema, dict):
|
|
return extract_schema_defaults(expand_style_elements(schema))
|
|
except Exception as e:
|
|
logger.debug("Could not load schema defaults from %s: %s",
|
|
schema_path, e)
|
|
return {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# x-style-elements schema expansion
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# A plugin declares its styleable display elements ONCE, compactly, on its
|
|
# customization object instead of hand-copying ~50-line property blocks:
|
|
#
|
|
# "customization": {
|
|
# "type": "object",
|
|
# "x-style-elements": {
|
|
# "score_text": {
|
|
# "title": "Game Score",
|
|
# "font": {"default": "PressStart2P-Regular.ttf"},
|
|
# "size": {"default": 10, "min": 4, "max": 16},
|
|
# "color": true, # or {"default": [r,g,b]}
|
|
# "offsets": true
|
|
# }
|
|
# }
|
|
# }
|
|
#
|
|
# expand_style_elements() turns each declaration into full font/font_size/
|
|
# text_color/layout-offset property blocks (marked "x-style-managed": true)
|
|
# using widgets the web config form already renders. The declaration stays
|
|
# in the schema — it doubles as the element registry for tooling. Expansion
|
|
# is idempotent, and a hand-written property block for the same element
|
|
# always wins over the generated one.
|
|
#
|
|
# SchemaManager.load_schema() applies this at serve time (so the web form,
|
|
# save path, validation, and defaults generation all see the expanded
|
|
# shape), and defaults_from_schema_file() applies it when plugins read
|
|
# their own schema — one implementation, no drift.
|
|
|
|
def get_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""The x-style-elements declaration from a schema ({} if none)."""
|
|
try:
|
|
decl = schema.get("properties", {}).get("customization", {}).get("x-style-elements")
|
|
return decl if isinstance(decl, dict) else {}
|
|
except AttributeError:
|
|
return {}
|
|
|
|
|
|
def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Expand x-style-elements into full customization property blocks.
|
|
|
|
Returns the schema unchanged (same object) when there is nothing to
|
|
expand; otherwise returns an expanded DEEP COPY, leaving the input
|
|
untouched. Never raises — on any error the original schema is returned
|
|
so a malformed declaration can't take a plugin down.
|
|
"""
|
|
import copy
|
|
|
|
try:
|
|
declarations = get_style_elements(schema)
|
|
if not declarations:
|
|
return schema
|
|
|
|
schema = copy.deepcopy(schema)
|
|
customization = schema["properties"]["customization"]
|
|
properties = customization.setdefault("properties", {})
|
|
order = customization.get("x-propertyOrder")
|
|
|
|
offset_elements = []
|
|
for element_key, declaration in declarations.items():
|
|
if not isinstance(declaration, dict):
|
|
continue
|
|
if declaration.get("offsets") is True:
|
|
offset_elements.append((element_key, declaration))
|
|
if element_key in properties:
|
|
# Hand-written (or previously expanded) block wins.
|
|
continue
|
|
properties[element_key] = _style_element_block(element_key, declaration)
|
|
if isinstance(order, list) and element_key not in order:
|
|
# Keep generated elements ahead of the layout section.
|
|
insert_at = order.index("layout") if "layout" in order else len(order)
|
|
order.insert(insert_at, element_key)
|
|
|
|
if offset_elements:
|
|
_expand_offset_blocks(properties, order, offset_elements)
|
|
|
|
return schema
|
|
except Exception as e:
|
|
logger.error("x-style-elements expansion failed: %s", e)
|
|
return schema
|
|
|
|
|
|
def _style_element_block(element_key: str, declaration: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""One generated customization.<element> property block."""
|
|
title = declaration.get("title") or element_key.replace("_", " ").title()
|
|
font_decl = declaration.get("font") if isinstance(declaration.get("font"), dict) else {}
|
|
size_decl = declaration.get("size") if isinstance(declaration.get("size"), dict) else {}
|
|
|
|
block_properties: Dict[str, Any] = {
|
|
"font": {
|
|
"type": "string",
|
|
"title": "Font Family",
|
|
"description": "Select the font to use",
|
|
"x-widget": "font-selector",
|
|
"default": font_decl.get("default", DEFAULT_FALLBACK_FONT),
|
|
},
|
|
"font_size": {
|
|
"type": "integer",
|
|
"title": "Font Size",
|
|
"description": ("Font size in pixels (BDF fonts are fixed-size "
|
|
"and ignore this)"),
|
|
"minimum": size_decl.get("min", 4),
|
|
"maximum": size_decl.get("max", 32),
|
|
"default": size_decl.get("default", 8),
|
|
},
|
|
}
|
|
block_order = ["font", "font_size"]
|
|
|
|
color_decl = declaration.get("color")
|
|
if color_decl:
|
|
default_color = [255, 255, 255]
|
|
if isinstance(color_decl, dict) and isinstance(color_decl.get("default"), list):
|
|
default_color = color_decl["default"]
|
|
# The default doubles as the "untouched" sentinel: the resolver only
|
|
# honors a color that DIFFERS from it, so untouched saves (the web
|
|
# form always posts the RGB inputs) can't clobber a plugin's
|
|
# semantic/state-dependent colors.
|
|
block_properties["text_color"] = {
|
|
"type": "array",
|
|
"title": "Text Color",
|
|
"description": "RGB color as [red, green, blue] (0-255 each)",
|
|
"items": {"type": "integer", "minimum": 0, "maximum": 255},
|
|
"minItems": 3,
|
|
"maxItems": 3,
|
|
"x-widget": "color-picker",
|
|
"default": default_color,
|
|
}
|
|
block_order.append("text_color")
|
|
|
|
return {
|
|
"type": "object",
|
|
"title": title,
|
|
"description": f"Style settings for {title}",
|
|
"x-style-managed": True,
|
|
"properties": block_properties,
|
|
"x-propertyOrder": block_order,
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
|
|
def _expand_offset_blocks(properties: Dict[str, Any], order,
|
|
offset_elements) -> None:
|
|
"""Generate customization.layout.<element> x/y offset blocks."""
|
|
layout = properties.get("layout")
|
|
if not isinstance(layout, dict):
|
|
layout = {
|
|
"type": "object",
|
|
"title": "Layout Positioning",
|
|
"description": ("Adjust X,Y coordinate offsets for elements. "
|
|
"Values are relative to default positions; "
|
|
"negative moves left/up, positive right/down."),
|
|
"x-style-managed": True,
|
|
"properties": {},
|
|
"additionalProperties": False,
|
|
}
|
|
properties["layout"] = layout
|
|
if isinstance(order, list) and "layout" not in order:
|
|
order.append("layout")
|
|
|
|
layout_properties = layout.setdefault("properties", {})
|
|
layout_order = layout.get("x-propertyOrder")
|
|
for element_key, declaration in offset_elements:
|
|
if element_key in layout_properties:
|
|
continue # hand-written layout entry wins
|
|
title = declaration.get("title") or element_key.replace("_", " ").title()
|
|
layout_properties[element_key] = {
|
|
"type": "object",
|
|
"title": title,
|
|
"x-style-managed": True,
|
|
"properties": {
|
|
"x_offset": {
|
|
"type": "integer",
|
|
"title": "X Offset",
|
|
"description": "Horizontal offset in pixels (default: 0)",
|
|
"default": 0,
|
|
},
|
|
"y_offset": {
|
|
"type": "integer",
|
|
"title": "Y Offset",
|
|
"description": "Vertical offset in pixels (default: 0)",
|
|
"default": 0,
|
|
},
|
|
},
|
|
"additionalProperties": False,
|
|
}
|
|
if isinstance(layout_order, list) and element_key not in layout_order:
|
|
layout_order.append(element_key)
|
|
|
|
|
|
def load_font(font_name: str, size: int, *,
|
|
fonts_dir: str = DEFAULT_FONTS_DIR,
|
|
fallback_font: str = DEFAULT_FALLBACK_FONT) -> PILFont:
|
|
"""Load a font by name at a pixel size, never raising.
|
|
|
|
Resolution order:
|
|
1. alias -> filename (``FONT_ALIASES``)
|
|
2. ``ImageFont.truetype`` — handles .ttf/.otf, and .bdf too (FreeType
|
|
loads BDF strikes at their native size; a non-native size raises
|
|
"invalid pixel size" and falls through)
|
|
3. for .bdf: a pre-converted ``.pil`` sidecar via ``ImageFont.load``
|
|
4. ``fallback_font`` at the requested size
|
|
5. ``ImageFont.load_default()``
|
|
"""
|
|
font_name = resolve_font_name(font_name or "")
|
|
font_path = os.path.join(fonts_dir, font_name)
|
|
lower = font_name.lower()
|
|
|
|
if os.path.exists(font_path):
|
|
try:
|
|
return ImageFont.truetype(font_path, size)
|
|
except Exception as e:
|
|
logger.debug("truetype failed for %s@%s: %s", font_name, size, e)
|
|
if lower.endswith(".bdf"):
|
|
pil_path = font_path.rsplit(".", 1)[0] + ".pil"
|
|
if os.path.exists(pil_path):
|
|
try:
|
|
return ImageFont.load(pil_path)
|
|
except Exception as e:
|
|
logger.debug("PIL sidecar failed for %s: %s", pil_path, e)
|
|
logger.warning(
|
|
"BDF font %s could not be loaded at size %s (BDF fonts are "
|
|
"fixed-size; font_size must match the native size). Falling "
|
|
"back to %s.", font_name, size, fallback_font)
|
|
else:
|
|
logger.warning("Font file not found: %s, falling back to %s",
|
|
font_path, fallback_font)
|
|
|
|
fallback_path = os.path.join(fonts_dir, resolve_font_name(fallback_font))
|
|
try:
|
|
return ImageFont.truetype(fallback_path, size)
|
|
except Exception as e:
|
|
logger.warning("Fallback font %s failed (%s); using PIL default",
|
|
fallback_font, e)
|
|
return ImageFont.load_default()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ElementStyle:
|
|
"""Resolved style for one display element."""
|
|
font: PILFont
|
|
font_name: str
|
|
font_size: int
|
|
#: The user's color when they genuinely changed it, else the plugin's
|
|
#: classic color (which may be state-dependent — e.g. a score that turns
|
|
#: gold on a touchdown — so an untouched schema default must never
|
|
#: clobber it; the web form always posts the color inputs).
|
|
color: Optional[Tuple[int, int, int]]
|
|
#: Additive (dx, dy) translation from customization.layout offsets.
|
|
offset: Tuple[int, int]
|
|
#: True when the configured value genuinely differs from the schema
|
|
#: default (NOT merely present — saved configs always contain defaults).
|
|
user_forced_font: bool
|
|
user_forced_size: bool
|
|
user_forced_color: bool = False
|
|
|
|
@property
|
|
def user_forced(self) -> bool:
|
|
"""True when the user pinned this element's font or size; adaptive
|
|
layouts must use the font as-is instead of ladder-fitting. (Color is
|
|
deliberately excluded — it never affects sizing.)"""
|
|
return self.user_forced_font or self.user_forced_size
|
|
|
|
|
|
def _as_int(value: Any, default: int) -> int:
|
|
"""Int coercion tolerant of floats and numeric strings from configs."""
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return default
|
|
if isinstance(value, (int, float)):
|
|
return int(value)
|
|
try:
|
|
return int(float(value))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _as_color(value: Any) -> Optional[Tuple[int, int, int]]:
|
|
"""[r, g, b] list/tuple -> tuple; anything else -> None."""
|
|
if isinstance(value, (list, tuple)) and len(value) == 3:
|
|
try:
|
|
return tuple(max(0, min(255, int(c))) for c in value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return None
|
|
|
|
|
|
class ElementStyleResolver:
|
|
"""Resolves per-element fonts, colors and offsets from a plugin config.
|
|
|
|
``schema_defaults`` is the nested defaults dict extracted from the
|
|
plugin's config_schema.json (SchemaManager.extract_defaults_from_schema).
|
|
It is the reference for the user-override check: a configured value
|
|
equal to its schema default is treated as untouched, because the save
|
|
flow persists all defaults. When ``schema_defaults`` is empty (older
|
|
cores, unit tests), the check degrades to comparing against the
|
|
``classic_*`` values the caller supplies.
|
|
"""
|
|
|
|
def __init__(self, config: Optional[Dict[str, Any]],
|
|
schema_defaults: Optional[Dict[str, Any]] = None, *,
|
|
fonts_dir: str = DEFAULT_FONTS_DIR,
|
|
fallback_font: str = DEFAULT_FALLBACK_FONT):
|
|
self._config = config if isinstance(config, dict) else {}
|
|
self._defaults = schema_defaults if isinstance(schema_defaults, dict) else {}
|
|
self._fonts_dir = fonts_dir
|
|
self._fallback_font = fallback_font
|
|
self._cache: Dict[Any, ElementStyle] = {}
|
|
|
|
# -- internals ----------------------------------------------------
|
|
|
|
def _element_config(self, element_key: str) -> Dict[str, Any]:
|
|
cust = self._config.get("customization")
|
|
if not isinstance(cust, dict):
|
|
return {}
|
|
element = cust.get(element_key)
|
|
return element if isinstance(element, dict) else {}
|
|
|
|
def _element_defaults(self, element_key: str) -> Dict[str, Any]:
|
|
cust = self._defaults.get("customization")
|
|
if not isinstance(cust, dict):
|
|
return {}
|
|
element = cust.get(element_key)
|
|
return element if isinstance(element, dict) else {}
|
|
|
|
# -- public API ---------------------------------------------------
|
|
|
|
def style(self, element_key: str, *, classic_font: str, classic_size: int,
|
|
classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle:
|
|
"""Resolve the style for one element.
|
|
|
|
``classic_font``/``classic_size``/``classic_color`` are the plugin's
|
|
hardcoded defaults for this element — used when the config has no
|
|
value, and as the override reference when schema defaults are
|
|
unavailable.
|
|
"""
|
|
cache_key = (element_key, classic_font, classic_size, classic_color)
|
|
cached = self._cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
element_cfg = self._element_config(element_key)
|
|
element_defaults = self._element_defaults(element_key)
|
|
|
|
configured_font = element_cfg.get("font")
|
|
configured_size = element_cfg.get("font_size")
|
|
|
|
# Reference for "did the user change it": schema default when known,
|
|
# else the plugin's classic default.
|
|
reference_font = element_defaults.get("font", classic_font)
|
|
reference_size = _as_int(element_defaults.get("font_size"), classic_size)
|
|
|
|
user_forced_font = (configured_font is not None
|
|
and configured_font != reference_font)
|
|
user_forced_size = (configured_size is not None
|
|
and _as_int(configured_size, reference_size) != reference_size)
|
|
|
|
font_name = configured_font if configured_font is not None else classic_font
|
|
font_size = _as_int(configured_size, classic_size)
|
|
font = load_font(font_name, font_size, fonts_dir=self._fonts_dir,
|
|
fallback_font=self._fallback_font)
|
|
|
|
# Color follows the same provenance rule as fonts: the web form
|
|
# always posts the RGB inputs, so a saved config carries the schema
|
|
# default whether or not the user touched it — only a value that
|
|
# DIFFERS from the schema default is a real override. Otherwise keep
|
|
# classic_color, which may be state-dependent (semantic colors like
|
|
# a gold touchdown score) and must not be clobbered by a default.
|
|
configured_color = _as_color(element_cfg.get("text_color"))
|
|
default_color = _as_color(element_defaults.get("text_color"))
|
|
if configured_color is None:
|
|
user_forced_color = False
|
|
elif default_color is None:
|
|
# no schema default to compare against — presence is intent
|
|
user_forced_color = True
|
|
else:
|
|
user_forced_color = configured_color != default_color
|
|
color = configured_color if user_forced_color else classic_color
|
|
|
|
resolved = ElementStyle(
|
|
font=font, font_name=font_name, font_size=font_size,
|
|
color=color, offset=self.offset(element_key),
|
|
user_forced_font=user_forced_font, user_forced_size=user_forced_size,
|
|
user_forced_color=user_forced_color,
|
|
)
|
|
self._cache[cache_key] = resolved
|
|
return resolved
|
|
|
|
def offset_value(self, element_key: str, axis: str, default: int = 0) -> int:
|
|
"""One offset axis for an element (e.g. 'x_offset', 'away_x_offset').
|
|
|
|
Reads ``customization.layout.<element>`` first (the deployed sports
|
|
convention), falling back to ``customization.<element>`` for plugins
|
|
that keep offsets on the element itself.
|
|
"""
|
|
cust = self._config.get("customization")
|
|
if not isinstance(cust, dict):
|
|
return default
|
|
layout = cust.get("layout")
|
|
if isinstance(layout, dict):
|
|
element = layout.get(element_key)
|
|
if isinstance(element, dict) and axis in element:
|
|
return _as_int(element.get(axis), default)
|
|
element = cust.get(element_key)
|
|
if isinstance(element, dict) and axis in element:
|
|
return _as_int(element.get(axis), default)
|
|
return default
|
|
|
|
def offset(self, element_key: str) -> Tuple[int, int]:
|
|
"""(dx, dy) additive translation for an element; (0, 0) when unset."""
|
|
return (self.offset_value(element_key, "x_offset"),
|
|
self.offset_value(element_key, "y_offset"))
|
|
|
|
def is_for(self, config: Optional[Dict[str, Any]]) -> bool:
|
|
"""True when this resolver was built over exactly this config object.
|
|
|
|
Callers that cache a resolver (plugins whose config dict gets
|
|
REPLACED on config change) use this to decide when to rebuild —
|
|
preferred over reaching into the private ``_config``.
|
|
"""
|
|
return self._config is config
|
|
|
|
def clear_cache(self) -> None:
|
|
self._cache.clear()
|