"""Card-drawing helpers shared by every sports scoreboard plugin. The eight scoreboards each carried byte-identical copies of the functions below: the colour pickers, the settings lookup, the date and time formatting, the favourite-team rules and the font-size grid snapping. One fix had to be made eight times, and a new scoreboard started by copying them a ninth. Everything here is a **free function taking explicit arguments**, not a base class. Adoption is therefore per-function and reversible: a plugin keeps its method and delegates the body, so the call sites and the override points are untouched. That is also why `config`, `logger` and `fonts` are parameters rather than attributes -- the helper never reaches back into the caller. The bodies are the plugins' own code, moved rather than rewritten. The one deliberate difference is `crisp_size`, which takes the seven-plugin guard (`not desired`) instead of football's: they agree on every real input, and the extra guard only stops a None size raising TypeError. """ import logging from datetime import datetime, timezone from typing import Any, Dict, Optional, Tuple from zoneinfo import ZoneInfo from src.common.font_layout import ( # noqa: F401 - re-exported, see below FONT_NAME_ALIASES, FONT_PIXEL_GRID, crisp_size, ) logger = logging.getLogger(__name__) __all__ = [ "ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES", "FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR", "scroll_card_option", "element_color", "font_color", "coerce_rgb", "score_color_for", "recent_score_color", "favorite_teams_for", "side_is_favorite", "side_score", "favorite_result", "card_tzinfo", "weekday_for", "format_game_date", "format_game_time", "vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size", "resolve_font_size", "unshare_element_fonts", ] #: Which customization element owns each font key, for colour resolution. ELEMENT_FOR_FONT: Dict[str, str] = { "score": "score_text", "time": "period_text", "team": "team_name", "status": "status_text", "detail": "detail_text", "rank": "rank_text", } #: Fallback colours when favourite_result_colors is on but a slot is unset. FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = { "win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0), } # Re-exported rather than defined: the grid tables and the snapping rule are # properties of the font files, which the display core needs too (it loads the # same two faces in DisplayManager._load_fonts). They live in # src/common/font_layout.py so there is one definition; they stay in this # module's namespace and __all__ so the eight scoreboards that delegate to # `sports_card.crisp_size` are untouched. MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") # --------------------------------------------------------------------------- # Settings lookup # --------------------------------------------------------------------------- def scroll_card_option(config: Optional[Dict[str, Any]], key: str, default: Any = None) -> Any: """Read one key from the scroll_card config block.""" block = (config or {}).get("scroll_card") if isinstance(block, dict) and block.get(key) is not None: return block.get(key) return default def vs_text(config: Optional[Dict[str, Any]]) -> str: """Separator drawn between the teams -- "VS", "@", "at", anything.""" return str(scroll_card_option(config, "vs_text", "VS")) def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str: """Middle of an upcoming card: 'vs', 'date_time' or 'none'.""" mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower() return mode if mode in ("vs", "date_time", "none") else "vs" # --------------------------------------------------------------------------- # Colour # --------------------------------------------------------------------------- def element_color(config: Optional[Dict[str, Any]], element: str, default: Tuple[int, int, int] = (255, 255, 255), mode: Optional[str] = None): """Per-element text colour from customization..text_color. Delegates to src.element_style.element_color, which also resolves the element under the names plugins actually use (the layout block says `score` where the style block says `score_text`) and honours a per-mode override. Hex strings are accepted. """ from src.element_style import element_color as _shared return _shared(config, element, default, mode) def resolve_font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]], font, default: Tuple[int, int, int], element_for_font: Dict[str, str], mode: Optional[str] = None): """Colour for whichever element owns this face. Identity matching is a stand-in for the element name, used where the draw site only ever received a font. Prefer ``element=`` on the draw call; this is the fallback for the sites that have not been annotated yet. One object can legitimately belong to several elements -- a size resolver can land two of them on the same face, and a BDF face cannot be un-shared at all because ``freetype.Face`` objects cannot be rebuilt from a path. Ambiguity is therefore narrowed before it is given up on: among the elements sharing a face, a single configured colour is the only thing the user can have meant, and several that agree mean the same thing. Only a genuine disagreement falls back to *default* -- otherwise an element drawn in any of the shipped bitmap fonts could lose a colour the user set. The element vocabulary is a parameter because the two callers disagree about it -- the mixin's map says ``team_text`` where this module's says ``team_name`` -- and quietly re-pointing either at the other's names would change which colour setting a live install honours. """ try: fonts = fonts or {} matches = [element for key, element in element_for_font.items() if fonts.get(key) is font] if len(matches) == 1: return element_color(config, matches[0], default, mode) if len(matches) > 1: configured = [] for element in matches: # None as the default makes it come back when unconfigured. colour = element_color(config, element, None, mode) # type: ignore[arg-type] if colour is not None and colour not in configured: configured.append(colour) if len(configured) == 1: return configured[0] except (AttributeError, TypeError): pass return default def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]], font, default: Tuple[int, int, int] = (255, 255, 255), mode: Optional[str] = None): """Colour for whichever element owns this face, by this module's map.""" return resolve_font_color(config, fonts, font, default, ELEMENT_FOR_FONT, mode) def coerce_rgb(value, fallback): """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" # Checked before unpacking: a 3-character string ("123") would otherwise # iterate into three digits and yield a colour rather than the fallback. if not isinstance(value, (list, tuple)) or len(value) != 3: return fallback try: r, g, b = (max(0, min(255, int(channel))) for channel in value) except (TypeError, ValueError): return fallback return (r, g, b) # --------------------------------------------------------------------------- # Favourite teams # --------------------------------------------------------------------------- def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list: """Favorite teams that apply to this game. Both sources are used. Games carry the league manager's *resolved* favorites, which is the only place dynamic groups such as AP_TOP_25 appear expanded; the config is read as well so an edit takes effect on already-fetched games, and so hand-built game dicts (tests, other callers) still work. """ favorites = list(game.get("favorite_teams") or []) league_config = config.get(str(game.get("league", "") or "")) if isinstance(league_config, dict): favorites += list(league_config.get("favorite_teams") or []) else: favorites += list(config.get("favorite_teams") or []) return favorites def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool: """Is the home/away side of this game a favorite team? Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``) payload shapes, and matches on the ESPN id too, because a couple of leagues (NRL) key favorites by id where abbreviations collide. """ candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")] team = game.get(f"{side}_team") if isinstance(team, dict): candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")] for value in candidates: if value is not None and str(value).strip().upper() in favorites: return True return False def side_score(game: Dict[str, Any], side: str) -> Optional[int]: """Numeric score for one side, from either payload shape.""" raw = None team = game.get(f"{side}_team") if isinstance(team, dict) and team.get("score") is not None: raw = team.get("score") if raw is None: raw = game.get(f"{side}_score") try: return int(float(str(raw).strip())) except (TypeError, ValueError): return None def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> Optional[str]: """Say how the favorite team did in a finished game. Returns 'win', 'loss' or 'tie', or None when there is no single team to root for: no favorites configured, neither side is a favorite, or *both* are -- a favorite-vs-favorite game has no losing side worth flagging in red. Also None when the scores are not usable numbers. """ favorites = { str(team).strip().upper() for team in favorite_teams_for(config, game) if str(team).strip() } if not favorites: return None home_fav = side_is_favorite(game, "home", favorites) away_fav = side_is_favorite(game, "away", favorites) if home_fav == away_fav: return None home_score = side_score(game, "home") away_score = side_score(game, "away") if home_score is None or away_score is None: return None if home_score == away_score: return "tie" favorite_score, other_score = ( (home_score, away_score) if home_fav else (away_score, home_score) ) return "win" if favorite_score > other_score else "loss" def recent_score_color(config: Dict[str, Any], logger, game: Dict[str, Any], default): """Fill color for a finished game's score, per favorite_result_colors.""" try: settings = (config.get("customization") or {}).get( "favorite_result_colors" ) or {} if not settings.get("enabled", False): return default result = favorite_result(config, game) if result is None: return default return coerce_rgb( settings.get(f"{result}_color"), FAVORITE_RESULT_COLOR_DEFAULTS[result], ) except Exception: logger.debug("Could not resolve favorite result color", exc_info=True) return default def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any], game_type: str, default=None): """Fill color for a game card's score. Only finished games are tinted. The default is the configured score colour rather than a flat white, so customization.score_text.text_color shows on games the favourite tint does not apply to. The tint still wins where it applies. """ if default is None: default = element_color(config, 'score_text') if game_type != "recent": return default return recent_score_color(config, logger, game, default) # --------------------------------------------------------------------------- # Date and time # --------------------------------------------------------------------------- def card_tzinfo(config: Optional[Dict[str, Any]], logger): """Timezone for weekday/24h conversions; falls back to UTC.""" configured = (config or {}).get("timezone") if configured: try: return ZoneInfo(configured) except (KeyError, ValueError, TypeError, OSError) as exc: # KeyError covers ZoneInfoNotFoundError. A bad zone name in # config should fall back to UTC, not blank the card. logger.debug("Unusable timezone %r: %s", configured, exc) return timezone.utc def weekday_for(config: Optional[Dict[str, Any]], logger, game: Optional[Dict]) -> str: """Weekday abbreviation from the game's start time, or ''.""" if not game: return "" raw = game.get("start_time_utc") or game.get("start_time") if not raw: return "" try: start = raw if isinstance(raw, datetime) else datetime.fromisoformat( str(raw).replace("Z", "+00:00")) return WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()] except (ValueError, TypeError): return "" def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str, game: Optional[Dict] = None) -> str: """Format an upcoming card's date per scroll_card.date_format.""" raw = str(date_text or "").strip() if not raw: return "" fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev") return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game)) def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str: """Render a stripped, non-empty "M/D" *raw* in style *fmt*. The body both date formatters share. They differ in which setting names the style and in which zone the weekday is taken from (see ``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments: *weekday* is a zero-argument callable, only called for the "weekday" style. *months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``. """ if fmt == "numeric": return raw parts = raw.replace("-", "/").split("/") if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()): return raw month, day = int(parts[0]), int(parts[1]) if not 1 <= month <= 12: return raw name = months[month - 1] if fmt == "numeric_day_first": return f"{day}/{month}" if fmt == "day_first": return f"{day} {name}" if fmt == "weekday": day_name = weekday() return f"{day_name} {name} {day}" if day_name else f"{name} {day}" return f"{name} {day}" def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str: """Return the time as-is (12h) or converted to 24h.""" raw = str(time_text or "").strip() if not raw or str(scroll_card_option(config, "time_format", "12h")) != "24h": return raw cleaned = raw.upper().replace(" ", "") meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else "" if not meridiem: return raw try: hh, _, mm = cleaned[:-2].partition(":") hour, minute = int(hh), int(mm or 0) except ValueError: return raw if not (0 <= hour <= 12 and 0 <= minute <= 59): return raw hour = hour % 12 + (12 if meridiem == "PM" else 0) return f"{hour:02d}:{minute:02d}" # --------------------------------------------------------------------------- # Font sizing # --------------------------------------------------------------------------- #: Per-schema caches, keyed by the schema's absolute path. Keyed rather than #: global because each plugin declares its own defaults; keyed rather than #: per-class because the helper has no class to hang it on. _SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {} def _read_schema_font_sizes(schema_path: str) -> Dict[str, int]: """``{element: font_size default}`` from a config_schema.json. Raises. The parse both schema-default lookups share. Each keeps its own cache -- this function per schema path, ``SportsCoreSharedMixin._schema_font_size`` per class -- because the lifetimes differ: a class is rebuilt when the display service reloads a plugin, a module-level path cache is not. One cache would change when a reloaded plugin sees an edited schema. """ import json with open(schema_path) as fh: schema = json.load(fh) props = (schema.get('properties', {}) .get('customization', {}) .get('properties', {})) sizes: Dict[str, int] = {} for key, spec in props.items(): size = spec.get('properties', {}).get('font_size', {}).get('default') if size is not None: sizes[key] = int(size) return sizes def schema_font_size(schema_path: str, element_key) -> Optional[int]: """The font_size this plugin's config_schema.json declares, or None. Cached per schema path. The plugins cached this on their own class; the path is the same distinction expressed without one, so two plugins never share an entry. """ if not element_key: return None cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path) if cache is None: try: cache = _read_schema_font_sizes(schema_path) except Exception as exc: # See sports_shared._schema_font_size: an unreadable schema # silently disables the pixel-grid snap for every element. # Built once per schema path, so this cannot repeat per frame. logger.warning( "could not read %s (%s: %s); font sizes will skip their " "pixel grid snap and may render a pixel narrow", schema_path, type(exc).__name__, exc) cache = {} _SCHEMA_FONT_SIZE_CACHE[schema_path] = cache return cache.get(element_key) def resolve_font_size(schema_path: str, element_config, element_key, default_size, font_name, aliases=None, grid_table=None): """Size to render at: the user's choice, or a grid-snapped default. A configured size counts as a real choice only when it differs from the schema default. The web UI writes the whole schema default block on every save, so "font_size == schema default" carries no intent and would otherwise pin every install to an anti-aliased size forever. """ configured = (element_config or {}).get('font_size') if configured is not None: try: configured = int(configured) if configured != schema_font_size(schema_path, element_key): return configured except (TypeError, ValueError): pass return crisp_size(font_name, default_size, aliases, grid_table) def unshare_element_fonts(logger, fonts, element_for_font=None): """Give each colourable element its own face object. The colour a draw gets is resolved from the face it was handed, and several of these loaders legitimately hand one object to more than one element -- a size resolver that lands two elements on the same face, a fallback that fills every key from one default, football's narrowing step that deliberately shrinks the clock along with the score. Sharing the object makes the element ambiguous and the colour unresolvable. Re-instantiating from the same path and size gives a distinct object with identical metrics, so nothing about the rendering changes; only the ability to tell two elements apart does. Faces that cannot be rebuilt (a BDF loaded through freetype.Face, anything without a usable path) are left shared; resolve_font_color then picks their colour. *element_for_font* names the font keys to consider, in order (the first holder of a face keeps it); it defaults to this module's :data:`ELEMENT_FOR_FONT`. ``SportsCoreSharedMixin`` passes its own map, which names different keys -- see ``resolve_font_color`` for why the two vocabularies are kept apart. """ # Looked up at call time so tests can spy on the pinned loader. from src.common.font_layout import load_truetype if element_for_font is None: element_for_font = ELEMENT_FOR_FONT seen = {} for key in element_for_font: font = fonts.get(key) if font is None: continue if id(font) not in seen: seen[id(font)] = key continue path, size = getattr(font, "path", None), getattr(font, "size", None) if not path or not size: continue try: fonts[key] = load_truetype(path, size) except (OSError, ValueError, TypeError): logger.debug( "Could not un-share the %s face; it keeps the default colour", key) return fonts