Files
LEDMatrix/src/element_style.py
T
ChuckandClaude Opus 5.5 f6c0fe55d9 fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes (#654)
* fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes

- font_manager: a .zip font URL is served as its extracted font after a
  restart (the cached-file check returned the archive first); downloads
  use requests with a 30s timeout into a temp file + os.replace.
- api_helper / sync_manager: rate-limit and heartbeat/leader timeouts use
  time.monotonic(); last_request_time and the status file's ts stay
  wall-clock. set_on_new_cycle docstring no longer claims core uses it.
- logo_helper: the placeholder uses the same scaled box as a real logo.
- permission_utils: one _sudo_bash_candidates() helper (with the sudoers
  exact-argv rationale) shared by sudo_remove_directory, which now retries
  the next bash path on a sudo refusal, and install_requirements_file.
- dynamic_team_resolver: failed/empty fetch backs off 5 min; duplicate
  INFO log and contradictory docstring example fixed.
- element_style: scale default looked up through element aliases.
- background_data_service: cache-hit callback runs outside the lock.
- config_arrays: union-aware type check (["array","null"]); stale
  dotToNested() reference removed.
- auto_update_setup: non-dict auto_update reads as off; temp result file
  unlinked when the write fails.
- exceptions: constructors copy the caller's context dict.
- logging_config: StructuredFormatter json.dumps(default=str).
- error_aggregator: removed unused export_path/export_to_file/_auto_export.
- Docstrings: validate_file_upload max_size_mb, raise_on_errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sync): retry the status-file rename like the other atomic writers

On Windows os.replace can fail with "Access is denied" while a scanner
briefly holds the target open; config_manager_atomic._replace already
retries that (and re-raises at once on other platforms). The sync status
writer called os.replace directly, which made
test_concurrent_writers_each_use_their_own_temp_file flaky on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:40:16 -04:00

1555 lines
67 KiB
Python

