Files
LEDMatrix/src/element_style.py
T
ChuckandClaude Opus 5.5 7b90759252 fix: /errors stack traces, Wi-Fi disconnect and save, plugin fonts, API cache TTL (#636)
* fix(errors): record the exception's own stack trace

record_error() called traceback.format_exc(), which only sees an
exception while its except block is running. plugin_executor records
exceptions caught on a worker thread after that block has ended, so
every trace on /errors read "NoneType: None". The trace is now built
from the exception's __traceback__. The executor's log call had the
same problem with exc_info=True and now passes the exception.

record_error() also merged LEDMatrixError context into the caller's
dict in place; it now works on a copy.

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

* docs(wifi): point at configure_wifi_permissions.sh instead of a sudoers list

The module docstring told users to grant NOPASSWD sudo on iptables and
ip. configure_wifi_permissions.sh refuses those grants on purpose: a
wildcard rule for either runs an arbitrary program as root. Point at
the script and say why it leaves them out.

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

* fix(wifi): disconnect finds the saved profile by SSID

disconnect_from_network() asked `nmcli -f NAME,802-11-wireless.ssid
connection show` for the profile to take down, but nmcli rejects that
column for `connection show`, so the lookup always failed and only the
device was disconnected. The per-profile lookup _connect_nmcli() already
used is now _find_profile_for_ssid(), and both callers share it. It
also splits terse output on the last colon and unescapes "\:", so a
profile name containing a colon is found.

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

* fix(wifi): write wifi_config.json atomically and report a failed save

_save_config() opened the file for writing in place and swallowed any
error, so a wifi_config.json left owned by root made the web toggle for
auto-enabling AP mode report success while nothing was saved, and a
crash mid-write could truncate the file. It now uses atomic_write_json,
which also keeps the file's owner and shared group when root saves it,
and returns False on failure. POST /wifi/ap/auto-enable answers 500 in
that case.

The file is now written with indent=4, like the other config files.

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

* fix(fonts): resolve plugin:// fonts in the plugin's own directory

FontManager looked for a plugin's bundled fonts under Path("plugins") /
plugin_id: relative to the process cwd, and not the default install
directory (plugin-repos/), so a manifest's plugin:// fonts never loaded.

register_plugin_fonts() takes an optional plugin_dir, and PluginManager
passes the directory it loaded the plugin from. Callers that omit it get
a lookup in the configured plugin_system.plugins_directory, then plugins/,
resolved against the install root.

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

* fix(api-helper): cache responses for the requested cache_ttl

APIHelper.get(cache_ttl=...) and set_cache(ttl=...) dropped the ttl on
the claim that CacheManager does not support one, but CacheManager.set()
takes a ttl, stores it with the entry, and both cache tiers honour it
over a reader's max_age. Without it every response expired after the
300-second default read age, whatever the plugin asked for. The ttl is
now passed through, and the cache read passes cache_ttl as max_age for
entries written without one. The class docstring describes what the
helper actually does.

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

* fix(style): one scale range for the schema, element_scale and LogoHelper

The generated Scale field allowed 0.1 to 10, element_style's reader
capped at 10 with no floor, and LogoHelper accepted 0.05 to 8 and reset
anything else to 1.0. A logo scale of 9, which the form accepts, drew at
the shipped size.

MIN_ELEMENT_SCALE / MAX_ELEMENT_SCALE (0.1, 10.0) in src.element_style
are now the schema bounds and the clamp every reader applies through
coerce_scale(): a positive number outside the range is clamped, and
anything that is not a finite positive number means the default. That
also stops element_scale() passing NaN through, since min(nan, 10.0)
is nan.

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

* fix(logos): placeholder lands at the requested path; empty logos list

download_missing_logo() wrote its fallback placeholder to
<normalize_abbreviation(abbr)>.png in the logo directory rather than to
the logo_path the caller passed, so it could return True while nothing
existed where the plugin looks (e.g. "TA&M.png" vs "TAANDM.png").
create_placeholder_logo() takes an optional filepath, and
download_missing_logo passes the requested one.

download_missing_logo_for_team() only caught KeyError, so a team whose
"logos" list is empty raised IndexError; it now treats KeyError,
IndexError and TypeError as "no logo URL".

The placeholder is drawn with PLACEHOLDER_SIZE / PLACEHOLDER_BG, the
constants is_placeholder_logo() recognises it by, instead of repeated
literals.

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

* fix(fonts): resolve bundled font paths against the install root

TextHelper's default font_dir, the logo placeholder's font and
FontManager's font_overrides.json were all relative to the process cwd,
so a process started anywhere but the install root (the plugin safety
harness, a manual run, a unit without WorkingDirectory) drew with PIL's
default face and read no overrides. They now go through
font_layout.resolve_asset_path; the overrides file sits in the install
root's config/.

The resolver docstrings described an order the code does not follow:
resolve_asset_path never consults the cwd, and sports_shared's
_resolve_font_path tries the cwd first. Both docstrings now say what
the code does, and _resolve_font_path calls resolve_asset_path instead
of probing FontManager for it.

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

* fix(sync): the web UI reads the sync status file the display writes

sync_manager writes its status to tempfile.gettempdir(), but
GET /api/v3/sync/status read a hardcoded /tmp/led_matrix_sync_status.json
and defaulted the port to a literal 5765. Wherever TMPDIR is set (or on
any non-/tmp host) the page only ever showed "starting". The endpoint now
uses sync_manager.STATUS_FILE and SYNC_PORT.

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

* fix(http): the rankings resolver sends the project's User-Agent

DynamicTeamResolver fetched ESPN rankings with a bare requests.get, so
it sent python-requests' default User-Agent, which ESPN rejects; the
AP_TOP_N favourites then resolved to nothing. It now sends
DEFAULT_HTTP_HEADERS. BaseOddsManager carried its own copy of the
User-Agent string and now uses the same shared headers (which also adds
Accept-Language).

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

* fix(backup): record the core release and read the configured plugin dir

The manifest's ledmatrix_version came from a VERSION file that does not
exist, then from .git/HEAD: a 12-character sha, or "ref: refs/he" when
the branch's ref was packed. It is now src.__version__.

list_installed_plugins() scanned a hardcoded plugin-repos/, so on an
install whose plugin_system.plugins_directory points elsewhere, plugins
missing from plugin_state.json were left out of the backup. It now reads
the configured directory from config/config.json, defaulting to
plugin-repos.

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

* fix(startup): report a missing display section once

A config without a display section produced three errors for the one
problem ("Missing required configuration key: display", "Display
configuration is missing or empty" and "Display configuration is
missing"), and an empty one produced two. _validate_config now reports
it once, as a missing key or an empty section, and
_validate_display_config leaves it to that.

The module docstring said the validator fails fast; nothing in the
display service calls raise_on_errors(), so it now says the errors are
reported and startup continues.

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

* refactor(wifi): share the copied blocks and name the AP constants

- _parse_nmcli_wifi_list() is the one parser behind _scan_nmcli and
  _scan_nmcli_cached.
- _verify_connected(), _wait_for_device_idle(), _failsafe_ap() and
  _mark_forced() replace blocks that were pasted two or three times in
  the connect and enable-AP paths. The device-idle wait now checks
  before its first one-second sleep instead of after it.
- _check_command() calls _find_command_path() instead of repeating it.
- AP_IP, PORTAL_PORT, AP_PROFILE_NAME and AP_PROFILE_NAMES name values
  that were spelled out 14, 12, 8 and 2 times; the two deletion loops
  now walk the same tuple. The iwconfig status path compares the AP
  address exactly: startswith() also skipped 192.168.4.10-19.
- Dropped a second WIFI.SIGNAL query that repeated the first, a no-op
  "if ssid: continue", the try/except around _connect_wpa_supplicant's
  constant return, and a second save of a scan scan_networks already
  saves.
- _ensure_wifi_radio_enabled's docstring says it returns True when the
  radio state cannot be read at all.

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

* refactor(config): drop dead branches and history comments in ConfigManager

- The module docstring pointed plugin authors at update_plugin_config(),
  which does not exist; it now names save_config_atomic() and
  save_raw_file_content().
- load_config's FileNotFoundError handler tested the message for
  "config_secrets.json", but a missing secrets file is handled where it
  is read, so only config.json reaches it; the check is gone.
- save_raw_file_content's `file_type == "main" or "secrets"` guard was
  always true (anything else raised earlier).
- get_raw_file_content('secrets') already returns {} for a missing file,
  so the os.path.exists() in front of two calls to it is gone.
- Comments that narrated earlier behaviour are rewritten as what the
  code does now.

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

* refactor(background-data): present-tense comments, drop unused API

- Comments that told the history of each fix (what "used to" happen,
  "the old per-delivery release") now state the invariant the code keeps.
- get_statistics() no longer reports a constant 'queue_size': 0, and the
  uncalled clear_completed_requests() is gone (_cleanup_completed_requests
  does that job on every completion). Neither is referenced in core, the
  web UI or the plugin monorepo.

shutdown_background_service() has no production caller either, but it
is the only way to tear down the get_background_service() singleton,
which the tests rely on, so it stays.

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

* refactor(odds): drop the unread cache_ttl and merge the odds_data branches

BaseOddsManager loaded base_odds_manager.cache_ttl from config and never
used it: cached odds live for the update interval (get_odds' ttl=interval).
No core or monorepo code reads the attribute, so it is gone along with
its log line. The two consecutive `if odds_data:` blocks are one.

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

* refactor(backup): one table for the single-file sections

config, secrets, wifi and ytm_auth were each spelled out in create,
preview, validate and restore. _SINGLE_FILE_SECTIONS lists them once,
with the RestoreOptions flag that restores each, and all four walk it.
Restore error messages keep their wording ("Failed to restore
<file name>").

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

* refactor(fonts): drop FontManager's write-only state and duplicate logs

- fonts_config, font_metadata and font_dependencies were written and
  never read; the performance_stats keys font_load_times, render_times,
  total_renders and the per-call "resolve" timings
  (_record_performance_metric) likewise. get_performance_stats() reads
  only the counters that remain. Nothing in core or the plugin monorepo
  references any of them.
- A failed BDF load was logged twice, by _load_bdf_font and again by
  get_font; get_font's line is the one kept.
- Removed "NEW:" and commented-out cozette entries, the "Copy font to
  assets/fonts" comment on code that copies nothing, and local imports
  of names the module already imports. The deprecated add_font() now
  resolves assets/fonts against the install root.

The @deprecated methods stay.

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

* refactor(text-helper): cache loaded fonts; drop the pre-textlength fallback

TextHelper declared _font_cache, cleared it and reported its size, but
never stored anything in it. load_fonts() now keeps each (file, size)
it loads there, so clear_font_cache() and get_font_cache_stats() mean
what they say and repeated load_fonts() calls reuse the fonts.

get_text_width() no longer catches AttributeError for Pillow releases
without ImageDraw.textlength; requirements.txt pins Pillow>=12.2.
The class docstring describes what the helper does.

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

* docs(common): fix wrong docstrings in api_helper, permission_utils, snapshot_policy

- permission_utils called 0o2775 "sticky bit"; the 2 is setgid, which is
  what makes new files take the directory's group.
- snapshot_policy pointed at web_interface/blueprints/api_v3.py, which
  is a package now; the health check is in api_v3/misc.py.
- APIHelper.clear_cache() lost a history note and a fallback to a
  clear() method that neither CacheManager nor the testing
  MockCacheManager has. The session headers are built from
  DEFAULT_HTTP_HEADERS instead of a copy of them, and the module
  docstring says what the module offers.

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

* docs(sports): present-tense comments in the shared scoreboard renderers

- sports_scroll and sports_game_renderer comments that referred to "this
  PR", "the old flat 128px card" or what the renderer "previously" did
  now describe the current behaviour and its reason.
- The block explaining why non-finite settings are rejected sat above
  _score_reserve_width; it describes _center_gap_width and now lives in
  it.
- unshare_element_fonts wrapped its import of font_layout.load_truetype
  in an `except ImportError` that cannot fire inside core; the import
  stays at call time so tests can spy on the pinned loader.
- sports_card docstrings that told the history of a fix say what the
  code does.

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

* refactor(sports-shared): drop dead code, name the ESPN limit

- _get_weeks_data asked for limit=1000, which fetch_espn_scoreboard
  clamps to ESPN_MAX_LIMIT anyway; it now names that constant. Its
  unused `immediate_events = []` is gone.
- _get_season_schedule_dates() returned ("", "") and has no caller in
  core or the plugin monorepo.
- _should_log keeps its warning_type parameter (part of the inherited
  signature, though nothing in core or the monorepo calls it) and its
  docstring says the cooldown is shared across types.
- An unused ImageFont import is gone.

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

* refactor(sync): one follower-mode switch, shared panel defaults

- The class docstring said the leader sends PNG frames. Frames go over
  UDP as raw RGB; PNG is only the Vegas scroll image sent over TCP. It
  now describes both paths.
- _enter_follower_mode() replaces the two copies of "note the leader,
  switch from standalone to follower, log, write status" in the frame
  and scroll-position handlers.
- The rows/cols fallbacks use DEFAULT_ROWS / DEFAULT_COLS from
  src.display_geometry, as chain_length already did.

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

* refactor(style): drop _layout_axis, name the layout group title

- ElementStyleResolver._layout_axis() had no caller in core or the
  plugin monorepo.
- _element_block_from_spec checked spec['size'] was a dict again after
  size_spec already had; it reads size_spec.
- The "Layout Offsets" title written into three generated schema blocks
  is _LAYOUT_TITLE.

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

* docs(logo-helper): say what the placeholder draws; name the 1.5 box factor

- _create_placeholder_logo's docstring said it draws the team
  abbreviation; it draws an outlined grey box and nothing else. The
  docstring says so, and the "in a real implementation you'd want text"
  comments are gone.
- The 1.5 x panel default logo box, written out six times, is
  DEFAULT_LOGO_BOX_FACTOR.
- ImageDraw is imported with Image at the top of the module.

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

* refactor(logos): drop dead code and a duplicate regex in logo_downloader

- _SAFE_LEAGUE_CODE_RE was the same pattern as _SAFE_LEAGUE_RE; both
  checks use the one.
- get_logo_filename_variations reassigned the TA&M case to the list it
  already had; the function returns the two names directly.
- _get_team_name_variations() had no caller in core or the plugin
  monorepo.
- fetch_single_team's docstring was copied from fetch_teams_data; a log
  message read "for{team_id}".

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

* refactor: drop the Pillow<9.1 resample shim and a catch-and-reraise

- adaptive_images fell back to Image.LANCZOS/NEAREST for Pillow < 9.1;
  requirements.txt pins Pillow>=12.2. RESAMPLE_LANCZOS and
  RESAMPLE_NEAREST keep their names (src.common re-exports them).
- CacheManager.save_cache caught CacheError only to re-raise it; the
  disk write is now called directly, with the same result.

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

* test(api-helper): stop the real CacheManager's cleanup thread

The cache-lifetime tests built a CacheManager and left its cleanup
thread's class-wide claim on the directory in place, which broke
test_cache_cleanup_thread_ownership when it ran later in the session.
The fixture now stops the thread on teardown.

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

* docs(changelog): core-common

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:32:29 -04:00

1535 lines
66 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
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).
# BDF faces are stateful in principle, but the core's own FontManager shares
# faces the same way.
#
# 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()
def _cache_put(key: Tuple[str, int], value: Tuple[Any, int]) -> None:
"""Insert, evicting the least recently used entry past the bound."""
_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)
cache_key = (path, size)
cached = _font_cache.get(cache_key)
if cached is not None:
_font_cache.move_to_end(cache_key)
return cached
try:
if path.lower().endswith('.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)
_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 = _font_cache.get(cache_key)
if cached is not None:
_font_cache.move_to_end(cache_key)
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.
layout_defaults = self._defaults.get('layout', {})
scale = self._forced(
self._layout_element(self._customization(), element_key),
layout_defaults.get(element_key, {})
if isinstance(layout_defaults, dict) else {},
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