diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a443458..9e4d809 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,7 +129,18 @@ specific plugin later. Per-plugin tests live in the LEDMatrix repo at `test/plugins/`. If you're adding a test for your plugin, open a corresponding PR in LEDMatrix. The dev preview server (`scripts/dev_server.py` in -LEDMatrix) is the fastest way to iterate visually. +LEDMatrix) is the fastest way to iterate visually — its **All Sizes** +button renders your plugin at every harness panel size side by side. + +Plugins should **scale up, not just avoid overflow**: content that stays +tiny in the corner of a panel twice its design size passes the bounds +check but fails users. Use the adaptive layout system +(`docs/ADAPTIVE_LAYOUT.md` in LEDMatrix: `self.layout`, `draw_fit`, +`draw_image`, `scoreboard_regions`) and check the harness's +`fill warn` output in `check_plugin.py` reports. Declare your layout's +design size in the manifest (`"display": {"design_size": ...}`) and, once +your plugin is adaptive, opt into strict checking via +`test/harness.json`: `{"fill_check": "strict"}`. ## Code of Conduct diff --git a/plugins.json b/plugins.json index 1b9aca0..9bfe4ec 100644 --- a/plugins.json +++ b/plugins.json @@ -210,10 +210,10 @@ "plugin_path": "plugins/football-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-08", + "last_updated": "2026-07-10", "verified": true, "screenshot": "", - "latest_version": "2.7.0" + "latest_version": "2.8.2" }, { "id": "geochron", diff --git a/plugins/football-scoreboard/CHANGELOG.md b/plugins/football-scoreboard/CHANGELOG.md index 88cb031..a6dd519 100644 --- a/plugins/football-scoreboard/CHANGELOG.md +++ b/plugins/football-scoreboard/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [2.8.1] - 2026-07-10 + +### Fixed +- **Adaptive layout (beta): blurry text on small panels.** Two of the ladder rungs + used for score/status/detail text rendered with visible antialiasing: + `press_start@10px`/`@12px` (PressStart2P only rasterizes crisply at exact + multiples of its 8px design grid) and `5by7.regular` (never crisp at any + size tested, in this or any other font's PIL rendering). Both dropped; + `4x6-font` corrected from 6px (33% antialiased) to 7px (its actual crisp + size). Every rung is now verified with `measure_font_crispness()` at 0% + antialiasing, with a regression test guarding it. +- **Adaptive layout (beta): score text could overlap the team logos on + larger panels.** Score/status/detail text was sized to the largest crisp + font that fit its own region — on a big panel that region has generous + room, so the score could balloon large enough to visually overlap/obscure + the logos next to it (which scale by a fixed geometry factor instead). + Text now sizes proportionally to the panel's scale factor relative to the + classic fixed size for that element (score=10px, status=8px, detail=6px) + via the new core `LayoutContext.fit_text_proportional()`, matching + classic's visual balance at every size while still being crisp and + larger than classic's fixed size. + +## [2.8.0] - 2026-07-10 + +### Added +- **Adaptive layout (beta, opt-in)**: set `"layout_mode": "adaptive"` to scale + fonts, logos, and element regions to the panel size — score/status/detail + text grows on large panels (e.g. 32px score on a 256x128) instead of staying + at the fixed classic sizes, and layouts degrade gracefully on small panels. + Built on the LEDMatrix core adaptive layout system (`src/adaptive_layout.py`); + on older cores the plugin silently keeps the classic layout. + - **Default is `"classic"`** — rendering is byte-identical to 2.7.0 unless + you opt in (verified by the committed golden images). + - **Your customization still applies in adaptive mode**: explicitly + configured `customization..font`/`font_size` win over the + adaptive sizing, and `customization.layout.` x/y offsets + translate elements from their computed positions. Note: adaptive mode + applies layout offsets in scroll mode too (classic scroll never did). + - **To revert**: set `"layout_mode": "classic"` in the plugin config — no + reinstall needed. (Or roll back to 2.7.0 in the plugin store.) +- Manifest now declares `display.design_size` (128x32), enabling the harness + scale-up fill check. Adaptive-mode golden images live in + `test/golden-adaptive/` (`test_adaptive_layout_mode.py`). + ## [2.4.0] - 2026-06-15 ### Added diff --git a/plugins/football-scoreboard/README.md b/plugins/football-scoreboard/README.md index 63a9a7f..83f82f9 100644 --- a/plugins/football-scoreboard/README.md +++ b/plugins/football-scoreboard/README.md @@ -277,6 +277,27 @@ If you have dynamic duration caps configured (e.g., `max_duration_seconds: 120`) - **Customizable Layout**: Adjust positioning of all elements via X/Y offsets - **Customizable Fonts**: Configure font family and size for each text element +### Adaptive Layout (beta) + +Set `"layout_mode": "adaptive"` to scale fonts, logos, and element regions +to your panel size — the score renders at up to 32px on a 256x128 instead of +the fixed 10px, and layouts degrade gracefully on small panels. + +```json +{ + "layout_mode": "adaptive" +} +``` + +- The default is `"classic"`: rendering is completely unchanged unless you + opt in. **To revert at any time, set it back to `"classic"`** — no + reinstall needed. +- Your font and X/Y offset customizations still apply in adaptive mode: + an explicitly configured font wins over the adaptive sizing, and offsets + shift elements from their computed positions. +- Requires a LEDMatrix core with the adaptive layout system + (`docs/ADAPTIVE_LAYOUT.md`); older cores silently keep the classic layout. + ### Layout Customization The plugin supports fine-tuning element positioning for custom display sizes. All offsets are relative to the default calculated positions, allowing you to adjust elements without breaking the layout. diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 7f3614c..730102a 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -692,6 +692,12 @@ } } }, + "layout_mode": { + "type": "string", + "enum": ["classic", "adaptive"], + "default": "classic", + "description": "Layout engine. 'classic' is the original fixed layout (unchanged). 'adaptive' (beta) scales fonts, logos, and element regions to the panel size — content grows on large panels and degrades gracefully on small ones. Your customization fonts and x/y offsets still apply in adaptive mode. Requires LEDMatrix core with the adaptive layout system; falls back to classic on older cores. Switch back to 'classic' at any time to restore the original rendering." + }, "customization": { "type": "object", "title": "Display Customization", diff --git a/plugins/football-scoreboard/football.py b/plugins/football-scoreboard/football.py index c74dd62..514a588 100644 --- a/plugins/football-scoreboard/football.py +++ b/plugins/football-scoreboard/football.py @@ -160,6 +160,11 @@ def _test_mode_update(self): def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: """Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring try: + # Adaptive layout (beta, opt-in via layout_mode: "adaptive") — + # classic layout below is untouched when it declines. + if self._adaptive_scorebug(game, "live", force_clear): + return + # Clear the display first to ensure full coverage (like weather plugin does) if force_clear: self.display_manager.clear() diff --git a/plugins/football-scoreboard/game_renderer.py b/plugins/football-scoreboard/game_renderer.py index c4d4163..eb38f32 100644 --- a/plugins/football-scoreboard/game_renderer.py +++ b/plugins/football-scoreboard/game_renderer.py @@ -22,8 +22,64 @@ except ImportError: FREETYPE_AVAILABLE = False +# Adaptive layout system (opt-in via layout_mode: "adaptive"). Older LEDMatrix +# cores don't ship it — fall back silently to the classic layout. +try: + from src.adaptive_layout import ( + FitResult, + FontStep, + LADDER_ARCADE, + LayoutContext, + Region, + measure_ink, + scoreboard_regions, + ) + ADAPTIVE_AVAILABLE = True + # TTF-only ladders: this renderer outlines text via ImageDraw.text(), + # which can't take the freetype BDF faces of the core grid ladder. + # + # Every rung here is verified crisp (measure_font_crispness == 0.0, see + # test_adaptive_layout_mode.py::TestLadderCrispness): PressStart2P is a + # pixel-grid font that PIL only rasterizes without antialiasing at exact + # multiples of 8px — a 10px or 12px rung (tried in an earlier version, + # to match classic's fixed 10px score) is 18-30% antialiased and reads + # as blurry on an LED panel. "5by7.regular" never renders crisp at any + # size in this range and was dropped entirely; "4x6-font" is crisp only + # at 7px, not the 6px used previously. + ADAPTIVE_LADDER_HEADLINE = LADDER_ARCADE + ( + FontStep("4x6-font", 7), + ) + ADAPTIVE_LADDER_TEXT = ( + FontStep("press_start", 16), + FontStep("press_start", 8), + FontStep("4x6-font", 7), + ) +except ImportError: + ADAPTIVE_AVAILABLE = False + +# Shared element-style resolver (newer cores): one implementation of font +# loading and of the "did the user actually override this font?" check, +# referenced against this plugin's own config_schema.json. Older cores fall +# back to the local loader below. +try: + from src.element_style import ElementStyleResolver, defaults_from_schema_file + STYLE_AVAILABLE = True +except ImportError: + STYLE_AVAILABLE = False + logger = logging.getLogger(__name__) +_shared_font_manager = None + + +def _get_font_manager(): + """Shared FontManager for adaptive font ladders (scans assets/fonts).""" + global _shared_font_manager + if _shared_font_manager is None: + from src.font_manager import FontManager + _shared_font_manager = FontManager({}) + return _shared_font_manager + class GameRenderer: """ @@ -55,12 +111,38 @@ def __init__( self.display_height = display_height self.config = config self.logger = custom_logger or logger - + # Shared logo cache for performance self._logo_cache = logo_cache if logo_cache is not None else {} - + + # Element-style resolver: schema defaults come from this plugin's own + # config_schema.json so the user-override check works in every + # context (production, harness, dev server). None on older cores. + self._style_resolver = None + if STYLE_AVAILABLE: + schema_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'config_schema.json') + self._style_resolver = ElementStyleResolver( + config, defaults_from_schema_file(schema_path)) + # Load fonts self.fonts = self._load_fonts() + + # Adaptive layout (beta, opt-in): scales fonts/logos/regions to the + # card size instead of the classic fixed-pixel layout. Default + # "classic" renders byte-identically to previous releases. + self.layout_mode = config.get('layout_mode', 'classic') + self._adaptive = ADAPTIVE_AVAILABLE and self.layout_mode == 'adaptive' + if self.layout_mode == 'adaptive' and not ADAPTIVE_AVAILABLE: + self.logger.warning( + "layout_mode 'adaptive' requires a LEDMatrix core with the " + "adaptive layout system; falling back to classic layout" + ) + if self._adaptive: + self._ctx = LayoutContext(display_width, display_height, + _get_font_manager(), + design_size=(128, 32)) + self._raw_logo_cache: Dict[str, Image.Image] = {} # Display options are read dynamically per league (stored in config under league.display_options) # These defaults are kept for backward compatibility but should not be used @@ -80,10 +162,19 @@ def _load_fonts(self) -> Dict[str, Union[ImageFont.FreeTypeFont, Any]]: freetype.Face for BDF fonts) """ fonts = {} - + + if self._style_resolver is not None: + for font_key, (loader_font, loader_size) in self._LOADER_DEFAULTS.items(): + element = self._FONT_ELEMENT_KEYS.get(font_key, font_key) + fonts[font_key] = self._style_resolver.style( + element, classic_font=loader_font, + classic_size=loader_size).font + return fonts + + # Older cores (no src.element_style): the original local loader. # Get customization config customization = self.config.get('customization', {}) - + # Load fonts from config with defaults for backward compatibility score_config = customization.get('score_text', {}) period_config = customization.get('period_text', {}) @@ -91,7 +182,7 @@ def _load_fonts(self) -> Dict[str, Union[ImageFont.FreeTypeFont, Any]]: status_config = customization.get('status_text', {}) detail_config = customization.get('detail_text', {}) rank_config = customization.get('rank_text', {}) - + try: fonts["score"] = self._load_custom_font(score_config, default_size=10) fonts["time"] = self._load_custom_font(period_config, default_size=8) @@ -296,6 +387,9 @@ def render_game_card( Returns: PIL Image of the rendered game card """ + if self._adaptive: + return self._render_game_card_adaptive(game, game_type) + # Create base image main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) @@ -375,6 +469,346 @@ def render_game_card( main_img = Image.alpha_composite(main_img, overlay) return main_img.convert('RGB') + # ------------------------------------------------------------------ + # Adaptive layout path (layout_mode: "adaptive", beta) + # + # Same card content as the classic path, but positions come from + # scoreboard_regions() and fonts/logos scale to the card size. User + # customization is preserved: an explicitly configured font/font_size + # wins over the ladder, and customization.layout. x/y offsets + # translate the computed regions as a final step. + # ------------------------------------------------------------------ + + # fonts-dict key -> customization element key (for user-font detection) + _FONT_ELEMENT_KEYS = { + "score": "score_text", + "time": "period_text", + "team": "team_name", + "status": "status_text", + "detail": "detail_text", + "rank": "rank_text", + } + + # Per-element (font, size) the local loader falls back to when a config + # key is absent — mirrors the _load_custom_font call sites exactly + # (note status: PressStart, the loader's parameter default, even though + # the schema declares 4x6 — a long-standing quirk kept for byte-identical + # bare-config rendering). + _LOADER_DEFAULTS = { + 'score': ('PressStart2P-Regular.ttf', 10), + 'time': ('PressStart2P-Regular.ttf', 8), + 'team': ('PressStart2P-Regular.ttf', 8), + 'status': ('PressStart2P-Regular.ttf', 6), + 'detail': ('4x6-font.ttf', 6), + 'rank': ('PressStart2P-Regular.ttf', 10), + } + + # (font filename, size) the config_schema.json declares as each + # element's default — matching these, not merely a key being *present*, + # is what "user set it" has to mean, because the web UI's save flow + # (schema_manager.merge_with_defaults) writes the FULL schema default + # object into config.json on every save, for every plugin, whether or + # not the user touched that section. Only used on older cores — with + # src.element_style available, the resolver reads the schema file itself. + _CLASSIC_FONT_DEFAULTS = { + 'score': ('PressStart2P-Regular.ttf', 10), + 'time': ('PressStart2P-Regular.ttf', 8), + 'team': ('PressStart2P-Regular.ttf', 8), + 'status': ('4x6-font.ttf', 6), + 'detail': ('4x6-font.ttf', 6), + 'rank': ('PressStart2P-Regular.ttf', 10), + } + + def _user_font_set(self, font_key: str) -> bool: + """True when the user's configured font/font_size for this element + genuinely differs from the schema default — adaptive mode must + respect a real override, but not a schema default that merely + happens to be present in a saved config.""" + if self._style_resolver is not None: + element = self._FONT_ELEMENT_KEYS.get(font_key, font_key) + loader_font, loader_size = self._LOADER_DEFAULTS.get( + font_key, ('PressStart2P-Regular.ttf', 8)) + return self._style_resolver.style( + element, classic_font=loader_font, + classic_size=loader_size).user_forced + element = self._FONT_ELEMENT_KEYS.get(font_key, font_key) + element_config = self.config.get('customization', {}).get(element, {}) + default_font, default_size = self._CLASSIC_FONT_DEFAULTS.get(font_key, (None, None)) + configured_font = element_config.get('font') + configured_size = element_config.get('font_size') + font_differs = configured_font is not None and configured_font != default_font + try: + size_differs = configured_size is not None and int(configured_size) != default_size + except (TypeError, ValueError): + size_differs = False + return font_differs or size_differs + + def _region_for(self, region: "Region", element: str) -> "Region": + """Apply the user's customization.layout. x/y offsets as a + final translation of the computed region.""" + if self._style_resolver is not None: + dx, dy = self._style_resolver.offset(element) + else: + offsets = self.config.get('customization', {}).get('layout', {}).get(element, {}) + try: + dx = int(offsets.get('x_offset', 0)) + dy = int(offsets.get('y_offset', 0)) + except (TypeError, ValueError): + dx = dy = 0 + return region.offset(dx, dy) if (dx or dy) else region + + def _fit_element(self, font_key: str, text: str, region: "Region", + ladder) -> "FitResult": + """Crisp font sized proportionally to this element's classic fixed + size (self.fonts[font_key]'s configured size — the default, e.g. + score=10/time=8/detail=6, unless the user overrode it) — unless the + user forced a font, in which case use it as-is. + + Proportional, not "largest that fits": on a big panel the score's + region has generous room, but the logos next to it scale by a fixed + geometry factor (px()) — maximizing the score independently would + let it balloon out of proportion (even overlapping the logos) well + past what fits the classic composition, even though the pick is + individually "correct" for its own box. + + Scaled by HEIGHT alone (not LayoutContext's conservative + min(width_ratio, height_ratio)) to match how the card's own logos + already scale (``logo_slot = min(height, width // 2)``) — a panel + that only grows taller (e.g. 128x32 -> 128x64) should still grow + the text, the same way it already grows the logos, or the text + reads as under-scaled next to them. + """ + if self._user_font_set(font_key): + font = self.fonts[font_key] + width, height, baseline, y_offset = measure_ink(text, font) + return FitResult(font, "user", getattr(font, 'size', 0), text, + width, height, baseline, y_offset, + fits=(width <= region.w and height <= region.h), + line_height=height) + base_size_px = getattr(self.fonts[font_key], 'size', 10) + height_scale = self.display_height / self._ctx.design_size[1] + return self._ctx.fit_text_proportional(text, region, base_size_px=base_size_px, + ladder=ladder, scale=height_scale) + + def _draw_fit_outline(self, draw: ImageDraw.Draw, fit: "FitResult", + region: "Region", fill: Tuple[int, int, int] = (255, 255, 255), + align: str = "center", valign: str = "center") -> Tuple[int, int]: + """Draw a fitted text ink-aligned in a region, with the classic black + outline. Returns the ink's top-left position.""" + x, y = region.align_xy(fit.width, fit.height, align, valign) + self._draw_text_with_outline(draw, fit.text, (x, y - fit.y_offset), + fit.font, fill=fill) + return (x, y) + + def _load_raw_logo(self, team_abbrev: str, logo_path) -> Optional[Image.Image]: + """Load a logo unresized (the adaptive path fits it per region; + results are cached per size by the LayoutContext).""" + cached = self._raw_logo_cache.get(team_abbrev) + if cached is not None: + return cached + try: + if logo_path and os.path.exists(logo_path): + logo = Image.open(logo_path) + if logo.mode != "RGBA": + logo = logo.convert("RGBA") + self._raw_logo_cache[team_abbrev] = logo + return logo + except Exception as e: + self.logger.error(f"Error loading logo for {team_abbrev}: {e}") + return None + + def _render_game_card_adaptive(self, game: Dict[str, Any], + game_type: str) -> Image.Image: + width, height = self.display_width, self.display_height + main_img = Image.new('RGBA', (width, height), (0, 0, 0, 255)) + overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + draw_overlay = ImageDraw.Draw(overlay) + + regs = scoreboard_regions(Region(0, 0, width, height), ctx=self._ctx) + + away_raw = self._load_raw_logo(game.get("away_abbr", ""), game.get("away_logo_path")) + home_raw = self._load_raw_logo(game.get("home_abbr", ""), game.get("home_logo_path")) + if not away_raw or not home_raw: + draw = ImageDraw.Draw(main_img) + self._draw_text_with_outline( + draw, + f"{game.get('away_abbr', '?')}@{game.get('home_abbr', '?')}", + (5, 5), + self.fonts['status'] + ) + return main_img.convert('RGB') + + for raw, slot, element, abbr in ( + (away_raw, regs.away_slot, 'away_logo', game.get("away_abbr", "")), + (home_raw, regs.home_slot, 'home_logo', game.get("home_abbr", "")), + ): + slot = self._region_for(slot, element) + ifit = self._ctx.fit_image(raw, slot, mode="fill_height", + crop_to_ink=True, + cache_key=f"logo:{abbr}") + if not ifit.is_empty: + x, y = slot.align_xy(ifit.width, ifit.height) + main_img.paste(ifit.image, (x, y), ifit.image) + + # Score — largest crisp font that fits the center region + score_text = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" + score_region = self._region_for(regs.score_area, 'score') + score_fit = self._fit_element('score', score_text, score_region, + ADAPTIVE_LADDER_HEADLINE) + self._draw_fit_outline(draw_overlay, score_fit, score_region) + + if game_type == "live": + self._draw_live_status_adaptive(draw_overlay, game, regs) + elif game_type == "recent": + top = game.get("period_text") or "Final" + fit = self._fit_element('time', top, + self._region_for(regs.status_band, 'status_text'), + ADAPTIVE_LADDER_TEXT) + self._draw_fit_outline(draw_overlay, fit, + self._region_for(regs.status_band, 'status_text')) + self._draw_bottom_center_adaptive(draw_overlay, game.get("game_date", ""), + regs, 'date') + elif game_type == "upcoming": + game_time = game.get("game_time", "") + if game_time: + region = self._region_for(regs.status_band, 'time') + fit = self._fit_element('time', game_time, region, ADAPTIVE_LADDER_TEXT) + self._draw_fit_outline(draw_overlay, fit, region) + self._draw_bottom_center_adaptive(draw_overlay, game.get("game_date", ""), + regs, 'date') + + game_league = game.get("league", "nfl") + if self._get_display_option(game_league, "show_odds") and game.get('odds'): + self._draw_dynamic_odds(draw_overlay, game['odds']) + show_records = self._get_display_option(game_league, "show_records") + show_ranking = self._get_display_option(game_league, "show_ranking") + if show_records or show_ranking: + self._draw_records_adaptive(draw_overlay, game, regs, + show_records, show_ranking) + + main_img = Image.alpha_composite(main_img, overlay) + return main_img.convert('RGB') + + def _draw_bottom_center_adaptive(self, draw: ImageDraw.Draw, text: str, + regs, element: str, + fill: Tuple[int, int, int] = (255, 255, 255)): + """Fit text into the bottom detail band. Returns (x, y, fit) or None.""" + if not text: + return None + region = self._region_for(regs.detail_band, element) + fit = self._fit_element('detail', text, region, ADAPTIVE_LADDER_TEXT) + x, y = self._draw_fit_outline(draw, fit, region, fill=fill) + return (x, y, fit) + + def _draw_live_status_adaptive(self, draw: ImageDraw.Draw, game: Dict, + regs) -> None: + # Period/quarter + clock in the top status band + period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip() + if game.get("is_halftime"): + period_clock_text = "Halftime" + elif game.get("is_period_break"): + period_clock_text = game.get("status_text", "Period Break") + if period_clock_text: + region = self._region_for(regs.status_band, 'status_text') + fit = self._fit_element('time', period_clock_text, region, + ADAPTIVE_LADDER_TEXT) + self._draw_fit_outline(draw, fit, region) + + # Scoring event or down & distance in the bottom detail band — + # semantic colors preserved from the classic layout + scoring_event = game.get("scoring_event", "") + down_distance = game.get("down_distance_text", "") + if self.display_width > 128: + down_distance = game.get("down_distance_text_long", down_distance) + + if scoring_event and game.get("is_live"): + color = { + "TOUCHDOWN": (255, 215, 0), + "FIELD GOAL": (0, 255, 0), + "PAT": (255, 165, 0), + }.get(scoring_event, (255, 255, 255)) + self._draw_bottom_center_adaptive(draw, scoring_event, regs, + 'down_distance', fill=color) + elif down_distance and game.get("is_live"): + down_color = (255, 0, 0) if game.get("is_redzone", False) else (200, 200, 0) + drawn = self._draw_bottom_center_adaptive(draw, down_distance, regs, + 'down_distance', fill=down_color) + if drawn: + self._draw_possession_adaptive(draw, game, *drawn) + + self._draw_timeouts_adaptive(draw, game, regs) + + def _draw_possession_adaptive(self, draw: ImageDraw.Draw, game: Dict, + dd_x: int, dd_y: int, fit) -> None: + """Possession football anchored to the fitted down&distance text, + radii scaled with the card.""" + possession = game.get("possession_indicator") + if not possession: + return + ball_radius_x = self._ctx.px(3, minimum=2) + ball_radius_y = self._ctx.px(2, minimum=1) + padding = self._ctx.px(3, minimum=2) + ball_y_center = dd_y + fit.height // 2 + if possession == "away": + ball_x_center = dd_x - padding - ball_radius_x + elif possession == "home": + ball_x_center = dd_x + fit.width + padding + ball_radius_x + else: + return + if ball_x_center > 0: + draw.ellipse( + (ball_x_center - ball_radius_x, ball_y_center - ball_radius_y, + ball_x_center + ball_radius_x, ball_y_center + ball_radius_y), + fill=(139, 69, 19), outline=(0, 0, 0) + ) + draw.line( + (ball_x_center - 1, ball_y_center, ball_x_center + 1, ball_y_center), + fill=(255, 255, 255), width=1 + ) + + def _draw_timeouts_adaptive(self, draw: ImageDraw.Draw, game: Dict, + regs) -> None: + """Timeout bars in the bottom corners, sized with the card.""" + bar_w = self._ctx.px(4, minimum=3) + bar_h = self._ctx.px(2, minimum=2) + spacing = self._ctx.px(1, minimum=1) + margin = self._ctx.px(2, minimum=2) + timeout_y = self.display_height - bar_h - 1 + + left = self._region_for(regs.bottom_left, 'timeouts') + away_timeouts = game.get("away_timeouts", 0) + for i in range(3): + to_x = left.x + margin + i * (bar_w + spacing) + color = (255, 255, 255) if i < away_timeouts else (80, 80, 80) + draw.rectangle([to_x, timeout_y, to_x + bar_w, timeout_y + bar_h], + fill=color, outline=(0, 0, 0)) + + right = self._region_for(regs.bottom_right, 'timeouts') + home_timeouts = game.get("home_timeouts", 0) + for i in range(3): + to_x = right.right - margin - bar_w - (2 - i) * (bar_w + spacing) + color = (255, 255, 255) if i < home_timeouts else (80, 80, 80) + draw.rectangle([to_x, timeout_y, to_x + bar_w, timeout_y + bar_h], + fill=color, outline=(0, 0, 0)) + + def _draw_records_adaptive(self, draw: ImageDraw.Draw, game: Dict, regs, + show_records: bool, show_ranking: bool) -> None: + """Records/rankings in the bottom corners, ladder-fitted.""" + for abbr_key, record_key, region, element, align in ( + ('away_abbr', 'away_record', regs.bottom_left, 'records', 'left'), + ('home_abbr', 'home_record', regs.bottom_right, 'records', 'right'), + ): + abbr = game.get(abbr_key, '') + if not abbr: + continue + text = self._get_team_display_text(abbr, game.get(record_key, ''), + show_records, show_ranking) + if not text: + continue + region = self._region_for(region, element).inset(2, 0) + fit = self._fit_element('detail', text, region, ADAPTIVE_LADDER_TEXT) + self._draw_fit_outline(draw, fit, region, align=align, valign="bottom") + def _draw_live_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: """Draw status elements for a live game.""" # Period/Quarter and Clock (Top center) diff --git a/plugins/football-scoreboard/manager.py b/plugins/football-scoreboard/manager.py index 52d30e4..3bdd575 100644 --- a/plugins/football-scoreboard/manager.py +++ b/plugins/football-scoreboard/manager.py @@ -694,6 +694,8 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: "timezone": timezone_str, "display": display_config, "customization": customization_config, + # Adaptive layout (beta): shared across leagues like customization + "layout_mode": self.config.get("layout_mode", "classic"), } ) diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index f38b91e..93a0cf1 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.7.0", + "version": "2.8.2", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,18 @@ "ncaa_fb_live" ], "versions": [ + { + "released": "2026-07-10", + "version": "2.8.1", + "ledmatrix_min_version": "2.0.0", + "notes": "Fix adaptive mode (beta): (1) two ladder rungs (press_start@10/12px, 5by7.regular at any size) rendered visibly antialiased/blurry text -- both dropped, 4x6-font corrected from 6px to 7px (its actual crisp size); (2) score/status/detail text was sized to maximize within its own region, which on large panels let the score balloon large enough to visually overlap the team logos -- now sized proportionally to the panel scale (matching classic's fixed-size look, just crisper and bigger) via the new core fit_text_proportional()." + }, + { + "released": "2026-07-10", + "version": "2.8.0", + "ledmatrix_min_version": "2.0.0", + "notes": "Adaptive layout (beta, opt-in): set layout_mode: \"adaptive\" to scale fonts/logos/regions to any panel size. Default stays \"classic\" \u2014 rendering is unchanged unless you opt in; switch back to classic in config to revert without reinstalling. Adaptive mode also applies customization.layout x/y offsets in scroll mode (classic scroll never did). User-configured fonts win over adaptive sizing." + }, { "released": "2026-07-08", "version": "2.7.0", @@ -269,7 +281,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-08", + "last_updated": "2026-07-10", "stars": 0, "downloads": 0, "verified": true, @@ -278,5 +290,11 @@ "entry_point": "manager.py", "compatible_versions": [ ">=2.0.0" - ] + ], + "display": { + "design_size": { + "width": 128, + "height": 32 + } + } } diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index 1c56e3f..ffc2057 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -19,6 +19,18 @@ from logo_downloader import LogoDownloader, download_missing_logo from base_odds_manager import BaseOddsManager from data_sources import ESPNDataSource +# Imported at module load time on purpose (see the monorepo module-naming +# rules): a deferred bare-name import could bind another plugin's +# game_renderer after namespace isolation. +from game_renderer import GameRenderer + +# Shared element-style resolver (newer cores); older cores use the local +# offset-reading code below. +try: + from src.element_style import ElementStyleResolver, defaults_from_schema_file + STYLE_AVAILABLE = True +except ImportError: + STYLE_AVAILABLE = False class SportsCore(ABC): @@ -187,6 +199,47 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: f"Error in base _draw_scorebug_layout: {e}", exc_info=True ) + def _adaptive_scorebug(self, game: Dict, game_type: str, + force_clear: bool = False) -> bool: + """Render the scorebug via the adaptive GameRenderer when + layout_mode is "adaptive" (beta, opt-in). + + Returns True when the frame was rendered and displayed; False means + the caller should draw its classic layout (either classic mode or an + older core without the adaptive layout system). + """ + if self.config.get('layout_mode', 'classic') != 'adaptive': + return False + try: + display_width = (self.display_manager.matrix.width + if getattr(self.display_manager, 'matrix', None) + else self.display_width) + display_height = (self.display_manager.matrix.height + if getattr(self.display_manager, 'matrix', None) + else self.display_height) + + renderer = getattr(self, '_adaptive_renderer', None) + if renderer is None or (renderer.display_width, renderer.display_height) \ + != (display_width, display_height): + renderer = GameRenderer(display_width, display_height, + self.config, custom_logger=self.logger) + self._adaptive_renderer = renderer + if not renderer._adaptive: + # Core without adaptive support: let the classic layout run + return False + if getattr(self, '_team_rankings_cache', None): + renderer.set_rankings_cache(self._team_rankings_cache) + + if force_clear: + self.display_manager.clear() + self.display_manager.image = renderer.render_game_card(game, game_type) + self.display_manager.update_display() + return True + except Exception as e: + self.logger.error(f"Adaptive scorebug failed, using classic layout: {e}", + exc_info=True) + return False + def display(self, force_clear: bool = False) -> bool: """Render the current game. Returns False when nothing can be shown.""" if not self.is_enabled: # Check if module is enabled @@ -286,11 +339,22 @@ def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: Returns: Offset value from config or default (always returns int) """ + if STYLE_AVAILABLE: + # Shared resolver (rebuilt if the config dict was swapped out, + # matching the old code's read-config-on-every-call semantics) + resolver = getattr(self, '_style_resolver_cached', None) + if resolver is None or resolver._config is not self.config: + schema_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') + resolver = ElementStyleResolver( + self.config, defaults_from_schema_file(schema_path)) + self._style_resolver_cached = resolver + return resolver.offset_value(element, axis, default) try: layout_config = self.config.get('customization', {}).get('layout', {}) element_config = layout_config.get(element, {}) offset_value = element_config.get(axis, default) - + # Ensure we return an integer (handle float/string from config) if isinstance(offset_value, (int, float)): return int(offset_value) @@ -1220,6 +1284,10 @@ def update(self): def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: """Draw the layout for an upcoming NCAA FB game.""" # Updated docstring try: + # Adaptive layout (beta, opt-in) — classic below untouched when declined + if self._adaptive_scorebug(game, "upcoming", force_clear): + return + # Clear the display first to ensure full coverage (like weather plugin does) if force_clear: self.display_manager.clear() @@ -1789,6 +1857,10 @@ def update(self): def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: """Draw the layout for a recently completed NCAA FB game.""" # Updated docstring try: + # Adaptive layout (beta, opt-in) — classic below untouched when declined + if self._adaptive_scorebug(game, "recent", force_clear): + return + # Clear the display first to ensure full coverage (like weather plugin does) if force_clear: self.display_manager.clear() diff --git a/plugins/football-scoreboard/test/golden-adaptive/128x32/live.png b/plugins/football-scoreboard/test/golden-adaptive/128x32/live.png new file mode 100644 index 0000000..375d71f Binary files /dev/null and b/plugins/football-scoreboard/test/golden-adaptive/128x32/live.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/128x32/recent.png b/plugins/football-scoreboard/test/golden-adaptive/128x32/recent.png new file mode 100644 index 0000000..98caf05 Binary files /dev/null and b/plugins/football-scoreboard/test/golden-adaptive/128x32/recent.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/128x32/upcoming.png b/plugins/football-scoreboard/test/golden-adaptive/128x32/upcoming.png new file mode 100644 index 0000000..77987ed Binary files /dev/null and b/plugins/football-scoreboard/test/golden-adaptive/128x32/upcoming.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/128x64/live.png b/plugins/football-scoreboard/test/golden-adaptive/128x64/live.png new file mode 100644 index 0000000..e71f3be Binary files /dev/null and b/plugins/football-scoreboard/test/golden-adaptive/128x64/live.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/128x64/recent.png b/plugins/football-scoreboard/test/golden-adaptive/128x64/recent.png new file mode 100644 index 0000000..54b4df6 Binary files /dev/null and b/plugins/football-scoreboard/test/golden-adaptive/128x64/recent.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/128x64/upcoming.png b/plugins/football-scoreboard/test/golden-adaptive/128x64/upcoming.png new file mode 100644 index 0000000..57fc4d4 Binary files /dev/null and b/plugins/football-scoreboard/test/golden-adaptive/128x64/upcoming.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/256x128/live.png b/plugins/football-scoreboard/test/golden-adaptive/256x128/live.png new file mode 100644 index 0000000..387dcdb Binary files /dev/null and b/plugins/football-scoreboard/test/golden-adaptive/256x128/live.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/256x128/recent.png b/plugins/football-scoreboard/test/golden-adaptive/256x128/recent.png new file mode 100644 index 0000000..7f1f957 Binary files /dev/null and b/plugins/football-scoreboard/test/golden-adaptive/256x128/recent.png differ diff --git a/plugins/football-scoreboard/test/golden-adaptive/256x128/upcoming.png b/plugins/football-scoreboard/test/golden-adaptive/256x128/upcoming.png new file mode 100644 index 0000000..d461948 Binary files /dev/null and b/plugins/football-scoreboard/test/golden-adaptive/256x128/upcoming.png differ diff --git a/plugins/football-scoreboard/test_adaptive_layout_mode.py b/plugins/football-scoreboard/test_adaptive_layout_mode.py new file mode 100644 index 0000000..36a8506 --- /dev/null +++ b/plugins/football-scoreboard/test_adaptive_layout_mode.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +""" +Regression tests for the opt-in adaptive layout mode (layout_mode: "adaptive"). + +Guards the two promises the migration made: +1. CLASSIC IS UNTOUCHED — with the default config (layout_mode absent or + "classic") the renderer takes the classic path and renders byte-identically + to a renderer that has never heard of adaptive layout. +2. ADAPTIVE SCALES + RESPECTS CUSTOMIZATION — adaptive renders fill more of a + big panel than classic, user-configured fonts win over the ladder, and + customization.layout x/y offsets translate elements. + +Golden images live in test/golden-adaptive//.png. Regenerate after +an intentional visual change with UPDATE_GOLDEN=1. + +Run from the core LEDMatrix tree (needs src.* and assets/fonts), e.g.: + cd /path/to/LEDMatrix + python -m pytest /path/to/football-scoreboard/test_adaptive_layout_mode.py -q +""" + +import os +import sys + +import pytest +from PIL import Image, ImageChops + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +GOLDEN_ADAPTIVE = os.path.join(PLUGIN_DIR, "test", "golden-adaptive") +LOGOS = os.path.join(PLUGIN_DIR, "assets", "sports", "nfl_logos") + +# test_score_celebration.py's standalone-run shim replaces sys.modules["src"] +# with a fake package, which clobbers src.adaptive_layout when both files +# share a pytest session. Evict the stub (the real core package is importable +# here — this file runs from the core tree), then import the real module so +# the shim won't reinstall itself. +_src = sys.modules.get("src") +if _src is not None and not hasattr(_src, "__path__"): + for _k in [k for k in list(sys.modules) if k == "src" or k.startswith("src.")]: + del sys.modules[_k] +import src.logo_downloader # noqa: E402,F401 + +from game_renderer import ADAPTIVE_AVAILABLE, GameRenderer # noqa: E402 + +pytestmark = pytest.mark.skipif( + not ADAPTIVE_AVAILABLE, + reason="core without src.adaptive_layout — adaptive mode falls back to classic", +) + +SIZES = [(128, 32), (128, 64), (256, 128)] + + +def _game(**overrides): + game = { + "home_id": "1", "home_abbr": "KC", + "home_logo_path": os.path.join(LOGOS, "KC.png"), + "away_id": "2", "away_abbr": "GB", + "away_logo_path": os.path.join(LOGOS, "GB.png"), + "home_score": "21", "away_score": "17", + "period_text": "Q3", "clock": "8:42", + "is_live": True, + "down_distance_text": "3rd & 7", + "down_distance_text_long": "3rd & 7 at KC 35", + "possession_indicator": "home", + "home_timeouts": 2, "away_timeouts": 3, + "league": "nfl", + } + game.update(overrides) + return game + + +def _renderer(width, height, config=None): + return GameRenderer(width, height, config or {}) + + +def _ink_ratio(img): + lit = img.convert("L").point(lambda p: 255 if p > 16 else 0) + return sum(1 for p in lit.getdata() if p) / (img.width * img.height) + + +class TestClassicUntouched: + @pytest.mark.parametrize("config", [{}, {"layout_mode": "classic"}]) + def test_default_takes_classic_path(self, config): + r = _renderer(128, 32, config) + assert not r._adaptive + + def test_classic_render_byte_identical(self): + """A default renderer must produce the same pixels as one explicitly + set to classic — proving the adaptive changes are inert by default.""" + game = _game() + for w, h in SIZES: + baseline = _renderer(w, h, {}).render_game_card(game, "live") + classic = _renderer(w, h, {"layout_mode": "classic"}).render_game_card(game, "live") + assert ImageChops.difference(baseline, classic).getbbox() is None + + +class TestAdaptiveMode: + def test_adaptive_flag(self): + assert _renderer(128, 32, {"layout_mode": "adaptive"})._adaptive + + def test_adaptive_text_scales_up_on_big_panels(self): + """Classic renders a fixed 10px score on every panel; adaptive scales + it proportionally to the panel HEIGHT (target = 10px * height/32, + nearest crisp rung at or below that) rather than maximizing to + whatever the region merely allows — maximizing let the score + balloon large enough to visually overlap/obscure the team logos + next to it. Height (not LayoutContext's conservative min-of-both- + axes scale) is the reference because the card's own logo_slot + already tracks height alone, and text should match that. 256x128 + is 4x the design height, so target=40 -> capped at the largest + crisp rung, 32 (which also happens to match the region max here — + confirming it's a deliberate height-proportional pick, not an + accidental one, matters more on a panel like 128x64 where they + diverge — see the golden images).""" + from src.adaptive_layout import Region, scoreboard_regions + from game_renderer import ADAPTIVE_LADDER_HEADLINE + + r = _renderer(256, 128, {"layout_mode": "adaptive"}) + regs = scoreboard_regions(Region(0, 0, 256, 128), ctx=r._ctx) + fit = r._fit_element('score', "17-21", regs.score_area, + ADAPTIVE_LADDER_HEADLINE) + assert fit.size_px == 32 + assert fit.family == "press_start" # still a crisp rung + # 256x128 is exactly 2:1 aspect -- the shared core center-reserve + # (scoreboard_regions' min_center_fraction) must have kept the + # logos from claiming the entire width, or the 32px score would + # have nowhere to sit without overlapping them. + assert regs.center_col.w > 0 + + game = _game() + adaptive = r.render_game_card(game, "live") + assert adaptive.size == (256, 128) # rendered without error + + @pytest.mark.parametrize("w,h", [(96, 48), (128, 64), (256, 128), (64, 32)]) + def test_no_zero_width_center_column_at_2to1_aspect(self, w, h): + """These sizes are all <= 2:1 aspect -- the shape where the raw + logo_slot=min(h, w//2) formula alone claims the entire card width + for logos and leaves the score with nowhere to render without + sitting directly on top of them (the bug originally reported: 'the + team logos are too big ... to where the score is overlapping the + logo'). Fixed once in the shared core scoreboard_regions() center + reserve, not here -- this just proves football picks it up.""" + from src.adaptive_layout import Region, scoreboard_regions + + r = _renderer(w, h, {"layout_mode": "adaptive"}) + regs = scoreboard_regions(Region(0, 0, w, h), ctx=r._ctx) + assert regs.center_col.w > 0 + assert regs.logo_slot < min(h, w // 2) # the reserve actually capped it + + def test_score_scales_with_height_not_width(self): + """128x64 (height doubled, width unchanged from the 128x32 design) + must still grow the score — logo_slot = min(height, width // 2) + already doubles the logos here, so text sized by the conservative + min(width_ratio, height_ratio) (which stays 1.0 since width didn't + grow) would look under-scaled next to them.""" + from src.adaptive_layout import Region, scoreboard_regions + from game_renderer import ADAPTIVE_LADDER_HEADLINE + + r = _renderer(128, 64, {"layout_mode": "adaptive"}) + assert r._ctx.scale == 1.0 # the conservative default would pick 8px + regs = scoreboard_regions(Region(0, 0, 128, 64), ctx=r._ctx) + fit = r._fit_element('score', "17-21", regs.score_area, + ADAPTIVE_LADDER_HEADLINE) + assert fit.size_px == 16 # height/32 = 2 -> target 20 -> nearest rung 16 + + def test_user_font_wins_over_ladder(self): + """An explicitly configured score font must be used verbatim — 14px + genuinely differs from the classic default (10px), so this must + register as a real user override.""" + cfg = {"layout_mode": "adaptive", + "customization": {"score_text": {"font_size": 14}}} + r = _renderer(256, 128, cfg) + from src.adaptive_layout import Region + fit = r._fit_element("score", "17-21", Region(0, 0, 100, 100), + ladder=None) + assert fit.family == "user" + # ladder on this panel would have picked something far larger than 14px + assert fit.height <= 16 + + def test_schema_default_is_not_mistaken_for_a_user_override(self): + """The web UI's save flow (schema_manager.merge_with_defaults) + writes the FULL schema default object into config.json on every + save — for every plugin, whether or not the user touched that + section. A config carrying only the schema's own declared defaults + must NOT be treated as a forced override, or adaptive sizing would + never engage on any config that's ever been saved once.""" + cfg = {"layout_mode": "adaptive", "customization": { + "score_text": {"font": "PressStart2P-Regular.ttf", "font_size": 10}, + "period_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8}, + "team_name": {"font": "PressStart2P-Regular.ttf", "font_size": 8}, + "status_text": {"font": "4x6-font.ttf", "font_size": 6}, + "detail_text": {"font": "4x6-font.ttf", "font_size": 6}, + "rank_text": {"font": "PressStart2P-Regular.ttf", "font_size": 10}, + }} + r = _renderer(256, 128, cfg) + for key in ("score", "time", "team", "status", "detail", "rank"): + assert not r._user_font_set(key), f"{key} incorrectly flagged as user-overridden" + + def test_user_offsets_translate_elements(self): + """customization.layout offsets must shift the rendered element.""" + game = _game(is_live=False, period_text="Final") + base_cfg = {"layout_mode": "adaptive"} + moved_cfg = {"layout_mode": "adaptive", + "customization": {"layout": {"score": {"x_offset": 6}}}} + base = _renderer(128, 32, base_cfg).render_game_card(game, "recent") + moved = _renderer(128, 32, moved_cfg).render_game_card(game, "recent") + assert ImageChops.difference(base, moved).getbbox() is not None + + def test_semantic_colors_preserved(self): + """Scoring-event gold must survive the adaptive path.""" + game = _game(scoring_event="TOUCHDOWN") + img = _renderer(128, 64, {"layout_mode": "adaptive"}).render_game_card(game, "live") + colors = {img.getpixel((x, y)) for x in range(img.width) + for y in range(img.height)} + assert (255, 215, 0) in colors + + +class TestAdaptiveGoldens: + """Visual-drift net for the adaptive path (classic is covered by the + celebration goldens in test/golden/).""" + + @pytest.mark.parametrize("w,h", SIZES) + @pytest.mark.parametrize("game_type,game_kwargs", [ + ("live", {}), + ("recent", {"is_live": False, "period_text": "Final", + "game_date": "10/12"}), + ("upcoming", {"is_live": False, "period_text": "", + "game_time": "7:30PM", "game_date": "10/12", + "home_score": "0", "away_score": "0"}), + ]) + def test_golden(self, w, h, game_type, game_kwargs): + img = _renderer(w, h, {"layout_mode": "adaptive"}).render_game_card( + _game(**game_kwargs), game_type) + path = os.path.join(GOLDEN_ADAPTIVE, f"{w}x{h}", f"{game_type}.png") + if os.environ.get("UPDATE_GOLDEN") == "1": + os.makedirs(os.path.dirname(path), exist_ok=True) + img.save(path, format="PNG") + return + assert os.path.exists(path), f"missing golden {path} (run with UPDATE_GOLDEN=1)" + with Image.open(path) as golden: + assert ImageChops.difference( + img.convert("RGB"), golden.convert("RGB")).getbbox() is None, \ + f"adaptive render drifted from {path}" + + +class TestElementStyleResolver: + """The shared core resolver (src.element_style) must actually be wired + in — a silently failing import would fall back to the local loader and + quietly reintroduce the per-plugin drift this migration removed.""" + + def test_resolver_is_active(self): + from game_renderer import STYLE_AVAILABLE + assert STYLE_AVAILABLE, "src.element_style import failed on this core" + r = _renderer(128, 32, {}) + assert r._style_resolver is not None + + def test_resolver_reads_schema_defaults(self): + """The resolver's override reference must come from this plugin's + own config_schema.json (works in harness/dev-server contexts where + no schema manager exists) — a schema-populated saved config is not + a user override, a genuine change is.""" + schema_populated = {"customization": {"status_text": { + "font": "4x6-font.ttf", "font_size": 6}}} + r = _renderer(128, 32, {"layout_mode": "adaptive", **schema_populated}) + assert not r._user_font_set("status") + r2 = _renderer(128, 32, {"layout_mode": "adaptive", "customization": { + "status_text": {"font": "4x6-font.ttf", "font_size": 8}}}) + assert r2._user_font_set("status") + + +class TestLadderCrispness: + """Every rung in the adaptive ladders must render with zero antialiasing. + + PIL antialiases TTF outlines by default; PressStart2P only rasterizes + crisply at exact multiples of its 8px design grid, and not every + 'pixel-style' font is crisp at any size at all. An earlier version of + these ladders included press_start@12/10 (not multiples of 8, added to + match classic's fixed 10px score) and '5by7.regular'@8 (never crisp at + any tested size) — both shipped visibly blurry text on small panels. + """ + + def test_headline_ladder_is_crisp(self): + from game_renderer import ADAPTIVE_LADDER_HEADLINE + from src.adaptive_layout import measure_font_crispness + from src.font_manager import FontManager + + fm = FontManager({}) + offenders = [] + for step in ADAPTIVE_LADDER_HEADLINE: + font = fm.get_font(step.family, step.size_px) + c = measure_font_crispness(font, "17-21") + if c > 0.02: + offenders.append(f"{step.family}@{step.size_px}px: {c:.1%} antialiased") + assert not offenders, "Blurry rung(s):\n " + "\n ".join(offenders) + + def test_text_ladder_is_crisp(self): + from game_renderer import ADAPTIVE_LADDER_TEXT + from src.adaptive_layout import measure_font_crispness + from src.font_manager import FontManager + + fm = FontManager({}) + offenders = [] + for step in ADAPTIVE_LADDER_TEXT: + font = fm.get_font(step.family, step.size_px) + c = measure_font_crispness(font, "3rd & 7") + if c > 0.02: + offenders.append(f"{step.family}@{step.size_px}px: {c:.1%} antialiased") + assert not offenders, "Blurry rung(s):\n " + "\n ".join(offenders) + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"]))