"""
Shared per-element style resolution for plugins (the x-style-elements system).
Plugins expose user-customizable text styling — font, size, color, and x/y
pixel offsets per named element — through their ``config_schema.json``. Two
declaration forms exist in the plugin ecosystem:
- The compact ``x-style-elements`` map on the ``customization`` object
(of-the-day is the reference). ``expand_style_elements()`` turns it into
the full per-element property blocks the web-UI config form renders.
- The manual ``customization`` block: hand-written per-element objects with
``font`` / ``font_size`` / ``text_color`` defaults (the scoreboards,
ledmatrix-music). No expansion needed — the defaults are read as-is.
At render time a plugin builds an ``ElementStyleResolver`` from its config
and the schema-file defaults, then asks for each element's resolved style::
from src.element_style import ElementStyleResolver, defaults_from_schema_file
resolver = ElementStyleResolver(config, defaults_from_schema_file(schema_path))
title = resolver.style('title_text', classic_font='PressStart2P-Regular.ttf',
classic_size=8, classic_color=(255, 255, 255))
# title.font (PIL font / freetype.Face), title.color (RGB tuple),
# title.offset ((dx, dy)), title.user_forced, title.user_forced_color
The central subtlety is what "the user set it" means. The web UI's save flow
(``schema_manager.merge_with_defaults``) writes the FULL schema-default
object into ``config.json`` on every save, whether or not the user touched
the styling section — so a value merely being *present* in config is not an
override. A value only counts as user-forced when it genuinely differs from
the schema default for that element. When nothing is forced, ``style()``
returns exactly the ``classic_*`` values the caller passes (the plugin's
pre-customization styling), so an untouched config renders byte-identically
to the classic code path. Note the classic values and the schema defaults
may legitimately differ (e.g. football's status_text: schema declares 4x6,
the classic loader fell back to PressStart) — the schema default is the
override *reference*, the classic values are the *fallback*.
``style()`` never raises: any malformed config value degrades to the classic
style with a logged warning. Font faces are cached module-wide by
(resolved path, size), and font files resolve independently of the caller's
cwd (cwd ``assets/fonts/`` first for compatibility, then the core install
root derived from this module's own location).
"""
import copy
import json
import logging
import math
import os
import threading
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from PIL import ImageFont
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
from src.common.font_layout import load_truetype
logger = logging.getLogger(__name__)
# Core install root (the directory that contains src/ and assets/fonts/),
# derived from this file so fonts resolve regardless of the caller's cwd.
_CORE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_FONTS_SUBDIR = os.path.join('assets', 'fonts')
# Last-resort font when a requested file can't be found or loaded.
_FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
# (resolved absolute path, requested size) -> (font face, realised size).
# TTF only: a BDF ``freetype.Face`` must never be shared between threads
# (FreeType does not allow it, and ``load_char`` rewrites the face's glyph
# slot), and this cache is process-wide. BDF faces come from
# ``load_bdf_face`` every time, which already caches them per thread.
#
# Bounded LRU rather than the unbounded dict this started as: the display
# process runs for weeks, and every config save can introduce a new
# (font, size) pair. 256 is far above the working set -- a panel draws from a
# handful of faces -- while still having a ceiling. Matches the house style of
# every other hot cache (display_manager, font_manager, adaptive_layout).
_FONT_CACHE_MAX = 256
_font_cache: 'OrderedDict[Tuple[str, int], Tuple[Any, int]]' = OrderedDict()
# load_font is called from the display thread and from plugin update threads.
# A get() then move_to_end() pair on an unguarded OrderedDict raises KeyError
# when another thread evicts the key in between.
_font_cache_lock = threading.Lock()
def _cache_get(key: Tuple[str, int]) -> Optional[Tuple[Any, int]]:
"""The cached entry for ``key`` (marked most recently used), or None."""
with _font_cache_lock:
cached = _font_cache.get(key)
if cached is not None:
_font_cache.move_to_end(key)
return cached
def _cache_put(key: Tuple[str, int], value: Tuple[Any, int]) -> None:
"""Insert, evicting the least recently used entry past the bound."""
with _font_cache_lock:
_font_cache[key] = value
_font_cache.move_to_end(key)
while len(_font_cache) > _FONT_CACHE_MAX:
_font_cache.popitem(last=False)
# Config keys a style element block carries, in schema/UI order.
_STYLE_KEYS = ('font', 'font_size', 'text_color', 'visible', 'align')
# Title of every generated `layout` (x/y offset) group in the config form.
_LAYOUT_TITLE = 'Layout Offsets'
#: Bounds on a user-set ``customization.layout.<element>.scale``. They are the
#: Scale field's minimum and maximum in the generated schema, and every reader
#: (coerce_scale, element_scale, LogoHelper.load_logo) clamps to them, so the
#: web form and the renderer agree. Below 0.1 a logo is a dot; ten times a
#: panel-sized box is already far off the panel.
MIN_ELEMENT_SCALE = 0.1
MAX_ELEMENT_SCALE = 10.0
@dataclass(frozen=True)
class ElementStyle:
"""A fully resolved style for one named element."""
font: Any # PIL ImageFont or freetype.Face
color: Tuple[int, int, int] # resolved RGB
offset: Tuple[int, int] # user layout (x, y) offset, default (0, 0)
font_name: str # resolved font filename
font_size: int # resolved pixel size
user_forced: bool # font or size genuinely overridden
user_forced_color: bool # color genuinely overridden
# The three below default to "change nothing", so a caller that ignores
# them renders exactly as it did before they existed, and a caller that
# honours them sees a neutral value until the user actually asks for
# something. That is what keeps an untouched config byte-identical.
visible: bool = True # False hides the element entirely
align: Optional[str] = None # 'left'|'center'|'right'; None = caller's own
scale: float = 1.0 # size multiplier for images/logos
# ---------------------------------------------------------------------------
# Font loading (cwd-independent, cached)
# ---------------------------------------------------------------------------
def resolve_font_path(font_name: str) -> Optional[str]:
"""Locate a font file by name, independent of the caller's cwd.
Tries, in order: an absolute path as given; ``assets/fonts/<name>``
relative to the cwd (the classic loaders' behavior, kept first so a
process running from a different checkout keeps its own fonts); then
``assets/fonts/<name>`` under the core install root. Returns an
absolute path, or None when the file doesn't exist anywhere.
"""
if not font_name or not isinstance(font_name, str):
return None
if os.path.isabs(font_name):
return font_name if os.path.isfile(font_name) else None
# A relative name must be a bare filename. font_name comes from plugin
# config, which the web UI writes; a value like "../../config/config.json"
# would otherwise escape assets/fonts/ once joined and let a config probe
# arbitrary paths for existence. os.path.basename collapses any such value
# to its last component, so a name that isn't already bare is rejected.
if os.path.basename(font_name) != font_name:
return None
candidates = (
os.path.join(os.getcwd(), _FONTS_SUBDIR, font_name),
os.path.join(_CORE_ROOT, _FONTS_SUBDIR, font_name),
)
for candidate in candidates:
if os.path.isfile(candidate):
return os.path.abspath(candidate)
return None
def native_bdf_size(font_name: str) -> Optional[int]:
"""The one pixel size a BDF font can render at, or None.
None means "not a BDF, not found, or unreadable" — i.e. the size is a
free choice. The web UI uses this to lock the size field for a bitmap
font instead of offering a number that cannot take effect.
"""
path = resolve_font_path(font_name)
if path is None or not path.lower().endswith('.bdf'):
return None
return _read_bdf_native_size(path)
def _read_bdf_native_size(path: str) -> Optional[int]:
"""A BDF file's own pixel size (the web UI's fonts API imports this name).
See :func:`src.common.bdf_font.read_bdf_native_size`: it prefers
PIXEL_SIZE over the SIZE line's point-size, which differ on the several
bundled fonts defined at 75dpi.
"""
return read_bdf_native_size(path)
def _load_bdf(path: str, size: int) -> Tuple[Any, int]:
"""A ``freetype.Face`` for a BDF file at the closest size it can do.
BDF fonts are fixed-size bitmap strikes: FreeType accepts only the pixel
size baked into the file, and 32 of the 35 shipped fonts are BDF, so a
size the user picked in the web UI usually is not a valid strike. The
shared loader retries at the native size; without that, 5x7.bdf at size
10 used to fall through to *PressStart2P*, a different typeface.
"""
return load_bdf_face(path, size)
def load_font(font_name: str, size: int) -> Any:
"""Load a font by filename at a pixel size, with caching and fallback.
``.bdf`` files load as ``freetype.Face`` (matching FontManager), other
files through the pinned ``load_truetype``. A BDF asked for a size it
has no strike for falls back to its own native size (see
:func:`_load_bdf`), not to a different font. A missing or unloadable
font degrades to ``PressStart2P-Regular.ttf`` at the requested size,
then to PIL's built-in default — this function never raises.
"""
return _load_font_sized(font_name, size)[0]
def _load_font_sized(font_name: str, size: int) -> Tuple[Any, int]:
"""``load_font`` plus the pixel size actually realised.
The two differ only for a BDF snapped to its native strike. Callers
that lay out by size (line heights, ladders) need the realised value,
or they reserve space for a size nothing was drawn at.
"""
try:
size = max(1, int(size))
except (TypeError, ValueError):
size = 8
path = resolve_font_path(font_name)
if path is None:
logger.warning("Font file not found: %s, using fallback", font_name)
return _load_fallback_font(size)
is_bdf = path.lower().endswith('.bdf')
cache_key = (path, size)
if not is_bdf:
cached = _cache_get(cache_key)
if cached is not None:
return cached
try:
if is_bdf:
font, effective = _load_bdf(path, size)
else:
font, effective = load_truetype(path, size), size
except Exception as e:
logger.warning("Error loading font %s at %spx: %s, using fallback",
path, size, e)
return _load_fallback_font(size)
# Not BDF: load_bdf_face caches those per thread (see _font_cache).
if not is_bdf:
_cache_put(cache_key, (font, effective))
return font, effective
def _load_fallback_font(size: int) -> Tuple[Any, int]:
"""PressStart2P at the requested size, else PIL's built-in default."""
path = resolve_font_path(_FALLBACK_FONT_NAME)
if path is not None:
cache_key = (path, size)
cached = _cache_get(cache_key)
if cached is not None:
return cached
try:
entry = (load_truetype(path, size), size)
_cache_put(cache_key, entry)
return entry
except Exception as e:
logger.error("Error loading fallback font: %s", e)
return ImageFont.load_default(), size
# ---------------------------------------------------------------------------
# Schema parsing
# ---------------------------------------------------------------------------
def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Expand a ``customization.x-style-elements`` declaration into the full
per-element property blocks the web-UI config form renders.
Each declared element becomes an object with ``font`` / ``font_size`` /
``text_color`` properties (only the sub-fields the declaration carries),
tagged ``x-style-managed: true``; elements declaring ``offsets: true``
additionally get an entry under ``customization.layout`` with
``x_offset`` / ``y_offset`` integers defaulting to 0. Hand-written
element blocks with the same key are left untouched.
Returns the schema unchanged (same object) when there is nothing to
expand; otherwise returns an expanded deep copy. Never raises.
"""
try:
customization = schema.get('properties', {}).get('customization')
if not isinstance(customization, dict):
return schema
declaration = customization.get('x-style-elements')
if not isinstance(declaration, dict) or not declaration:
# No compact declaration: the plugin may still have hand-written
# its style blocks longhand, which nineteen of them do.
return _adopt_handwritten_block(schema, customization)
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] = {}
for element_key, spec in declaration.items():
if not isinstance(spec, dict):
continue
if element_key not in props:
props[element_key] = _element_block_from_spec(element_key, spec)
if spec.get('offsets'):
layout_props[element_key] = _offset_block_from_spec(
element_key, spec)
if layout_props:
layout = props.setdefault('layout', {
'type': 'object',
'title': _LAYOUT_TITLE,
'description': 'Pixel offsets applied to each element '
'(positive x moves right, positive y moves down)',
'x-advanced': True,
'properties': {},
'additionalProperties': False,
})
layout.setdefault('properties', {})
for element_key, block in layout_props.items():
layout['properties'].setdefault(element_key, block)
modes = customization.get('x-style-modes')
if isinstance(modes, list) and modes:
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)
return schema
def _element_block_from_spec(element_key: str,
spec: Dict[str, Any]) -> Dict[str, Any]:
"""Build one expanded per-element schema block from its declaration."""
properties: Dict[str, Any] = {}
order = []
size_spec = spec.get('size') if isinstance(spec.get('size'), dict) else None
font_spec = spec.get('font')
if isinstance(font_spec, dict):
font_prop: Dict[str, Any] = {
'type': 'string',
'title': 'Font Family',
'x-advanced': True,
# The core's font picker; without the hint the form renders a
# bare text box the user has to type a filename into.
'x-widget': 'font-selector',
}
# A bitmap font ignores font_size and renders at its own baked-in
# size, so the size ceiling has to be enforced when picking the
# font, not when setting the size.
max_size = size_spec.get('max') if size_spec else None
if isinstance(max_size, (int, float)):
font_prop['x-options'] = {'maxFixedSize': max_size}
if 'default' in font_spec:
font_prop['default'] = font_spec['default']
if isinstance(font_spec.get('enum'), list):
font_prop['enum'] = list(font_spec['enum'])
properties['font'] = font_prop
order.append('font')
if isinstance(size_spec, dict):
size_prop: Dict[str, Any] = {
'type': 'integer',
'title': 'Font Size',
'description': 'Font size in pixels',
'x-advanced': True,
}
if 'default' in size_spec:
size_prop['default'] = size_spec['default']
if 'min' in size_spec:
size_prop['minimum'] = size_spec['min']
if 'max' in size_spec:
size_prop['maximum'] = size_spec['max']
properties['font_size'] = size_prop
order.append('font_size')
color_spec = spec.get('color')
if isinstance(color_spec, dict):
color_prop: Dict[str, Any] = {
'type': 'array',
'title': 'Text Color',
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
'minItems': 3,
'maxItems': 3,
'x-widget': 'color-picker',
}
if 'default' in color_spec:
color_prop['default'] = list(color_spec['default'])
properties['text_color'] = color_prop
order.append('text_color')
# ``"visible": true`` is accepted as shorthand for
# ``{"default": true}`` -- the common case is a plugin saying only that
# the element can be hidden.
visible_spec = spec.get('visible')
if visible_spec is True or isinstance(visible_spec, dict):
default = True
if isinstance(visible_spec, dict):
default = bool(visible_spec.get('default', True))
properties['visible'] = {
'type': 'boolean',
'title': 'Show',
'default': default,
'x-widget': 'toggle-switch',
}
order.append('visible')
align_spec = spec.get('align')
if align_spec is True or isinstance(align_spec, dict):
align_prop: Dict[str, Any] = {
'type': 'string',
'title': 'Align',
'enum': list(_ALIGNMENTS),
'x-advanced': True,
}
if isinstance(align_spec, dict) and 'default' in align_spec:
align_prop['default'] = align_spec['default']
properties['align'] = align_prop
order.append('align')
return {
'type': 'object',
'title': spec.get('title', element_key),
'x-style-managed': True,
'x-propertyOrder': order,
'additionalProperties': False,
'properties': properties,
}
def _offset_block_from_spec(element_key: str,
spec: Dict[str, Any]) -> Dict[str, Any]:
"""Build one layout.<element> block: x/y offsets, and scale if declared."""
axis = {
'type': 'integer',
'default': 0,
'x-advanced': True,
}
properties: Dict[str, Any] = {
'x_offset': dict(axis, title='X Offset'),
'y_offset': dict(axis, title='Y Offset'),
}
# scale sits here rather than in the element block because it is
# geometry, like the offsets: a logo has a scale and no font, and the
# renderer applies both when it places the thing.
scale_spec = spec.get('scale')
if scale_spec is True or isinstance(scale_spec, dict):
scale_prop: Dict[str, Any] = {
'type': 'number',
'title': 'Scale',
'description': 'Size multiplier; 1 is the shipped size.',
'default': 1.0,
'minimum': MIN_ELEMENT_SCALE,
'maximum': MAX_ELEMENT_SCALE,
'x-advanced': True,
}
if isinstance(scale_spec, dict):
for key, prop_key in (('default', 'default'),
('min', 'minimum'), ('max', 'maximum')):
if key in scale_spec:
scale_prop[prop_key] = scale_spec[key]
properties['scale'] = scale_prop
return {
'type': 'object',
'title': spec.get('title', element_key),
'x-style-managed': True,
'additionalProperties': False,
'properties': properties,
}
def _nullable(prop: Dict[str, Any]) -> Dict[str, Any]:
"""The same property, retyped as "this or unset".
A mode field defaults to null, meaning inherit the base element. The
default has to be null rather than the base value: the save flow writes
schema defaults into config.json wholesale, so a concrete default here
would turn every mode into a copy of the base the moment a user pressed
Save, and the base would stop reaching them.
"""
out = dict(prop)
declared = out.get('type', 'string')
types = declared if isinstance(declared, list) else [declared]
if 'null' not in types:
types = list(types) + ['null']
out['type'] = types
out['default'] = None
# An enum constrains the value independently of the type, so widening
# the type is not enough: null has to be an allowed choice too, or the
# default this function just set fails its own schema. That is not a
# corner case -- the save flow writes the default into config, so a
# plugin declaring an enum field with modes could not save at all.
if isinstance(out.get('enum'), list) and None not in out['enum']:
out['enum'] = list(out['enum']) + [None]
return out
def _mode_element_block(element_key: str, spec: Dict[str, Any]) -> Dict[str, Any]:
"""One element's override block for one mode: every field nullable."""
base = _element_block_from_spec(element_key, spec)
base['properties'] = {k: _nullable(v)
for k, v in base.get('properties', {}).items()}
base['description'] = ('Leave blank to use the settings above for this '
'mode.')
return base
def _mode_offset_block(element_key: str, spec: Dict[str, Any]) -> Dict[str, Any]:
"""One element's offset overrides for one mode: both axes nullable."""
base = _offset_block_from_spec(element_key, spec)
base['properties'] = {k: _nullable(v)
for k, v in base.get('properties', {}).items()}
return base
def _modes_block(declaration: Dict[str, Any],
modes: Any) -> Dict[str, Any]:
"""``customization.modes`` — one override group per declared mode."""
mode_props: Dict[str, Any] = {}
for mode in modes:
if not isinstance(mode, str) or not mode:
continue
element_props: Dict[str, Any] = {}
layout_props: Dict[str, Any] = {}
for element_key, spec in declaration.items():
if not isinstance(spec, dict):
continue
element_props[element_key] = _mode_element_block(element_key, spec)
if spec.get('offsets'):
layout_props[element_key] = _mode_offset_block(element_key, spec)
if layout_props:
element_props['layout'] = {
'type': 'object',
'title': _LAYOUT_TITLE,
'x-advanced': True,
'additionalProperties': False,
'properties': layout_props,
}
mode_props[mode] = {
'type': 'object',
'title': mode.replace('_', ' ').title(),
'x-style-managed': True,
'additionalProperties': False,
'properties': element_props,
}
return {
'type': 'object',
'title': 'Per-Mode Overrides',
'description': 'Override the settings above for one display mode. '
'Anything left blank follows the settings above.',
'x-advanced': True,
'additionalProperties': False,
'properties': mode_props,
}
#: The sub-fields that make a customization sub-object a style element.
#: Checked against every published schema: 68 blocks across 19 plugins match
#: exactly, and nothing else does -- favorite_result_colors, baseball's
#: bases/outs/player_card, jellyfin's progress_bar and the stocks blocks all
#: carry other fields and are correctly left alone.
_STYLE_BLOCK_FIELDS = frozenset(_STYLE_KEYS)
def _looks_like_style_block(block: Any) -> bool:
"""Whether a hand-written customization sub-object is a style element.
Deliberately strict: every field must be one this system understands.
A looser rule ("has at least one style field") would sweep in blocks
like baseball's ``count``, which happens to carry a text_color next to
geometry that means nothing here.
"""
if not isinstance(block, dict):
return False
props = block.get('properties')
if not isinstance(props, dict) or not props:
return False
return set(props) <= _STYLE_BLOCK_FIELDS
def _detect_style_blocks(customization: Dict[str, Any]) -> list:
"""Element keys in a hand-written customization block, in declared order."""
props = customization.get('properties')
if not isinstance(props, dict):
return []
return [key for key, block in props.items()
if key not in ('layout', 'modes') and _looks_like_style_block(block)]
def _upgrade_font_property(block: Dict[str, Any]) -> None:
"""Point a hand-written font field at the font picker, in place.
These fields ship a hardcoded ``enum`` -- football lists five of the
thirty-five installed fonts -- which is why a font a user uploads can
never appear in one. The enum is replaced rather than extended: it is
not a curated safe set (it omits some twenty other faces that fit
just as well), it is the fonts that happened to exist when it was
written.
The size ceiling the block already declares is carried across as
``maxFixedSize``, because a bitmap font ignores font_size and renders
at its own baked-in size -- so widening the list without that would
offer faces that overflow the panel no matter what size is set.
"""
props = block.get('properties')
if not isinstance(props, dict):
return
font_prop = props.get('font')
if not isinstance(font_prop, dict):
return
font_prop.pop('enum', None)
font_prop['x-widget'] = 'font-selector'
size_prop = props.get('font_size')
maximum = size_prop.get('maximum') if isinstance(size_prop, dict) else None
if isinstance(maximum, (int, float)):
options = font_prop.setdefault('x-options', {})
if isinstance(options, dict):
options.setdefault('maxFixedSize', maximum)
def _nullable_block(block: Dict[str, Any], title: Optional[str] = None,
description: Optional[str] = None) -> Dict[str, Any]:
"""A copy of an element block with every field optional.
The per-mode counterpart of a hand-written block: same fields, all
nullable and defaulting to null, which is this system's "inherit".
"""
out = copy.deepcopy(block)
out['properties'] = {k: _nullable(v)
for k, v in (out.get('properties') or {}).items()}
out['x-style-managed'] = True
if title:
out['title'] = title
if description is not None:
out['description'] = description
return out
def _modes_block_from_properties(props: Dict[str, Any], element_keys: list,
modes: Any) -> Dict[str, Any]:
"""``customization.modes`` built from already-expanded element blocks.
The compact declaration has ``_modes_block``; this is the same thing for
a plugin that hand-wrote its blocks, so both forms get per-mode overrides
from one declaration line.
"""
layout_source = (props.get('layout') or {}).get('properties') or {}
mode_props: Dict[str, Any] = {}
for mode in modes:
if not isinstance(mode, str) or not mode:
continue
element_props: Dict[str, Any] = {}
layout_props: Dict[str, Any] = {}
for key in element_keys:
block = props.get(key)
if isinstance(block, dict):
element_props[key] = _nullable_block(
block,
description='Leave blank to use the settings above for '
'this mode.')
# Every layout element, not just those with a style block. The two
# namespaces do not line up in a hand-written schema -- football
# styles 'score_text' but positions 'score', and positions logos,
# timeouts and possession that have no style block at all. Keying
# this off the style elements would have given six of its eleven
# positionable things no per-mode offset.
for key, layout_block in layout_source.items():
if isinstance(layout_block, dict):
layout_props[key] = _nullable_block(layout_block)
if layout_props:
element_props['layout'] = {
'type': 'object',
'title': _LAYOUT_TITLE,
'x-advanced': True,
'additionalProperties': False,
'properties': layout_props,
}
mode_props[mode] = {
'type': 'object',
'title': mode.replace('_', ' ').title(),
'x-style-managed': True,
'additionalProperties': False,
'properties': element_props,
}
return {
'type': 'object',
'title': 'Per-Mode Overrides',
'description': 'Override the settings above for one display mode. '
'Anything left blank follows the settings above.',
'x-advanced': True,
'additionalProperties': False,
'properties': mode_props,
}
def _adopt_handwritten_block(schema: Dict[str, Any],
customization: Dict[str, Any]) -> Dict[str, Any]:
"""Give a hand-written customization block the same treatment as a
declared one, without the plugin rewriting its schema.
Nineteen plugins spell their style elements out longhand -- football's
block is 701 lines for seven elements -- and predate every part of this
system. Recognising that shape lets them pick up the row-per-element
editor and the real font picker on a core update, with no plugin
release. What they do not get for free is per-mode overrides and the
visible/align/scale fields, because core cannot invent a plugin's list
of display modes: adding ``x-style-modes`` is the one line that unlocks
the rest.
"""
element_keys = _detect_style_blocks(customization)
if not element_keys:
return schema
expanded = copy.deepcopy(schema)
customization = expanded['properties']['customization']
customization.setdefault('x-widget', 'style-editor')
props = customization['properties']
layout_block = props.get('layout')
layout_fields = (layout_block.get('properties')
if isinstance(layout_block, dict) else None)
layout_fields = layout_fields if isinstance(layout_fields, dict) else {}
for key in element_keys:
_upgrade_font_property(props[key])
# Marked like a declared element, so consumers can tell the style
# blocks from whatever else the plugin keeps under customization.
# Football's block also holds favorite_result_colors, which is a
# feature with its own fields -- without this the editor treats it
# as an element and every row grows an "enabled"/"win color" column.
props[key]['x-style-managed'] = True
# Where this element's offsets live, resolved through the same alias
# map the renderer reads them with. The two blocks were never named
# alike -- football styles score_text but positions score -- and the
# editor matched them by exact name, so it drew one offset in eleven
# and the rest had no control anywhere. Recording the answer here
# keeps the alias rules in one place instead of a JavaScript copy.
# Only an object-shaped entry (x_offset/y_offset/...) can hold an
# element's offsets. A leaf directly under layout -- a show_logo
# toggle -- is its own control, so it is never claimed by a row and
# always gets a position row of its own.
layout_key = next((name for name in alias_keys(key)
if isinstance(layout_fields.get(name), dict)
and isinstance(layout_fields[name].get('properties'),
dict)), None)
if layout_key is not None:
props[key]['x-layout-key'] = layout_key
if layout_fields:
# Positions are listed in the order the plugin declared them. Flask's
# JSON provider sorts keys, which would put the logos after the date.
layout_block.setdefault('x-propertyOrder', list(layout_fields))
modes = customization.get('x-style-modes')
if isinstance(modes, list) and modes:
props.setdefault('modes',
_modes_block_from_properties(props, element_keys,
modes))
mode_blocks = props['modes'].get('properties') if isinstance(
props.get('modes'), dict) else None
for mode_block in (mode_blocks or {}).values():
mode_layout = ((mode_block or {}).get('properties') or {}).get('layout')
if (isinstance(mode_layout, dict)
and isinstance(mode_layout.get('properties'), dict)):
mode_layout.setdefault('x-propertyOrder',
list(mode_layout['properties']))
# Stated explicitly because the config form serialises the schema with
# Flask's JSON provider, which sorts keys -- without this the elements
# reach the browser alphabetised.
order = list(element_keys)
order += [k for k in props if k not in order]
customization.setdefault('x-propertyOrder', order)
return expanded
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Extract per-element style defaults from a config schema dict.
Understands both declaration forms: the compact ``x-style-elements``
map, and hand-written per-element blocks under
``customization.properties`` (their ``font`` / ``font_size`` /
``text_color`` property defaults). Returns a config-shaped dict::
{"customization": {"<element>": {"font": ..., "font_size": ...,
"text_color": [...]}, ...}}
Elements with no declared defaults are omitted. Never raises.
"""
elements: Dict[str, Dict[str, Any]] = {}
# Layout defaults live alongside the elements under the reserved
# 'layout' key, mirroring the config shape, so one dict carries both.
layout: Dict[str, Dict[str, Any]] = {}
try:
customization = schema.get('properties', {}).get('customization')
if not isinstance(customization, dict):
return {'customization': elements}
declaration = customization.get('x-style-elements')
if isinstance(declaration, dict):
for element_key, spec in declaration.items():
if not isinstance(spec, dict):
continue
defaults: Dict[str, Any] = {}
font_spec = spec.get('font')
if isinstance(font_spec, dict) and 'default' in font_spec:
defaults['font'] = font_spec['default']
size_spec = spec.get('size')
if isinstance(size_spec, dict) and 'default' in size_spec:
defaults['font_size'] = size_spec['default']
color_spec = spec.get('color')
if isinstance(color_spec, dict) and 'default' in color_spec:
defaults['text_color'] = list(color_spec['default'])
visible_spec = spec.get('visible')
if visible_spec is True:
defaults['visible'] = True
elif isinstance(visible_spec, dict) and 'default' in visible_spec:
defaults['visible'] = bool(visible_spec['default'])
align_spec = spec.get('align')
if isinstance(align_spec, dict) and 'default' in align_spec:
defaults['align'] = align_spec['default']
scale_spec = spec.get('scale')
if isinstance(scale_spec, dict) and 'default' in scale_spec:
layout.setdefault(element_key, {})['scale'] = scale_spec['default']
elif scale_spec is True:
layout.setdefault(element_key, {})['scale'] = 1.0
if defaults:
elements[element_key] = defaults
properties = customization.get('properties')
if isinstance(properties, dict):
layout_block = properties.get('layout')
if isinstance(layout_block, dict):
for element_key, block in (
layout_block.get('properties') or {}).items():
if not isinstance(block, dict):
continue
scale_prop = (block.get('properties') or {}).get('scale')
if isinstance(scale_prop, dict) and 'default' in scale_prop:
layout.setdefault(element_key, {})['scale'] = scale_prop['default']
for element_key, block in properties.items():
if element_key in ('layout', 'modes') or element_key in elements:
continue
if not isinstance(block, dict):
continue
block_props = block.get('properties')
if not isinstance(block_props, dict):
continue
defaults = {}
for style_key in _STYLE_KEYS:
prop = block_props.get(style_key)
if isinstance(prop, dict) and 'default' in prop:
defaults[style_key] = prop['default']
if defaults:
elements[element_key] = defaults
except Exception as e:
logger.warning("Error extracting style defaults from schema: %s", e)
if layout:
elements['layout'] = layout
return {'customization': elements}
def defaults_from_schema_file(schema_path: Union[str, os.PathLike]) -> Dict[str, Any]:
"""``defaults_from_schema`` for a schema file on disk. A missing or
malformed file yields empty defaults (with a logged warning) — every
configured value then counts as a user override, which is the safe
degradation. Never raises."""
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
if not isinstance(schema, dict):
raise ValueError("schema is not a JSON object")
except Exception as e:
logger.warning("Could not read style defaults from %s: %s",
schema_path, e)
return {'customization': {}}
return defaults_from_schema(schema)
# ---------------------------------------------------------------------------
# Resolver
# ---------------------------------------------------------------------------
def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
"""An (r, g, b) tuple of ints in 0..255, or None for anything else.
``"#RRGGBB"`` is accepted as well as ``[r, g, b]``: the scoreboards'
own colour readers have always taken both, and this is the function
they now share.
"""
if isinstance(value, str):
text = value.strip()
if len(text) == 7 and text.startswith('#'):
try:
return (int(text[1:3], 16), int(text[3:5], 16),
int(text[5:7], 16))
except ValueError:
return None
return None
if isinstance(value, (list, tuple)) and len(value) == 3:
try:
# Clamped, not rejected. The readers this replaced clamped
# (sports_card.coerce_rgb), and the eight scoreboards' own tests
# pin it: a configured [999, -5, 20] is a typo'd bright red, and
# answering "unusable, take the default" turned it white instead.
rgb = tuple(max(0, min(255, int(c))) for c in value)
except (TypeError, ValueError):
return None
return rgb # type: ignore[return-value]
return None
#: Element names that drifted between plugins, beyond what the ``_text``
#: suffix rule below covers. Counted across the published schemas: the
#: layout block spells it ``records`` in seven plugins and ``record`` in
#: two, ``status_text`` in seven and ``status`` in two.
_ELEMENT_ALIASES: Dict[str, Tuple[str, ...]] = {
'records': ('record',),
'record': ('records',),
'rank_text': ('ranking', 'rank'),
'ranking': ('rank_text', 'rank'),
'team_name': ('team',),
'team': ('team_name',),
}
def alias_keys(element_key: str) -> Tuple[str, ...]:
"""The names one element may be stored under, exact match first.
Two conventions collided as the scoreboards grew. The style block names
elements with a ``_text`` suffix (``score_text``, ``status_text``) while
the layout block mostly uses the bare noun (``score``, ``date``,
``odds``) -- except ``status_text``, which kept the suffix in seven
plugins and lost it in two. Plugins also disagree on ``records`` vs
``record``.
Rather than make every plugin rename its config keys -- which would
orphan whatever offsets its users had already dialled in -- a lookup
tries the exact name first and then the spellings that mean the same
thing. Exact-first is what keeps this from changing any behaviour for a
config that already matches.
This also covers the compact declaration form, which uses one key for
both blocks: a plugin moving to it can still find offsets its users
saved under the old bare-noun layout key.
"""
if not isinstance(element_key, str) or not element_key:
return ()
explicit = _ELEMENT_ALIASES.get(element_key)
if explicit:
# An explicit entry replaces the suffix rule rather than adding to
# it, so 'records' does not also generate 'records_text'.
return (element_key,) + tuple(a for a in explicit if a != element_key)
if element_key.endswith('_text'):
stem = element_key[:-len('_text')]
return (element_key, stem) if stem else (element_key,)
return (element_key, element_key + '_text')
def _lookup_element(block: Any, element_key: str) -> Dict[str, Any]:
"""``block[element]`` under any of its names, or {}."""
if not isinstance(block, dict):
return {}
for key in alias_keys(element_key):
value = block.get(key)
if isinstance(value, dict):
return value
return {}
def _coerce_bool(value: Any, default: bool) -> bool:
"""A real bool, or ``default``. Accepts the strings a form may post."""
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ('true', 'yes', 'on', '1'):
return True
if lowered in ('false', 'no', 'off', '0'):
return False
return default
_ALIGNMENTS = ('left', 'center', 'right')
def _coerce_align(value: Any) -> Optional[str]:
"""One of left/center/right, or None for anything else.
None means "no preference", which is what an unset value resolves to --
the caller keeps whatever alignment it already did.
"""
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in _ALIGNMENTS:
return lowered
if lowered in ('centre', 'middle'): # the spelling users try
return 'center'
return None
def coerce_scale(value: Any, default: float = 1.0) -> float:
"""A usable size multiplier: ``value`` clamped to
[MIN_ELEMENT_SCALE, MAX_ELEMENT_SCALE], or ``default``.
``default`` is returned for anything that is not a finite positive number
(None, a bool, a string, 0, a negative, NaN, infinity): those are typos,
and a typo should cost the shipped size, not a blank or inverted image or
a crash inside PIL. A positive number outside the range is a real request
for "smaller" or "bigger", so it is clamped rather than ignored.
"""
if isinstance(value, bool) or value is None:
return default
try:
scale = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(scale) or scale <= 0:
return default
return min(max(scale, MIN_ELEMENT_SCALE), MAX_ELEMENT_SCALE)
def _coerce_offset(value: Any, default: int, element_key: str,
axis: str) -> int:
"""A pixel offset as an int; anything nonsensical is ``default``.
A bool degrades rather than counting as 1/0 -- the more correct reading
of a pixel offset, and what the shared resolver has always done
relative to the classic inline read. Non-finite floats degrade too:
the scroll-card reader guarded against those explicitly and this is now
the one implementation.
"""
if isinstance(value, bool):
return int(default)
if isinstance(value, (int, float)):
if isinstance(value, float) and not math.isfinite(value):
return int(default)
return int(value)
if isinstance(value, str):
try:
parsed = float(value)
except (TypeError, ValueError):
logger.warning("Invalid layout offset for %s.%s: %r, using %s",
element_key, axis, value, default)
return int(default)
if not math.isfinite(parsed):
return int(default)
return int(parsed)
return int(default)
def layout_offset(config: Any, element_key: str, axis: str,
default: int = 0, mode: Optional[str] = None) -> int:
"""One ``customization.layout.<element>.<axis>`` value, as an int.
The stateless form of :meth:`ElementStyleResolver.offset_value`, for the
scoreboard helpers that are handed a config rather than holding one.
Both go through here, so the alias handling and the per-mode lookup
cannot drift between them -- there were three separate readers of this
block before, and the scroll-card one had already been found ignoring
offsets the schema advertised.
"""
try:
block = config.get('customization') if isinstance(config, dict) else None
block = block if isinstance(block, dict) else {}
base = _lookup_element(block.get('layout'), element_key).get(axis)
base_value = (int(default) if base is None
else _coerce_offset(base, default, element_key, axis))
if mode:
modes = block.get('modes')
mode_block = modes.get(mode) if isinstance(modes, dict) else None
if isinstance(mode_block, dict):
override = _lookup_element(mode_block.get('layout'),
element_key).get(axis)
if override is not None:
return _coerce_offset(override, base_value,
element_key, axis)
return base_value
except Exception as e:
logger.warning("Error reading layout offset %s.%s: %s",
element_key, axis, e)
try:
return int(default)
except (TypeError, ValueError):
return 0
def element_color(config: Any, element_key: str,
default: Tuple[int, int, int] = (255, 255, 255),
mode: Optional[str] = None) -> Tuple[int, int, int]:
"""``customization.<element>.text_color``, or ``default``.
The stateless colour lookup the scoreboards share. Unlike
:meth:`ElementStyleResolver.style` this does not compare against a
schema default -- the callers have no schema to hand -- so any
configured colour counts, which is what their own readers did.
"""
try:
block = config.get('customization') if isinstance(config, dict) else None
block = block if isinstance(block, dict) else {}
if mode:
modes = block.get('modes')
mode_block = modes.get(mode) if isinstance(modes, dict) else None
if isinstance(mode_block, dict):
override = _normalize_color(
_lookup_element(mode_block, element_key).get('text_color'))
if override is not None:
return override
value = _normalize_color(
_lookup_element(block, element_key).get('text_color'))
return value if value is not None else default
except Exception as e:
logger.warning("Error reading colour for %s: %s", element_key, e)
return default
def _element_field(config: Any, element_key: str, field: str,
mode: Optional[str] = None, in_layout: bool = False):
"""Raw ``customization[.layout].<element>.<field>``, mode first.
The shared body behind the stateless visible/align/scale readers. Returns
None for "not configured", which every caller turns into its own default --
None must not collapse into a value here, because for a mode it is the
inherit sentinel.
"""
block = config.get('customization') if isinstance(config, dict) else None
block = block if isinstance(block, dict) else {}
def _read(source):
if not isinstance(source, dict):
return None
holder = source.get('layout') if in_layout else source
return _lookup_element(holder, element_key).get(field)
if mode:
modes = block.get('modes')
mode_block = modes.get(mode) if isinstance(modes, dict) else None
override = _read(mode_block)
if override is not None:
return override
return _read(block)
def element_visible(config: Any, element_key: str, default: bool = True,
mode: Optional[str] = None) -> bool:
"""``customization.<element>.visible``, or *default*.
The stateless form of the flag :meth:`ElementStyleResolver.style` already
resolves, for the scoreboard draw paths that hold a config rather than a
resolver.
"""
try:
value = _element_field(config, element_key, 'visible', mode)
return default if value is None else _coerce_bool(value, default)
except Exception as e:
logger.warning("Error reading visibility for %s: %s", element_key, e)
return default
def element_align(config: Any, element_key: str,
default: Optional[str] = None,
mode: Optional[str] = None) -> Optional[str]:
"""``customization.<element>.align`` ('left'/'center'/'right'), or *default*."""
try:
value = _element_field(config, element_key, 'align', mode)
if value is None:
return default
# _coerce_align answers None for anything that is not an alignment;
# that is "no preference", which means the caller's default.
coerced = _coerce_align(value)
return default if coerced is None else coerced
except Exception as e:
logger.warning("Error reading alignment for %s: %s", element_key, e)
return default
def element_scale(config: Any, element_key: str, default: float = 1.0,
mode: Optional[str] = None) -> float:
"""``customization.layout.<element>.scale``, or *default*.
Scale sits in the layout block beside the offsets, because it positions and
sizes rather than styles -- a logo has no font or colour but is very much
something users want smaller.
"""
try:
value = _element_field(config, element_key, 'scale', mode,
in_layout=True)
return default if value is None else coerce_scale(value, default)
except Exception as e:
logger.warning("Error reading scale for %s: %s", element_key, e)
return default
class ElementStyleResolver:
"""Resolves per-element user styling against schema defaults.
Built from a plugin's live config dict and the defaults extracted from
its own ``config_schema.json`` (``defaults_from_schema_file``). The
config dict is held by reference as ``_config`` — consumers compare
identity (``resolver._config is not self.config``) to decide when a
resolver must be rebuilt after ``on_config_change`` swaps the dict.
A configured font/size/color counts as user-forced only when it differs
from the schema default (see module docstring); otherwise ``style()``
returns the caller's classic values verbatim, keeping untouched configs
byte-identical to pre-customization rendering.
**Modes.** A plugin that displays the same element in more than one
situation — a scoreboard's live / upcoming / recent cards, weather's
current / hourly / daily screens — can let the user style each one
separately under ``customization.modes.<mode>``. The mode is normally
bound once at construction rather than passed per call, because the
natural owner already knows it: SportsUpcoming and SportsRecent are
distinct instances with distinct ``SKIN_MODE`` values, so binding here
makes every existing call site mode-aware without touching one of them.
A mode layer is pure override. Its fields default to ``None``, which
means *inherit*, and any non-None value wins over the base element.
That is why ``None`` and a real value must stay distinguishable: a mode
offset of ``0`` means "sit at the base position", not "no preference"
— the same distinction ``scroll_card.switch_*`` draws with its
``"inherit"`` sentinel.
"""
def __init__(self, config: Optional[Dict[str, Any]],
defaults: Optional[Dict[str, Any]] = None,
mode: Optional[str] = None):
# Keep the exact object for identity-based invalidation, even if the
# caller hands us something odd; reads are guarded.
self._config = config
if isinstance(defaults, dict):
element_defaults = defaults.get('customization', {})
else:
element_defaults = {}
self._defaults: Dict[str, Any] = (
element_defaults if isinstance(element_defaults, dict) else {})
self._mode = mode if isinstance(mode, str) and mode else None
self._memo: Dict[Any, ElementStyle] = {}
@property
def mode(self) -> Optional[str]:
"""The mode bound at construction, if any."""
return self._mode
# -- internal accessors -------------------------------------------------
def _customization(self) -> Dict[str, Any]:
config = self._config if isinstance(self._config, dict) else {}
customization = config.get('customization', {})
return customization if isinstance(customization, dict) else {}
def _element_config(self, element_key: str) -> Dict[str, Any]:
return _lookup_element(self._customization(), element_key)
def _element_defaults(self, element_key: str) -> Dict[str, Any]:
return _lookup_element(self._defaults, element_key)
def _mode_block(self, mode: Optional[str]) -> Dict[str, Any]:
"""``customization.modes.<mode>``, or {} when there is no such block."""
if not mode:
return {}
modes = self._customization().get('modes', {})
if not isinstance(modes, dict):
return {}
block = modes.get(mode, {})
return block if isinstance(block, dict) else {}
def _mode_element_config(self, element_key: str,
mode: Optional[str]) -> Dict[str, Any]:
return _lookup_element(self._mode_block(mode), element_key)
def _effective_mode(self, mode: Any) -> Optional[str]:
"""A per-call mode overrides the bound one; anything else uses it."""
if isinstance(mode, str) and mode:
return mode
return self._mode
# -- public API ---------------------------------------------------------
def style(self, element_key: str,
classic_font: str = _FALLBACK_FONT_NAME,
classic_size: int = 8,
classic_color: Optional[Tuple[int, int, int]] = None,
mode: Optional[str] = None) -> ElementStyle:
"""Resolve one element's style. Never raises.
Args:
element_key: Key under ``config['customization']`` (e.g.
``'title_text'``).
classic_font: Font filename the plugin's classic (pre-
customization) code used for this element.
classic_size: Classic pixel size.
classic_color: Classic RGB color, or None when the caller only
cares about the font (``.color`` then falls back to the
schema default color, else white).
mode: Overrides the mode bound at construction for this call.
Rarely needed — a host that renders one mode should bind it
once instead.
Returns:
ElementStyle with the loaded font face, RGB color, (x, y)
offset, and the ``user_forced`` / ``user_forced_color`` flags.
"""
effective_mode = self._effective_mode(mode)
try:
memo_key = (element_key, classic_font, classic_size,
_normalize_color(classic_color) or classic_color,
effective_mode)
memoized = self._memo.get(memo_key)
if memoized is not None:
return memoized
except Exception:
memo_key = None
try:
resolved = self._resolve(element_key, classic_font,
classic_size, classic_color,
effective_mode)
except Exception as e:
logger.warning("Error resolving style for element '%s': %s — "
"using classic style", element_key, e)
resolved = self._classic_style(classic_font, classic_size,
classic_color)
if memo_key is not None:
self._memo[memo_key] = resolved
return resolved
def offset(self, element_key: str,
mode: Optional[str] = None) -> Tuple[int, int]:
"""The user's ``customization.layout.<element>`` (x, y) pixel
offset, defaulting to (0, 0). Never raises."""
return (self.offset_value(element_key, 'x_offset', 0, mode),
self.offset_value(element_key, 'y_offset', 0, mode))
def offset_value(self, element_key: str, axis: str, default: int = 0,
mode: Optional[str] = None) -> int:
"""One ``customization.layout.<element>.<axis>`` value as an int.
``axis`` is usually ``'x_offset'`` / ``'y_offset'`` but any key is
honored (e.g. the scoreboards' ``'away_x_offset'``). Numeric
strings are coerced; anything else degrades to ``default``. Never
raises.
When a mode is in play, ``customization.modes.<mode>.layout`` is
consulted first and wins if it carries a non-None value for this
axis — ``None`` there means inherit the base offset, which is what
lets a mode nudge one element without restating the rest.
"""
return layout_offset(self._config, element_key, axis, default,
self._effective_mode(mode))
@staticmethod
def _layout_element(block: Dict[str, Any],
element_key: str) -> Dict[str, Any]:
"""``block['layout'][element]``, or {} if absent anywhere.
The layout block is where the naming drift lives, so the lookup
goes through the aliases: a plugin asking for ``score_text``
offsets still finds the ``score`` its users configured.
"""
if not isinstance(block, dict):
return {}
return _lookup_element(block.get('layout'), element_key)
# -- resolution internals -----------------------------------------------
def _forced(self, element_config: Dict[str, Any],
element_defaults: Dict[str, Any],
mode_config: Dict[str, Any], key: str) -> Any:
"""A field's value if the user genuinely chose one, else None.
Same two-layer rule the font/size/colour resolution uses: a mode
value counts whenever it is set, a base value only when it differs
from the schema default (the save flow writes that default in
whether or not the user touched it). Returning None for "not
chosen" lets the caller substitute a neutral value, which is how
an untouched config keeps rendering exactly as before.
"""
mode_value = mode_config.get(key)
if mode_value is not None:
return mode_value
configured = element_config.get(key)
if configured is None:
return None
if key in element_defaults and configured == element_defaults[key]:
return None
return configured
def _resolve(self, element_key: str, classic_font: str,
classic_size: int,
classic_color: Optional[Tuple[int, int, int]],
mode: Optional[str] = None) -> ElementStyle:
element_config = self._element_config(element_key)
element_defaults = self._element_defaults(element_key)
mode_config = self._mode_element_config(element_key, mode)
# The two layers answer different questions. The base layer asks
# "does this differ from the schema default?", because the save flow
# writes the full default object into config.json whether or not the
# user touched it. The mode layer asks only "is it set?", because its
# schema default is None -- there is nothing for a stray write to
# make look deliberate.
# Font family: forced only when it differs from the schema default
# (falling back to the classic font as the reference when the
# schema declares none).
default_font = element_defaults.get('font', classic_font)
configured_font = element_config.get('font')
font_forced = (isinstance(configured_font, str) and configured_font
and configured_font != default_font)
# Font size: same rule, with defensive int coercion.
default_size = self._coerce_size(
element_defaults.get('font_size'), None)
if default_size is None:
default_size = self._coerce_size(classic_size, 8)
configured_size = self._coerce_size(element_config.get('font_size'),
None)
size_forced = (configured_size is not None
and configured_size != default_size)
font_name = configured_font if font_forced else classic_font
font_size = configured_size if size_forced else self._coerce_size(
classic_size, 8)
# Mode overrides sit on top of whatever the base layer settled on.
mode_font = mode_config.get('font')
if isinstance(mode_font, str) and mode_font:
font_name, font_forced = mode_font, True
mode_size = self._coerce_size(mode_config.get('font_size'), None)
if mode_size is not None:
font_size, size_forced = mode_size, True
user_forced = bool(font_forced or size_forced)
# Color: forced only when it differs from the schema default (or,
# absent one, from the classic color).
default_color = _normalize_color(element_defaults.get('text_color'))
configured_color = _normalize_color(element_config.get('text_color'))
reference_color = (default_color if default_color is not None
else _normalize_color(classic_color))
color_forced = (configured_color is not None
and configured_color != reference_color)
if color_forced:
color = configured_color
else:
color = (_normalize_color(classic_color) or default_color
or (255, 255, 255))
mode_color = _normalize_color(mode_config.get('text_color'))
if mode_color is not None:
color, color_forced = mode_color, True
visible = self._forced(element_config, element_defaults,
mode_config, 'visible')
align = self._forced(element_config, element_defaults,
mode_config, 'align')
# scale is geometry, so it lives with the offsets rather than in the
# element block -- a logo has a scale and no font.
# The default is looked up through the aliases too, like the value:
# an exact-key lookup missed a default filed under another name, so
# a configured value equal to it counted as a user choice.
scale = self._forced(
self._layout_element(self._customization(), element_key),
_lookup_element(self._defaults.get('layout', {}), element_key),
self._layout_element(self._mode_block(mode), element_key),
'scale')
# font_size reports what was actually realised, which differs from
# the request only when a BDF snapped to its native strike. Callers
# lay out from this value; reporting the request would reserve space
# for a size nothing was drawn at.
font, realised_size = _load_font_sized(font_name, font_size)
return ElementStyle(
font=font,
color=color,
offset=self.offset(element_key, mode),
font_name=font_name,
font_size=realised_size,
user_forced=user_forced,
user_forced_color=bool(color_forced),
visible=_coerce_bool(visible, True),
align=_coerce_align(align),
scale=coerce_scale(scale, 1.0),
)
def _classic_style(self, classic_font: str, classic_size: int,
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
"""The untouched fallback style — used when resolution itself
fails, so ``style()`` can keep its never-raises promise."""
size = self._coerce_size(classic_size, 8)
font, realised_size = _load_font_sized(classic_font, size)
return ElementStyle(
font=font,
color=_normalize_color(classic_color) or (255, 255, 255),
offset=(0, 0),
font_name=classic_font,
font_size=realised_size,
user_forced=False,
user_forced_color=False,
)
@staticmethod
def _coerce_size(value: Any, default: Optional[int]) -> Optional[int]:
"""An int pixel size, or ``default`` for None/garbage."""
if value is None or isinstance(value, bool):
return default
try:
size = int(value)
except (TypeError, ValueError):
return default
return size if size > 0 else default