From 7e350abe3acd565d2c1bed20f07d4942f23ef915 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 9 Apr 2026 17:57:53 -0400 Subject: [PATCH 1/8] fix(masters-tournament): Vegas scroll block sizing + defensive stale-cache guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three user reports from Discord. ## Report 3 (JScottyR): Vegas scroll cards spanning the full panel On a 64×64 × 5-panel chain (320×64), masters was rendering each player card at the full panel size, so "Vegas scroll" mode showed one giant card at a time instead of a ticker of smaller fixed-size blocks. Every other sports scoreboard in the repo follows a fixed-block convention — football, hockey, and baseball all default `game_card_width=128` regardless of the physical display width, with cards constructed at that explicit size. Fix: - New top-level config key `scroll_card_width` (default 128, min 32, max 256) in config_schema.json with a description referencing the Vegas scroll ticker mode. - `manager.py` reads it as `self._scroll_card_width` in `__init__` (and on hot-reload), then passes `card_width=self._scroll_card_width, card_height=self.display_height` to each `render_*` call inside `get_vegas_content()`. - `render_player_card()`, `render_hole_card()`, and `render_fun_fact()` in `masters_renderer.py` now accept optional `card_width`/`card_height` parameters. When provided, the body uses local `w`/`h`/`cw`/`ch` variables instead of `self.width`/`self.height`, and `is_wide_short` is recomputed per-card (so a 128×64 block on a 320×64 panel is aspect 2.0 and uses the standard vertical-stack layout, while the same panel's full-screen modes still use the two-column wide-short layout for the leaderboard, schedule, etc). - `_render_player_card_wide_short()` takes the same `w`/`h` overrides and parameterizes every `self.width`/`self.height` reference. - `_draw_gradient_bg()` accepts optional `width`/`height` so each render can allocate its own image at the override size. - Enhanced renderer overrides (`render_player_card`, `render_hole_card`) delegate to `super()` when called with explicit card dimensions, since the enhanced round-scores block and left-panel-plus-image layouts are designed for full-panel rendering and don't fit at block sizes. Verified locally at every `(parent, card)` combination in `{(320,64), (384,64), (192,48), (128,64), (64,32)} × {(128,64), (128,48), (80,64), (64,32)}`: every returned image's `.size` exactly matches the override. A simulated `get_vegas_content()` on a 320×64 parent with default `scroll_card_width=128` produces 33 cards (10 players, 18 holes, 5 facts), ALL at exactly 128×64. A 5-card scroll strip concatenated side-by-side renders as a 640×64 image that looks like the football scoreboard ticker pattern — headshot + name + country + score + pos/thru per card, cleanly repeated. ## Reports 1 & 2 (Fish Man): defensive stale-cache guard Reports 1 (`'str' object has no attribute 'tzinfo'`) and 2 (`'<=' not supported between instances of 'float' and 'NoneType'`) are both already fixed in PR #95 via `_rehydrate_meta()` and `_NEVER_EXPIRE` respectively, but neither has merged to `main` yet — a user who pulled the plugin-store update to 2.1.2 after PR #94 merged is running the exact version where both errors fire. Residual risk even after PR #95 / this branch ships: the disk cache file `/var/cache/ledmatrix/masters_tournament_meta.json` persists from the broken 2.1.2 run. The core `CacheManager` logs the `<= None` error and returns None (cache miss) rather than re-raising, but the log noise persists every tick until the file is overwritten by a successful fetch. Added `MastersDataSource._safe_cache_get()` that wraps every `cache_manager.get()` call in a try/except, treats any exception as a cache miss, and logs a single warning per instance (not per tick) pointing at the stale file path. Replaced all 9 `cache_manager.get()` call sites in `masters_data.py` with the safe wrapper. Verified with a stub `BrokenCache` that raises `TypeError('<=' not supported...)` on every `.get()` AND a blocked network: the plugin still initializes without crashing, returns the computed second-Thursday- of-April fallback meta, falls back to mock leaderboard data, and logs the stale-cache warning exactly once. ## Other - Bumps manifest `2.2.4 → 2.2.5`. - test_plugin_standalone.py: 45/45 still passing. Depends on PR #95 being merged first (same branch base). Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins.json | 2 +- plugins/masters-tournament/config_schema.json | 7 + plugins/masters-tournament/manager.py | 30 +++- plugins/masters-tournament/manifest.json | 7 +- plugins/masters-tournament/masters_data.py | 41 ++++- .../masters-tournament/masters_renderer.py | 153 +++++++++++------- .../masters_renderer_enhanced.py | 30 +++- 7 files changed, 194 insertions(+), 76 deletions(-) diff --git a/plugins.json b/plugins.json index 0743846..b5fed91 100644 --- a/plugins.json +++ b/plugins.json @@ -702,7 +702,7 @@ "last_updated": "2026-04-09", "verified": true, "screenshot": "", - "latest_version": "2.2.4" + "latest_version": "2.2.5" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/config_schema.json b/plugins/masters-tournament/config_schema.json index 749783f..f405056 100644 --- a/plugins/masters-tournament/config_schema.json +++ b/plugins/masters-tournament/config_schema.json @@ -44,6 +44,13 @@ "maximum": 300, "description": "Seconds between page advances in paginated modes (leaderboard, champions, tournament stats, schedule, course overview)" }, + "scroll_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 256, + "description": "Width in pixels of each card when rendered in Vegas scroll mode (the horizontally-scrolling ticker). Set lower to pack more cards into a long panel; set higher for more detail per card. Card height always matches the display height." + }, "mock_data": { "type": "boolean", "default": false, diff --git a/plugins/masters-tournament/manager.py b/plugins/masters-tournament/manager.py index e241028..4fff105 100644 --- a/plugins/masters-tournament/manager.py +++ b/plugins/masters-tournament/manager.py @@ -133,6 +133,12 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man self._last_player_card_advance = 0.0 self._player_card_interval = config.get("player_card_duration", 8) + # Vegas scroll mode: fixed card block width. Cards render at + # (scroll_card_width × display_height) regardless of the panel width + # so long chained displays (e.g. 5×64 = 320 wide) scroll smoothly + # instead of showing one player per full-panel card. + self._scroll_card_width = config.get("scroll_card_width", 128) + self.logger.info( f"Masters Tournament plugin initialized: {self.display_width}x{self.display_height}, " f"{len(self.modes)} modes, phase: {self._tournament_phase}" @@ -538,22 +544,37 @@ def _display_course_overview(self, force_clear: bool) -> bool: return self._display_amen_corner(force_clear) def get_vegas_content(self) -> Optional[List[Image.Image]]: - """Return cards for Vegas scroll mode.""" + """Return cards for Vegas scroll mode. + + Cards are rendered at (scroll_card_width × display_height), not the + full panel width — on a long chained display (e.g. 5×64 = 320 wide) + this gives you a smoothly-scrolling ticker of ~128-wide blocks + instead of one full-panel card at a time. Matches the pattern used + by the other sports scoreboard plugins. + """ cards = [] + cw = self._scroll_card_width + ch = self.display_height for player in self._leaderboard_data[:10]: - card = self.renderer.render_player_card(player) + card = self.renderer.render_player_card( + player, card_width=cw, card_height=ch, + ) if card: cards.append(card) for hole in range(1, 19): - card = self.renderer.render_hole_card(hole) + card = self.renderer.render_hole_card( + hole, card_width=cw, card_height=ch, + ) if card: cards.append(card) # Fun facts for i in range(5): - card = self.renderer.render_fun_fact(i) + card = self.renderer.render_fun_fact( + i, card_width=cw, card_height=ch, + ) if card: cards.append(card) @@ -588,6 +609,7 @@ def on_config_change(self, new_config): self._hole_switch_interval = new_config.get("hole_display_duration", 15) self._page_interval = new_config.get("page_display_duration", 15) self._player_card_interval = new_config.get("player_card_duration", 8) + self._scroll_card_width = new_config.get("scroll_card_width", 128) self._last_hole_advance.clear() self._last_page_advance.clear() self.modes = self._build_enabled_modes() diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index c7beb40..3326c8e 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.2.4", + "version": "2.2.5", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,11 @@ "height": 64 }, "versions": [ + { + "version": "2.2.5", + "released": "2026-04-09", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.2.4", "released": "2026-04-09", diff --git a/plugins/masters-tournament/masters_data.py b/plugins/masters-tournament/masters_data.py index 263bb04..9367028 100644 --- a/plugins/masters-tournament/masters_data.py +++ b/plugins/masters-tournament/masters_data.py @@ -48,6 +48,31 @@ def __init__(self, cache_manager, config: Dict[str, Any]): self.config = config self.mock_mode = config.get("mock_data", False) self.logger = logging.getLogger(__name__) + self._cache_warned = False + + def _safe_cache_get(self, key: str, max_age: int) -> Any: + """Wrap cache_manager.get() and treat any exception as a cache miss. + + A stale or malformed cache file from an older plugin version can + cause the core CacheManager to raise (e.g. `<=` comparisons against + None, unpickling errors, etc.). Rather than propagate those errors + and crash the plugin's `__init__`, we swallow them here and return + None so the caller proceeds as if the cache were empty — the next + successful fetch will overwrite the stale file. Log once per + instance so we don't spam the journal every tick. + """ + try: + return self.cache_manager.get(key, max_age=max_age) + except Exception as e: + if not self._cache_warned: + self.logger.warning( + f"Cache read for {key!r} failed ({e!r}); treating as miss. " + f"A stale cache file from an older plugin version may need " + f"to be removed at /var/cache/ledmatrix/{key}.json — " + f"it will be regenerated on the next successful fetch." + ) + self._cache_warned = True + return None # ── Leaderboard ────────────────────────────────────────────── @@ -59,7 +84,7 @@ def fetch_leaderboard(self) -> List[Dict]: cache_key = CACHE_KEY_LEADERBOARD ttl = self._get_cache_ttl() - cached = self.cache_manager.get(cache_key, max_age=ttl) + cached = self._safe_cache_get(cache_key, max_age=ttl) if cached: self.logger.debug("Using cached leaderboard data") return cached @@ -124,7 +149,7 @@ def fetch_tournament_meta(self) -> Optional[Dict]: "is_masters": bool, } """ - cached = self.cache_manager.get(CACHE_KEY_META, max_age=self._get_cache_ttl()) + cached = self._safe_cache_get(CACHE_KEY_META, max_age=self._get_cache_ttl()) if cached: return self._rehydrate_meta(cached) @@ -135,7 +160,7 @@ def fetch_tournament_meta(self) -> Optional[Dict]: except Exception as e: self.logger.warning(f"fetch_tournament_meta: leaderboard fetch failed: {e}") - cached = self.cache_manager.get(CACHE_KEY_META, max_age=_NEVER_EXPIRE) + cached = self._safe_cache_get(CACHE_KEY_META, max_age=_NEVER_EXPIRE) if cached: return self._rehydrate_meta(cached) @@ -289,7 +314,7 @@ def fetch_schedule(self) -> List[Dict]: cache_key = CACHE_KEY_SCHEDULE ttl = self._get_cache_ttl() - cached = self.cache_manager.get(cache_key, max_age=ttl) + cached = self._safe_cache_get(cache_key, max_age=ttl) if cached is not None: return cached @@ -302,7 +327,7 @@ def fetch_schedule(self) -> List[Dict]: self.logger.error(f"fetch_schedule: leaderboard refresh failed: {e}") return self._get_fallback_data(cache_key) - cached = self.cache_manager.get(cache_key, max_age=_NEVER_EXPIRE) + cached = self._safe_cache_get(cache_key, max_age=_NEVER_EXPIRE) if cached is not None: return cached return [] @@ -317,7 +342,7 @@ def fetch_player_details(self, player_id: str) -> Optional[Dict]: cache_key = f"masters_player_{player_id}" ttl = self._get_cache_ttl() - cached = self.cache_manager.get(cache_key, max_age=ttl) + cached = self._safe_cache_get(cache_key, max_age=ttl) if cached: self.logger.debug(f"Using cached player details for {player_id}") return cached @@ -649,7 +674,7 @@ def _get_cache_ttl(self) -> int: Avoids calling fetch_tournament_meta() (which could recurse into fetch_leaderboard) — only reads whatever is already in cache. """ - raw = self.cache_manager.get(CACHE_KEY_META, max_age=_NEVER_EXPIRE) + raw = self._safe_cache_get(CACHE_KEY_META, max_age=_NEVER_EXPIRE) if not raw: return 3600 meta = self._rehydrate_meta(raw) @@ -669,7 +694,7 @@ def _get_cache_ttl(self) -> int: def _get_fallback_data(self, cache_key: str) -> List[Dict]: """Get stale cached data or mock data as fallback.""" - cached = self.cache_manager.get(cache_key, max_age=_NEVER_EXPIRE) + cached = self._safe_cache_get(cache_key, max_age=_NEVER_EXPIRE) if cached: self.logger.warning("Using stale cached data for %s", cache_key) return cached diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index d9bc572..eb5b7e4 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -273,19 +273,23 @@ def _text_height(self, draw, text, font) -> int: bbox = draw.textbbox((0, 0), text, font=font) return bbox[3] - bbox[1] - def _draw_gradient_bg(self, c1, c2, vertical=True) -> Image.Image: - img = Image.new("RGB", (self.width, self.height)) + def _draw_gradient_bg(self, c1, c2, vertical=True, + width: Optional[int] = None, + height: Optional[int] = None) -> Image.Image: + w = width if width is not None else self.width + h = height if height is not None else self.height + img = Image.new("RGB", (w, h)) draw = ImageDraw.Draw(img) - steps = self.height if vertical else self.width + steps = h if vertical else w for i in range(steps): ratio = i / max(steps - 1, 1) r = int(c1[0] + (c2[0] - c1[0]) * ratio) g = int(c1[1] + (c2[1] - c1[1]) * ratio) b = int(c1[2] + (c2[2] - c1[2]) * ratio) if vertical: - draw.line([(0, i), (self.width, i)], fill=(r, g, b)) + draw.line([(0, i), (w, i)], fill=(r, g, b)) else: - draw.line([(i, 0), (i, self.height)], fill=(r, g, b)) + draw.line([(i, 0), (i, h)], fill=(r, g, b)) return img def _draw_header_bar(self, img, draw, title, show_logo=True): @@ -497,16 +501,34 @@ def _draw_leaderboard_row( # PLAYER CARD - Spacious layout # ═══════════════════════════════════════════════════════════ - def render_player_card(self, player: Dict) -> Optional[Image.Image]: - """Render spacious player card with headshot and stats.""" + def render_player_card(self, player: Dict, + card_width: Optional[int] = None, + card_height: Optional[int] = None) -> Optional[Image.Image]: + """Render spacious player card with headshot and stats. + + When card_width/card_height are provided, the card is drawn at those + dimensions instead of the full panel (self.width × self.height). Used + by Vegas scroll mode where each player is a fixed-size block that + scrolls across a long display, not a full-screen card. + """ if not player: return None - img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"]) + w = card_width if card_width is not None else self.width + h = card_height if card_height is not None else self.height + # Recompute wide-short per-card so a 128x64 block on a 320x64 panel + # gets the standard vertical-stack layout (aspect 2.0, not wide-short) + # while the same panel's full-screen modes still use the two-column + # wide-short layout. + aspect = w / max(1, h) + card_is_wide_short = (self.tier == "large") and aspect >= 2.5 + + img = self._draw_gradient_bg(COLORS["masters_dark"], COLORS["masters_green"], + width=w, height=h) draw = ImageDraw.Draw(img) # Gold border - draw.rectangle([(0, 0), (self.width - 1, self.height - 1)], + draw.rectangle([(0, 0), (w - 1, h - 1)], outline=COLORS["masters_yellow"]) raw_name = player.get("player", "Unknown") @@ -515,8 +537,8 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: # full vertical minus padding; name/country/pos use fonts scaled to # height; big score block hugs the right edge. Works for 192x48, # 192x64, 256x64 and anything else aspect >= 2.5. - if self.is_wide_short: - return self._render_player_card_wide_short(img, draw, player, raw_name) + if card_is_wide_short: + return self._render_player_card_wide_short(img, draw, player, raw_name, w, h) x = 4 y = 4 @@ -524,7 +546,7 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: # Headshot on left (sized to available vertical space) headshot_size = self.headshot_size if self.show_headshot: - max_headshot = self.height - (2 * y) - 2 + max_headshot = h - (2 * y) - 2 headshot_size = min(headshot_size, max(16, max_headshot)) headshot = self.logo_loader.get_player_headshot( player.get("player_id", ""), @@ -540,7 +562,7 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: headshot if headshot.mode == "RGBA" else None) tx = x + headshot_size + 6 if self.show_headshot else x - bottom_bound = self.height - 3 + bottom_bound = h - 3 if self.tier == "tiny": name = format_player_name(raw_name, 10) @@ -594,7 +616,7 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: # Green jacket count at bottom (only if there's still vertical room) jacket_count = MULTIPLE_WINNERS.get(ascii_safe(raw_name), 0) if jacket_count > 0 and self.tier != "tiny": - jy = self.height - 10 + jy = h - 10 if jy > y_text + 2: jacket_icon = self.logo_loader.get_green_jacket_icon(size=8) jx = 4 @@ -667,27 +689,29 @@ def _fit_name(self, draw, raw_name: str, max_width: int, text = text[:-1] return font, text, h - def _render_player_card_wide_short(self, img, draw, player, raw_name): + def _render_player_card_wide_short(self, img, draw, player, raw_name, + w: Optional[int] = None, + h: Optional[int] = None): """Maximize canvas usage for wide-short player cards. - Sizes scale from actual width/height: - - headshot fills height minus padding (e.g. 40px tall on 48-tall, - 56px on 64-tall, 22px on 32-tall) - - name font is ~1/5 of height, loaded dynamically - - score font is ~1/3 of height, loaded dynamically - - flag height matches the country label font - - Layout columns (left → right): - [ headshot ] [ name / flag+country / pos+thru ] [ big score ] + Sizes scale from actual width/height (defaults to self.width/height + but accepts overrides so Vegas scroll mode can pass a smaller card + size). A full body that only references the locals w/h keeps this + safe for per-call dimensions. """ - padding = max(3, self.height // 16) - bottom_bound = self.height - padding + if w is None: + w = self.width + if h is None: + h = self.height + + padding = max(3, h // 16) + bottom_bound = h - padding # Headshot — fill the vertical budget, but also cap horizontally so # narrow wide-short panels (e.g. 128x48) leave enough room for the # name + score columns. The /4 cap ties the headshot to available # width, so on 128x48 it shrinks to 32px while 192x48 keeps 42px. - headshot_size = max(16, min(self.height - 2 * padding, self.width // 4)) + headshot_size = max(16, min(h - 2 * padding, w // 4)) hx = padding hy = padding if self.show_headshot: @@ -711,8 +735,8 @@ def _render_player_card_wide_short(self, img, draw, player, raw_name): # Proportional font sizes. Score scales with BOTH height and width so # narrow wide-short displays (e.g. 128x48) don't let the score eat # the entire text column. - score_px = max(10, min(24, int(self.height // 2.4), self.width // 8)) - detail_px = max(6, min(10, self.height // 7)) + score_px = max(10, min(24, int(h // 2.4), w // 8)) + detail_px = max(6, min(10, h // 7)) score_font = _load_font_sized("PressStart2P-Regular.ttf", score_px) or self.font_score detail_font = _load_font_sized("4x6-font.ttf", detail_px) or self.font_detail @@ -723,13 +747,13 @@ def _render_player_card_wide_short(self, img, draw, player, raw_name): score_w = self._text_width(draw, score_text, score_font) score_h = self._text_height(draw, score_text, score_font) score_block_w = score_w + padding * 2 - score_x = self.width - score_w - padding - 1 - score_y = (self.height - score_h) // 2 + score_x = w - score_w - padding - 1 + score_y = (h - score_h) // 2 self._text_shadow(draw, (score_x, score_y), score_text, score_font, self._score_color(score)) # Faint separator before the score column - sep_x = self.width - score_block_w - 1 + sep_x = w - score_block_w - 1 draw.line([(sep_x, padding), (sep_x, bottom_bound)], fill=COLORS["masters_dark"]) @@ -743,12 +767,12 @@ def _render_player_card_wide_short(self, img, draw, player, raw_name): # size 12; 4x6-font is much narrower. We try several sizes of each # and fall back to truncation only if nothing fits. name_font, name_display, name_h = self._fit_name( - draw, raw_name, text_w, max_height=self.height // 3, + draw, raw_name, text_w, max_height=h // 3, ) ty = padding self._text_shadow(draw, (tx, ty), name_display, name_font, COLORS["white"]) - ty += name_h + max(3, self.height // 16) + ty += name_h + max(3, h // 16) # Country flag + code country = player.get("country", "") @@ -813,16 +837,21 @@ def _render_player_card_wide_short(self, img, draw, player, raw_name): # HOLE CARD - Clean layout # ═══════════════════════════════════════════════════════════ - def render_hole_card(self, hole_number: int) -> Optional[Image.Image]: + def render_hole_card(self, hole_number: int, + card_width: Optional[int] = None, + card_height: Optional[int] = None) -> Optional[Image.Image]: + cw = card_width if card_width is not None else self.width + ch = card_height if card_height is not None else self.height hole_info = get_hole_info(hole_number) - img = self._draw_gradient_bg((15, 80, 30), COLORS["augusta_green"]) + img = self._draw_gradient_bg((15, 80, 30), COLORS["augusta_green"], + width=cw, height=ch) draw = ImageDraw.Draw(img) # Header - h = self.header_height - draw.rectangle([(0, 0), (self.width - 1, h - 1)], fill=COLORS["masters_green"]) - draw.line([(0, h - 1), (self.width, h - 1)], fill=COLORS["masters_yellow"]) + header_h = self.header_height + draw.rectangle([(0, 0), (cw - 1, header_h - 1)], fill=COLORS["masters_green"]) + draw.line([(0, header_h - 1), (cw, header_h - 1)], fill=COLORS["masters_yellow"]) hole_text = f"HOLE {hole_number}" self._text_shadow(draw, (3, 1), hole_text, self.font_header, COLORS["white"]) @@ -830,23 +859,23 @@ def render_hole_card(self, hole_number: int) -> Optional[Image.Image]: if self.tier != "tiny": name_text = hole_info["name"] name_w = self._text_width(draw, name_text, self.font_detail) - draw.text((self.width - name_w - 3, 2), name_text, + draw.text((cw - name_w - 3, 2), name_text, fill=COLORS["masters_yellow"], font=self.font_detail) # Hole layout image (clamp to min 1px for tiny displays) hole_img = self.logo_loader.get_hole_image( hole_number, - max_width=max(1, self.width - 8), - max_height=max(1, self.height - h - 14), + max_width=max(1, cw - 8), + max_height=max(1, ch - header_h - 14), ) if hole_img: - hx = (self.width - hole_img.width) // 2 - hy = h + 2 + hx = (cw - hole_img.width) // 2 + hy = header_h + 2 img.paste(hole_img, (hx, hy), hole_img if hole_img.mode == "RGBA" else None) # Footer - footer_y = self.height - 9 - draw.rectangle([(0, footer_y), (self.width - 1, self.height - 1)], fill=(0, 0, 0)) + footer_y = ch - 9 + draw.rectangle([(0, footer_y), (cw - 1, ch - 1)], fill=(0, 0, 0)) info_text = f"Par {hole_info['par']} {hole_info['yardage']}y" self._text_shadow(draw, (3, footer_y + 1), info_text, self.font_detail, COLORS["white"]) @@ -855,10 +884,10 @@ def render_hole_card(self, hole_number: int) -> Optional[Image.Image]: if zone and self.tier != "tiny": badge_text = zone.upper() bw = self._text_width(draw, badge_text, self.font_detail) + 4 - draw.rectangle([(self.width - bw - 2, footer_y), - (self.width - 2, self.height - 1)], + draw.rectangle([(cw - bw - 2, footer_y), + (cw - 2, ch - 1)], fill=COLORS["masters_dark"]) - draw.text((self.width - bw, footer_y + 1), badge_text, + draw.text((cw - bw, footer_y + 1), badge_text, fill=COLORS["masters_yellow"], font=self.font_detail) return img @@ -978,29 +1007,35 @@ def render_past_champions(self, page: int = 0) -> Optional[Image.Image]: # FUN FACTS - Scrolling text # ═══════════════════════════════════════════════════════════ - def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0) -> Optional[Image.Image]: + def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, + card_width: Optional[int] = None, + card_height: Optional[int] = None) -> Optional[Image.Image]: """Render a fun fact with vertical scroll support for long text.""" + cw = card_width if card_width is not None else self.width + ch = card_height if card_height is not None else self.height + if fact_index < 0: fact = get_random_fun_fact() else: fact = get_fun_fact_by_index(fact_index) - img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"]) + img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"], + width=cw, height=ch) draw = ImageDraw.Draw(img) # Header - h = self.header_height - draw.rectangle([(0, 0), (self.width - 1, h - 1)], fill=COLORS["masters_green"]) - draw.line([(0, h - 1), (self.width, h - 1)], fill=COLORS["masters_yellow"]) + header_h = self.header_height + draw.rectangle([(0, 0), (cw - 1, header_h - 1)], fill=COLORS["masters_green"]) + draw.line([(0, header_h - 1), (cw, header_h - 1)], fill=COLORS["masters_yellow"]) title = "DID YOU KNOW?" self._text_shadow(draw, (3, 1), title, self.font_header, COLORS["masters_yellow"]) # Word-wrap the fact text with generous padding - content_top = h + 4 + content_top = header_h + 4 font = self.font_detail line_h = self._text_height(draw, "Ag", font) + 2 # Extra line spacing - max_w = self.width - 10 # More horizontal padding + max_w = cw - 10 # More horizontal padding words = fact.split() lines = [] @@ -1017,7 +1052,7 @@ def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0) -> Optio lines.append(current_line) # Apply scroll offset (for long facts) - visible_lines = max(1, (self.height - content_top - 4) // line_h) + visible_lines = max(1, (ch - content_top - 4) // line_h) if len(lines) > visible_lines: start_line = scroll_offset % max(1, len(lines) - visible_lines + 1) lines = lines[start_line : start_line + visible_lines] @@ -1031,8 +1066,8 @@ def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0) -> Optio # Scroll indicator if text is long if len(words) > visible_lines * 4: # Rough heuristic # Small down arrow - ax = self.width - 6 - ay = self.height - 6 + ax = cw - 6 + ay = ch - 6 draw.polygon([(ax - 2, ay - 2), (ax + 2, ay - 2), (ax, ay + 1)], fill=COLORS["masters_yellow"]) diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py index 9f40488..a88fee7 100644 --- a/plugins/masters-tournament/masters_renderer_enhanced.py +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -91,11 +91,24 @@ def render_leaderboard( self._draw_page_dots(draw, page, total_pages) return img - def render_player_card(self, player: Dict) -> Optional[Image.Image]: - """Enhanced player card with round scores and green jacket info.""" + def render_player_card(self, player: Dict, + card_width: Optional[int] = None, + card_height: Optional[int] = None) -> Optional[Image.Image]: + """Enhanced player card with round scores and green jacket info. + + When card_width/card_height are passed (Vegas scroll mode), the card + is drawn at those dimensions instead of the full panel. We delegate + to the base class's layouts which already honor the override, since + the enhanced round-scores block doesn't fit in a Vegas-size card anyway. + """ if not player: return None + # Vegas scroll override → always delegate to base (handles its own + # width/height overrides and the wide-short/standard layout split). + if card_width is not None or card_height is not None: + return super().render_player_card(player, card_width=card_width, card_height=card_height) + # Wide-short panels (192x48, 256x64, etc.): delegate to the base # class's two-column layout. We drop the round-scores block — there's # no room for it on a 48-tall canvas — but the core card stays legible. @@ -205,7 +218,9 @@ def render_player_card(self, player: Dict) -> Optional[Image.Image]: return img - def render_hole_card(self, hole_number: int) -> Optional[Image.Image]: + def render_hole_card(self, hole_number: int, + card_width: Optional[int] = None, + card_height: Optional[int] = None) -> Optional[Image.Image]: """Enhanced hole card — left info panel, right hole image using full height. Layout is anchored to the TOP and BOTTOM of the canvas so hole number @@ -216,7 +231,16 @@ def render_hole_card(self, hole_number: int) -> Optional[Image.Image]: Small tier (64x32 and similar) uses a compact text-only layout — the hole map is too small to be useful at that size and eating it lets us actually show par and yardage without clipping. + + Vegas scroll overrides delegate to the base class which honors the + dimension override directly — the enhanced left-panel-plus-image + layout is designed for full-panel rendering, not small blocks. """ + if card_width is not None or card_height is not None: + return super().render_hole_card( + hole_number, card_width=card_width, card_height=card_height + ) + hole_info = get_hole_info(hole_number) img = self._draw_gradient_bg((10, 70, 25), COLORS["augusta_green"]) From e5a4004f5e81cceb81c77dd867b98eba41aa6b73 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 9 Apr 2026 19:04:50 -0400 Subject: [PATCH 2/8] fix(masters-tournament): hole card layout per effective dims + Masters wordmark assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Hole card layout (bundled with Vegas scroll fix) The earlier Vegas scroll fix (2.2.5) had the enhanced `render_hole_card` delegating to the base class when called with `card_width`/`card_height` overrides. The base class uses a different layout (horizontal header + centered image + footer) that's neither the user's desired "1 text column + image" for larger cards nor the "2 text columns, no image" for smaller ones — so Vegas scroll hole cards looked inconsistent with the full-panel layouts. Rewrote the enhanced `render_hole_card` to: 1. Accept `card_width`/`card_height` directly and render at those dimensions. 2. Choose between two layouts based on the EFFECTIVE card dimensions (not `self.tier`), using thresholds `cw >= 96 AND ch >= 40`: * **Large enough for image** → single left-column text [Hole #, Name, Par, Yards] + hole image on the right, zone badge in the bottom-right. Split into a new helper `_render_hole_card_with_image()`. * **Smaller canvases** → two text columns: [# / Name] | [Par / Yards / Zone], no image. `_render_hole_card_compact()` now also accepts `cw`/`ch` overrides. 3. `left_w` (text column width) now scales with `cw` via `max(38, min(56, cw // 3))` so 256x64 cards get a roomier text column than 128x48 blocks. Verified across the full size matrix: * 192x48 full panel → IMAGE (#12 + Golden Bell + Par 3 + 155y + map + AMEN CORNER) * 128x64 full panel → IMAGE * 256x64 full panel → IMAGE * 320x64 Vegas 128x64 → IMAGE (inside a scrollable block, not full-panel) * 320x64 Vegas 128x48 → IMAGE * 192x48 Vegas 128x48 → IMAGE * 320x64 Vegas 80x64 → COMPACT 2-col (too narrow for image) * 128x32 full panel → COMPACT 2-col * 128x32 Vegas 80x32 → COMPACT 2-col * 64x32 full panel → COMPACT 2-col Both layouts have the user's requested content layout: * Image layout: one text column [Hole #, Hole Name, Par, Yards] * Compact layout: col 1 [Hole # + Name], col 2 [Par, Yards, Zone] ## Masters wordmark assets Extracted from `masters-icons.ttf` (IcoMoon icon font from masters.com). The font has 143 glyphs in the U+E900–U+E98F Private Use Area, mostly UI icons, but scanning for unusually-wide advance widths revealed two wordmarks: * U+E954 (5.95 em) - the iconic MASTERS wordmark with the Augusta National contour + fairway flag + "Masters" in italic serif * U+E95B (4.89 em) - AUGUSTA wordmark in matching italic serif Added to `plugins/masters-tournament/assets/masters/logos/`: * wordmark_32.png 191x32 white on transparent * wordmark_48.png 286x49 * wordmark_64.png 381x64 * wordmark_128.png 762x128 * augusta_wordmark_32.png 156x30 * augusta_wordmark_48.png 235x45 Assets only — not yet wired into the renderer. A future change can swap these in for the current `masters_logo_*.png` in countdown, player card headers, etc. Bumps manifest 2.2.5 → 2.2.6. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins.json | 2 +- .../masters/logos/augusta_wordmark_32.png | Bin 0 -> 2588 bytes .../masters/logos/augusta_wordmark_48.png | Bin 0 -> 4035 bytes .../assets/masters/logos/wordmark_128.png | Bin 0 -> 16232 bytes .../assets/masters/logos/wordmark_32.png | Bin 0 -> 3159 bytes .../assets/masters/logos/wordmark_48.png | Bin 0 -> 5050 bytes .../assets/masters/logos/wordmark_64.png | Bin 0 -> 6970 bytes plugins/masters-tournament/manifest.json | 7 +- .../masters_renderer_enhanced.py | 126 ++++++++++-------- 9 files changed, 80 insertions(+), 55 deletions(-) create mode 100644 plugins/masters-tournament/assets/masters/logos/augusta_wordmark_32.png create mode 100644 plugins/masters-tournament/assets/masters/logos/augusta_wordmark_48.png create mode 100644 plugins/masters-tournament/assets/masters/logos/wordmark_128.png create mode 100644 plugins/masters-tournament/assets/masters/logos/wordmark_32.png create mode 100644 plugins/masters-tournament/assets/masters/logos/wordmark_48.png create mode 100644 plugins/masters-tournament/assets/masters/logos/wordmark_64.png diff --git a/plugins.json b/plugins.json index b5fed91..8c21d64 100644 --- a/plugins.json +++ b/plugins.json @@ -702,7 +702,7 @@ "last_updated": "2026-04-09", "verified": true, "screenshot": "", - "latest_version": "2.2.5" + "latest_version": "2.2.6" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/assets/masters/logos/augusta_wordmark_32.png b/plugins/masters-tournament/assets/masters/logos/augusta_wordmark_32.png new file mode 100644 index 0000000000000000000000000000000000000000..b542b3cfa8d89635ef6481c389bb74442d748cd5 GIT binary patch literal 2588 zcmV+%3gh*OP) z`ac1%sia|&hDsVFslTL^lKv^_?3B`T9c^PL-aFRn$i{*Gl1527RMLbI z>%<2IKzHD5;4R8n2z;!gZSBMw4&ZaZ<8EO69T*AxBn-H#JKEMxtlaGq0?vRePwi9bKfZc(mHkjrBo4dL$HUKNE0M_YPi#xH#0@xT>V1sEfuyv^4A*^n9 zcbv7ISe@vyx*Gu2l5~TlK2~&cO6l=HJ0N_Yvbyc+#0M0>7`JlWR^U7pR=BPH_m*~I zjR&v}@VX76R$y?3^NBFnIupFB0YEQcb6^nAwVg7*18xJ-`(?-KPW+CQw4VJvF{Si$ zhO>Y8J~^idI7-q+nW~nQ(j|erwxlx(Ha(P5y0y?ocS#3IIz-YolDbKnWj0-tQd$Ch zTGFtT(v?AWxTKv5ZC)QwN`QkTeWt)4pHg})W8aRF4wkfqq_-tKE@@Iq=|QU>BI(eS z(z#Bizod&L?I)?b6}JMjB~6z!P14kq(!9{tKuL$$X-^MH%L=x7nZb5iG*D7+`}A^@ z8=G{zYXH50MQ-K#Y=L)gSmE|A@TSlfD}d88+yTJ+41YebYeD}3z_S^d3a4)li^=d;7F45uq_OMK}TSx4@M_G0JMv^m%5Szeb*v;ni!@#h!XJ=1Y!k*?{x7zNF`U0dSIQY-SDb z=3v<98t$0DJ+Z-txK-y?x45!h(4cc9Ft$)PH0ZjsLD#c_F8{Vyd%0%&cCP4=g3~_A6+KWBXh@P$O24kDj;r_|>vT!l zH+(NWPd_gMY@Sk@o#Bm_^p-D3DIF{6*bH~~@O^57tr15C9b-~T%WL%CRn!D*1xyIb zeF-hVx+!gd~+`xa)aaZI53*+4r z^v-#&dCK7PEog6=MDMsEKDfF+)OQI1TW)gv=R^QFC+zL*!M;JqGp_Co^L=ukcvyd7 z%|q@HxIb*@lnH@9Jk;+U>i^Q-4llIVD_pgDxPiY-X#d_C?oJ`F0$2i^@GfoLEa)n3 z(*^ncgZv4h{_~;!ikySXdltpO$tZr>f(EiArVb9yp=^5qfUIIQ;WB9CK*oYeL zMS;6RL#O!FV^N&J=G%CTDr;*-<|Q;uh|>fAv}5hm4zur~O%00#ODv_&niJ3Tqyq2g{4jB1Iwr@;3icI_4bd@AU^DZ_ar zaJDY!>IZxw^BE9$G5Bbj5cjpG{U9?U+^DHAx0K^7=$YzdR|5UoAy&!u4zA6kmX`6K zhlBnNqLMx?9DWe!(Ov_B9-qjZ*)S;LEGuT9S9J@>%&=oJoCU7FHJ6Ss-g%i93~+GZ zJ@4w~m2z*&=N_N0d!8UW5O^SC&jKKK3KQjfx_3>JFPxD-y3ijbJepH7(h-JdTWU6B z*N|D>rGY;=7}jP`IPU1c9a-WU|Ng*lGlRnmf4N52heL4jMocyXTZi_3)nH!?sxB?? z63{zS-VOM9=H*^f*RIL#6=cRW$pra3ob2mB|KOJ&5%gb_Q*w?Ytn%t+GxlYt%*7{TgoHFqj@I@VtZnYJvx(dguq-WY|F*&{bKKl*m*0doi#`!>Y~U^{`OCn$7Vva0)ORu0rNZJ; zyH3Vu5SEm57i7N`Y;WVI0{?LJt+^Fiz?U4UHhUlB*0Sp>72dR)6iss12E)JK!1uH2 z{sLz+JA97VxxkN3erc82&vCNXrtJETN1Fj;=cX5A?3-5e+|{vB(S-nh>2&?I#53-Y z&~|q9wD-D0Cco-~fd?{j1A<$X-Ce2%UNzF}_8hXB^XYFdEC&v3hurYYq47x#GX6mP zzkWIhY+#Q4605(|ZI}NlMZUn6!H&7Wu)rA-qT|a8y5g_R_HuRIfUh|{vqF^kje^d8 z?&ZzRxg3_Rdd%rMzM&t-20OfGChHDg&PXn za%3&Qb=HqQ<30sg-!cIePA%v>F0?%x__DbTJ?%Lsy86F0q?LwbZl;t5yH9(JuuGdy zy0YBWtf6kwS6E(j(aayX!I59E+}v*Va9^~L`1Y`DK2tYX^v>*z=UNZsQuKx1@MY>Zh>}Z8g0;rSztK?$g&3d3-o@uVi+&_6AsA&yaLuN@-SxdxWHQW4U2@N@;>GXmeNH yuJIga4>#ET+G?S7;$4!iODVNGVcCgJRQNaRYH2z?=fv3n0000 zd$305+7gi=^Eo?I3BSq^%_lle9w8Vo5V3ot;v8yua=3qKg3qU}xYA z;BH_A?N|wXyua=4qKg3mpa(btxGQ6F_TqNnZ-LGG+x{-P7!UyV0sgmOk^;;GHt28L zyXay-0N4n)JeZ6#fQi6@;PdZe6n|vYW17E97_(Zv7&uqUv%Hu$~V;fB+Z_+*_M;OMv$V$0uN}Gf`g!Hd^;1=%R}O0^ky7 z;vLs;{9eIZ^_F#Oc^6#_AOIhBCf$uKGXEA#))UvAuyJYJh{i-4_KdJ^*m4GvQ8Yk$Gn@QU9>+EbrpY4q#j0DBvyqZA-rc7zVshn{ac0^;_gl z4kqil>(2VM3t$7_E!DVk7tH_$0b_yjz<$6^ojMzVttuhJDSdBkJJ#zDr42Yk(q^^4 zU7S){)*^Rk@b{aWgn;cOeKW(Z$5KkCH}ISy=|dSjm!y<#%{WfL5J`tgI$YA;l1A81 zF9Fje-6rX(l+wJ4%+YoZ$ETF$2E3~{oJ)a=>>P|RnHeqof5_ln zXcIEx7@xrba8ZjbI{-Ij+Vfpof}R9CV1GZz;e1=>^{8SIa0BpZVC%rvYII_9^Hp@^ zhXZE;PZrwtePH8KeQOoK*^Z%?Wyl>73^M1*FW}0C6ZJw)LItotu)Kk1GVqQJo_&E? zg%=aM0eW+yFK^Ltu+{TMm;`Lm!Z8H+Z3D+5;3F*@hX5}+vde+lz!NPGtXFLHQMc=X z2ANzu0bC3Gi%sAk)_%`fMfKX$V>~ZAdKLiJHQa-fa`mh;02|xWvx-%~jv2Do1Oq)H zQ}?!k{IZhdf8gqX=PqDyi);e^wP6y?wCt=e0iOlFYLm*l0S?Zv_2+?}o7+|+6^=0h z$4cP+EwbaChuc$DkTJM+>~_G{ZE{xehWNRimnwb@d;%B;90OcoK9DuRl{x3f?AZjk z&Ea?&IIhEu^XXr`CQ@n>)=Fvr*I4t1D?hSZ%^Ln zfiufu&aP7l*bMk~<@jYeOD5n4fxc}D#yc$F_>UI3&7BAH0$^l|+*sh*ip&?CW2&o? z`@QpK=X+9MdlOaw=jS^3Y@$wecy6p;BWsFK$Gn)mpI}~2FHLao4B5Sd6PvxK0KOJ* z99TGxM;(q?8FJqb^#51xUIqA$$^3Wb@lQMY{+J_M;W#?5yRltPki6396R61keMM%w z+HXT^oUb(Sc;kNo_!aOc`~PxFRei+X$U7IDo1S0--dLaP=A-Z`#jefOx6S~3-ZAud znX(rIH%wz(w#IS4BmZ(qR|#9Hd1%`|o*&5FTN29yb~Cxw0()SWKNq1)XOF#I9}9keu)=vqaLmrZ_< z*M|apE3n7)O6M9e*7lY^1s)3w>cz{px!rG@Z5M# zgeB42IoP-LVy$*vwB>aj&spp;mt&v6_SRUSl{k91D(4|@&d4Ec$2G|Rr=xpmUxi0( zsawb6k>f51rTw`R#Y1g^dhJwluo8G*z`eeC0Q(vj7mV*`0Y`Q|v1Ju4bvVMeb3@U= zy}5cjfn858#FQ20s_=Xxus_FpA8lPQZ}eN&XpqgiHwv!RZGFVQQ?OE-5F9@|I1k_U zj(3+H_T-u%>GVq0MknZzbcCe;aeg~NQhib^l9aVyl{8M0x36o=TIz+pNe*y+nx0ac zooRz4?QdBpb-1N@w%q${z*V$w>;MM_*Zfz7Jl;0t$&}Kg8L}5iI?l^1GTw)P6LK@X z3NbFYR;A;QaH6F8>R^SW>w@FG`%jX7o2AW`yPgxEQ~ci1u@NxOIra;gHVzH4mR@X& zX;wJiAJ|*!Xotw6dadlt6WgD*5 zJNtlpgJ2&YJ_v&Au@b1K+`7X11X(3h&55#`Igqvhwg&b9J{5RWdvTtl=UnHw#|kD| ziWX5oD=Yb>2?iUjGWxzu^C=+HyzGW_B_0&V7$@xqbSr>%=kvpRAAT| z==nAekZvjk#cLN^}N9E9KP1dtW54G1Xzk! zT9a{{B=qCgbO4Ty(atiPD8Svob8#@xuM}{2WmzlRs!eJfQFn^QG_fFb@}~m5(Hn9O zuxB6>tt1*VtQ?-}1D??(JPrIqfu96AdU0e2yu*Sw_ceWF_hg5Are(Hy|HcGc!P|3` zRkMh)J*owR85BI(W|p?dFh`V!tmT|}?Up$5cNf|bIa)r|BIn`30=*+0`PZ7Vwty1@ zy?pKCW;8O7ns+wkseyp z0XY2MbPQY7mf_^^><|nxJ1z$JaO|Ak)w8(nP;D-xzd%3-hNe#d+9GN%N zgSK#dC^&D81?Cm`*+3L;^p5u;1^|n4IVxP<+j?bReEJhOEwD385NR0OZaE5{sR;CG zX^we>J!Hq_DqK-sXLdQ8IWs(zhEjREe@kbTgRd#*sYS5DYi!)g?EHcp-~{~M(f53=-CCU~=Y=DSR#FZ8UfQdd1XPu|&yiV}wSNZgtO;f% z!e{Uv6ztLN>}07OETt)|+>h!`8{ddpy@dWzAP;a(21k^*omGP6d?jybTUaC5uVq!4 zSQ%n8QN#S_8b{R=+Zqb2$bG@l*&c^{J;woo?O!e73Am%s-9I+SY#A(@f7zz5cP7`L zvM2PxKt3wz7fyQH3`rl$Xi!z>`zvdxRmTcRCHt7D&av^Pv(I+iC+S%GllO$r>HZ&u zhgxqQTej8D!Cfh(uy)hnHJoeo71$p2Ym%l&8ZPN&N%d=XN41ez1FCQSvkPL`IDacm zLyVFte%-1Z-VPiKd@%4rd;W=O4cAzbj&vHLNz%+VdwPx9sW!`B0d~j|_W3>&Jk93; z@9uzaNKkd(`*hEKO9O`oJZ@5kwSBp&HlFL9rEqo_z>1tbTo^4Atr6JWj!bLqZXoxh zw`VnH;IXypm_^pRsg9E}bbD`-3p3Bt%{94KbK=nz`4p63KGPNgGT%UT`FvLG_hC+3 zon=k?PRkL9s>QySJ1zlt)P$#%2BFRM9xW4}0JbgR)9SdY*M*^qpXNGCtV+gf1vB6J z;*0^_XI=CrJ2%?Jb`#V)0c2OqSSTg>Li@w!Io-?BI}f!=m8f}GPNmC9f$St(=8mz+ zIMG@1K4r-m`#JKh4npgFkm{^*lYb&>)q^fFeNvK)Vc|JwTE>qv_yoV71`0jv2zNSrVf(l#ySulKR+c*vrmCZWF&vLz}!m+85i-lXOf6pV)X-(xs9nrj(Wx%JvKFsW`$s zq%WkD7WA_%8%tW6@!60y(X)vafFtSYwW+Jcjvjl+4wp2@8W7A&DHX+2*Cy<(KFaQq z7bPu7Da~KkcC>lKMoRj)r2XvM99~t+a{DI9R7p2Ux;@jK-$fT)6riYr!S;h&NZL@+ pDoKkaEleq`SbHXR(M1=M^na0ZP<~8Q1eX8+002ovPDHLkV1g>V?ic_7 literal 0 HcmV?d00001 diff --git a/plugins/masters-tournament/assets/masters/logos/wordmark_128.png b/plugins/masters-tournament/assets/masters/logos/wordmark_128.png new file mode 100644 index 0000000000000000000000000000000000000000..2ec3d8c8f5877b3208f19c584ff28d20ba7ec87b GIT binary patch literal 16232 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 literal 0 HcmV?d00001 diff --git a/plugins/masters-tournament/assets/masters/logos/wordmark_32.png b/plugins/masters-tournament/assets/masters/logos/wordmark_32.png new file mode 100644 index 0000000000000000000000000000000000000000..6d044b8415612d3ff56dd51e074cc8ceab67ac43 GIT binary patch literal 3159 zcmV-d45;&oP)*PLQ&Lc6b*`r zA_%T!3JppmaTh5HK`oUKL%nE>;Q}dmbbJ$6;#kH0L}!yW*w?q+NKIBi1D2!(%VN;nDUbN=n%r)4b@%2w-mq!Zq#25ENo$W+5zv7j?N9#IU+;f`@n&X z---%0cmN%N&wxJwqk&z35gUMAr)D_okOt^F3#d!cH4oULXL=+$M_&*^c)yYDt)Pj~BF+ySCh6%A!aI@{NZPd-sy*CYzb)x;ci%=*k2E3R0!i1| z?NUjnOWIh{!H#WO)0n*B0JsVm*V1-0Cca5>0ozFGD{1w5=$juxm}dI=OKL6Y30Fri zN!!>+`Lq@PP|_Zfrms&!3ZSd~ua-0`gisC4m9)E+pCM_Y7XZ3SnqapzlFkYtECkN9 zwk2rrlE?NE``PT*R#H1j?cK9^K7{aGhU{^YPO^Q-Qc15#ni4|z(&$4a-4Q}q?qu3Y z>MyCAy~K+p{nP&DcwQ&dM$!#-JvfB07T877`3-3D*bu_!PIo^^I~0&xB57&};loml z)-NZUul-NI*b??yNp~mA=K?fi)Fh7#(0xZg@uLgJ(7zG>;Y-V!3j6TWf+79?QMMrh?SLa{4r_Usl=m3ScTQ7g*$l%1-p;7;qpk#ybRXW0r#eziF(xb8PDCI^<`O ztscF!?j?T9mERZX`_R#*mT60C;5O$JOMqd(UXf!iOG}v9i=1T8Yk5_9)RWwZPS`U$(ZuN-_Z6?gg(c=K+?BtT?}Q%;2W1+v<7RJ{{m-RT2M8r*kPV z$NJ@M;8Ec8w1^zgHL`hFPTTJU&ds3x+3BeEGqBOOh-QF#{u=emy&3gfXY?LP!Dhgf zSw=Soa-4rCFtDJGjc-q=yOqEuNx?v%OVI{51Ub$>7uc ze99Gg(#b7~XzhVh3h3A`s=t2<&pnP_8xMHaFV&7`Ro06L^zkau0d;uM{irtnrp=X!1`qYs{Oa`XH$!uA1QIC|z$3Acnx zumtEh1o&Fnm{fSzAQ?b*S~+W`AUOCo?9GW1N3 z`2J&2pWMpt%VlT;U~(C@_z>nNz@X^Qe`vEbj)iCF?VJ&!T%5P@ zjC9->$rd_hVr}}=$<2u9&^`kJYr zF)!GDY9_}o^U$8dFfM*pI=ebHYYzZrC;X!&+5Yx1Gkl3JdvYkqpX?1Dw41Wa*!HNb^M?JsD@HBkrN8PVHXL}!$b0NHt%1pi8j zcI^}CO${|#A14RbewD%Fmoe2@&Y*P;?6bOyvUpQuM@2TLW}=Pv(zHF74JLj@tj&=1 zxIUwfxkgXTc1QiPx?aCn=Z*u8vE|4h+Z_DEk_?%dx+n>?#$@~3{TzEUhgveF9Z;90 zD@At|@S4-V40s&4*Z%u+IoNWlQ0%=78;(pldHG@Fdq;__+KfzeCkqSLrR7(meNck<%vF5W zX-S+n0Dj=;b#}+`8h3#?l#lXuv(yY5VH?>h;75LR zw^`)_8|ljW=|WphJ(bc~9<`PN?^r-((R$n2Ii5J7l;ECZI=3@M2|l&ibG5tg8tJY| zNvtKfI>nZ|%t2pr%cS{M&kW$-PJU9_GDGW}GJfF_%QA(ZJHd)e`_yZBk;AZ{O+m|7P(<1)+B#jCo%(wfVk_K5{ zY$0idq}lf2O6LB^V(&XWxGF8($$iVkhGPguk0h836dU&+g9kqaoYp*w9g*DFu4aq z2rGc;A%xrmOm@0(m89O1dWR4i@&(Yh0=68gNpqqms;^ZDq3}}j{{=<*r9r=%U~l9= x8+EEf2=%7w6q!Ft+Edby5JKfQZYpST{1-G`>}q6=0mc9T002ovPDHLkV1kI$D{KG& literal 0 HcmV?d00001 diff --git a/plugins/masters-tournament/assets/masters/logos/wordmark_48.png b/plugins/masters-tournament/assets/masters/logos/wordmark_48.png new file mode 100644 index 0000000000000000000000000000000000000000..7e095c3d9b59c4273395e74839dc8ec6bfbb436b GIT binary patch literal 5050 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 Q&j0`b07*qoM6N<$f>yi3c>n+a literal 0 HcmV?d00001 diff --git a/plugins/masters-tournament/assets/masters/logos/wordmark_64.png b/plugins/masters-tournament/assets/masters/logos/wordmark_64.png new file mode 100644 index 0000000000000000000000000000000000000000..1823d2e55119fc4fe44a6eb587c4fd36c04f08c0 GIT binary patch literal 6970 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 Optional[Image.Image]: - """Enhanced hole card — left info panel, right hole image using full height. - - Layout is anchored to the TOP and BOTTOM of the canvas so hole number - is pinned to the top, par/yardage are pinned to the bottom, and the - hole name fills whatever's left in the middle (wrapped on tall - displays, truncated on short ones). - - Small tier (64x32 and similar) uses a compact text-only layout — - the hole map is too small to be useful at that size and eating it - lets us actually show par and yardage without clipping. - - Vegas scroll overrides delegate to the base class which honors the - dimension override directly — the enhanced left-panel-plus-image - layout is designed for full-panel rendering, not small blocks. + """Enhanced hole card with two layout modes: + + 1. **Large enough for an image** (cw >= 96 and ch >= 40): a single + text column on the left with [Hole #, Name, Par, Yards] stacked, + and the hole layout image filling the right side. + + 2. **Smaller canvases** (64x32, narrow Vegas blocks, etc.): a + two-column text-only layout: + ┌─────────────┬─────────────┐ + │ #12 │ Par 3 │ + │ Golden Bell │ 155y │ + │ │ AMEN CORNER │ + └─────────────┴─────────────┘ + + The layout decision uses the effective card dimensions, so Vegas + scroll blocks at e.g. 128x48 get the image layout while a small + 64x32 full panel or an 80x32 Vegas block gets the 2-column text. """ - if card_width is not None or card_height is not None: - return super().render_hole_card( - hole_number, card_width=card_width, card_height=card_height - ) + cw = card_width if card_width is not None else self.width + ch = card_height if card_height is not None else self.height hole_info = get_hole_info(hole_number) - - img = self._draw_gradient_bg((10, 70, 25), COLORS["augusta_green"]) + img = self._draw_gradient_bg((10, 70, 25), COLORS["augusta_green"], + width=cw, height=ch) draw = ImageDraw.Draw(img) - # Compact text-only layout for small/short displays. - if self.tier == "small": - return self._render_hole_card_compact(img, draw, hole_number, hole_info) + if cw >= self._HOLE_IMAGE_MIN_W and ch >= self._HOLE_IMAGE_MIN_H: + return self._render_hole_card_with_image( + img, draw, hole_number, hole_info, cw, ch, + ) + return self._render_hole_card_compact( + img, draw, hole_number, hole_info, cw, ch, + ) - # Left panel width for text info — wider on large tier, and wider - # still when we have lots of horizontal room to spare (e.g. 192x48). - if self.tier == "large": - left_w = 48 if self.is_wide_short else 38 - else: - left_w = 28 + def _render_hole_card_with_image(self, img, draw, hole_number: int, + hole_info: Dict, cw: int, ch: int) -> Image.Image: + """Large-canvas layout: single text column on the left + hole image on the right. - # ── Left panel: hole info ── - draw.rectangle([(0, 0), (left_w - 1, self.height - 1)], fill=COLORS["masters_dark"]) - draw.line([(left_w - 1, 0), (left_w - 1, self.height)], fill=COLORS["masters_yellow"]) + Text column contents (stacked top to bottom): + [Hole #, Hole Name (wrapped), Par, Yardage] + Zone badge is drawn as a small chip in the bottom-right over the image. + """ + # Left panel width: wide enough to fit "Golden Bell" and par/yardage text. + # Grows with card width so 256x64 cards get more text room than 128x48. + left_w = max(38, min(56, cw // 3)) + + # Left panel background strip + draw.rectangle([(0, 0), (left_w - 1, ch - 1)], fill=COLORS["masters_dark"]) + draw.line([(left_w - 1, 0), (left_w - 1, ch)], fill=COLORS["masters_yellow"]) line_h = self._text_height(draw, "A", self.font_detail) + 1 max_text_w = left_w - 4 @@ -276,7 +291,7 @@ def render_hole_card(self, hole_number: int, par_text = f"Par {hole_info['par']}" yard_text = f"{hole_info['yardage']}y" par_block_h = line_h * 2 - par_y = self.height - par_block_h - 2 + par_y = ch - par_block_h - 2 pw = self._text_width(draw, par_text, self.font_detail) draw.text(((left_w - pw) // 2, par_y), par_text, fill=COLORS["white"], font=self.font_detail) @@ -285,7 +300,7 @@ def render_hole_card(self, hole_number: int, fill=COLORS["light_gray"], font=self.font_detail) bottom_bound = par_y - 2 - # Middle: hole name — fit in whatever space is left + # Middle: hole name — fit in whatever space is left between hole# and par name_text = hole_info["name"] name_slot = bottom_bound - top_bound max_lines = max(1, name_slot // line_h) @@ -307,6 +322,7 @@ def render_hole_card(self, hole_number: int, current = word if current: name_lines.append(current) + # Clamp to available lines; ellipsize the last surviving line if clipped. if len(name_lines) > max_lines: name_lines = name_lines[:max_lines] @@ -314,13 +330,11 @@ def render_hole_card(self, hole_number: int, while last and self._text_width(draw, last + "..", self.font_detail) > max_text_w: last = last[:-1] name_lines[-1] = (last + "..") if last else ".." - # Also shrink any single line that doesn't fit horizontally. for idx, line in enumerate(name_lines): while line and self._text_width(draw, line, self.font_detail) > max_text_w: line = line[:-1] name_lines[idx] = line - # Vertically center the name block in its slot. block_h = len(name_lines) * line_h name_y = top_bound + max(0, (name_slot - block_h) // 2) for i, line in enumerate(name_lines): @@ -328,10 +342,10 @@ def render_hole_card(self, hole_number: int, draw.text(((left_w - lw) // 2, name_y + i * line_h), line, fill=COLORS["masters_yellow"], font=self.font_detail) - # ── Right side: hole layout image using full height ── + # Right side: hole layout image img_x = left_w + 2 - img_w = self.width - img_x - 2 - img_h = self.height - 4 + img_w = cw - img_x - 2 + img_h = ch - 4 hole_img = self.logo_loader.get_hole_image( hole_number, max_width=img_w, @@ -339,17 +353,17 @@ def render_hole_card(self, hole_number: int, ) if hole_img: hx = img_x + (img_w - hole_img.width) // 2 - hy = (self.height - hole_img.height) // 2 + hy = (ch - hole_img.height) // 2 img.paste(hole_img, (hx, hy), hole_img if hole_img.mode == "RGBA" else None) - # Zone badge at bottom-right corner (over the hole image area) + # Zone badge at bottom-right corner over the hole image zone = hole_info.get("zone") if zone and self.tier != "tiny": badge = zone.upper() bw = self._text_width(draw, badge, self.font_detail) + 4 - bx = self.width - bw - 1 - by = self.height - 9 - draw.rectangle([(bx, by), (self.width - 1, self.height - 1)], + bx = cw - bw - 1 + by = ch - 9 + draw.rectangle([(bx, by), (cw - 1, ch - 1)], fill=COLORS["masters_dark"]) draw.text((bx + 2, by + 1), badge, fill=COLORS["masters_yellow"], font=self.font_detail) @@ -357,12 +371,13 @@ def render_hole_card(self, hole_number: int, return img def _render_hole_card_compact(self, img, draw, hole_number: int, - hole_info: Dict) -> Image.Image: - """Two-column compact hole card for short/small displays (e.g. 64x32). + hole_info: Dict, + cw: Optional[int] = None, + ch: Optional[int] = None) -> Image.Image: + """Two-column compact hole card for canvases too small for a hole image. - Drops the hole map image entirely — it's too small to read at this - size, and dedicating the canvas to text lets us show hole #, name, - par, yardage, and zone all without clipping. + Drops the hole map image entirely and dedicates the canvas to text + so hole #, name, par, yardage, and zone all fit without clipping. Layout: ┌─────────────┬─────────────┐ @@ -371,9 +386,14 @@ def _render_hole_card_compact(self, img, draw, hole_number: int, │ │ AMEN CORNER │ └─────────────┴─────────────┘ """ - col_w = self.width // 2 + if cw is None: + cw = self.width + if ch is None: + ch = self.height + + col_w = cw // 2 # Divider - draw.line([(col_w, 1), (col_w, self.height - 2)], + draw.line([(col_w, 1), (col_w, ch - 2)], fill=COLORS["masters_yellow"]) line_h = self._text_height(draw, "A", self.font_detail) + 1 @@ -397,7 +417,7 @@ def _render_hole_card_compact(self, img, draw, hole_number: int, # Right column: Par / yardage / zone stacked rx = col_w + 3 - right_w = self.width - rx - 2 + right_w = cw - rx - 2 y = 1 par_text = f"Par {hole_info['par']}" draw.text((rx, y), par_text, @@ -410,7 +430,7 @@ def _render_hole_card_compact(self, img, draw, hole_number: int, y += line_h zone = hole_info.get("zone") - if zone: + if zone and y + line_h <= ch: zone_text = zone.upper() while zone_text and self._text_width(draw, zone_text, self.font_detail) > right_w: zone_text = zone_text[:-1] From 9db5ed1c10908a87d5b354434c108ffc57e92a48 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 9 Apr 2026 19:28:09 -0400 Subject: [PATCH 3/8] fix(masters-tournament): pixel-perfect 5x7 BDF font for compact hole card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous attempts to use 5by7.regular.ttf via _load_font_sized() resulted in washed-out anti-aliased text because PIL's TTF renderer smooths small pixel fonts. The 5x7.bdf asset in the LEDMatrix core assets dir is a true fixed-size bitmap font, and PIL.BdfFontFile can convert it to PIL format for pixel-perfect 1:1 rendering. Changes: - Added _load_bdf_font(filename) in masters_renderer.py. Converts BDF → PIL format once per process via tempfile.mkdtemp(), caches the loaded ImageFont in _BDF_FONT_CACHE (keyed by filename), and stores failure results so missing files don't re-hit the disk. Uses a local `from PIL import BdfFontFile` import since it's only needed by this helper. - Enhanced renderer imports _load_bdf_font alongside _load_font_sized. - _render_hole_card_compact() now loads 5x7.bdf for both text_font and hole_font. The BDF renders at its native 7px height with no anti-aliasing, so glyphs look crisp and bold. Falls back to self.font_detail / self.font_body when the BDF file isn't on the search path. - The previous 4x6 code is kept in a commented block directly underneath so we can flip fonts back in one edit if users report issues with 5x7 on actual hardware. Verified: - _load_bdf_font("5x7.bdf") returns a valid ImageFont, 'Ag' bbox height exactly 7px, second call hits the cache (same object id). - _load_bdf_font("nonexistent.bdf") returns None cleanly. - Render matrix across full panels (64x32, 128x32, 128x48, 128x64, 192x48, 256x64) and Vegas scroll blocks (128x64, 128x48, 128x32, 80x32) all produce expected layouts: * ch >= 48 → IMAGE layout (left-panel text + hole image, unchanged) * ch < 48 → COMPACT 2-col (# + Name left, Par + Yards right) - Compact layouts now render with pixel-perfect 5x7 glyphs; the narrower-per-glyph 5x7 font also lets "AMEN CORNER" fit fully on a 128-wide compact card where 4x6 was truncating to "AMEN COR". - test_plugin_standalone.py: 45/45 passing. Note: 5x7.bdf is NOT copied into the plugin repo — it already lives in the core LEDMatrix assets dir which the plugin's FONT_SEARCH_DIRS already knows about. If a future deployment doesn't ship the core fonts, the fallback to self.font_detail keeps the hole card working. Bumps manifest 2.2.6 → 2.2.7. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins.json | 2 +- plugins/masters-tournament/manifest.json | 7 +- .../masters-tournament/masters_renderer.py | 46 +++++++ .../masters_renderer_enhanced.py | 112 +++++++++++------- 4 files changed, 121 insertions(+), 46 deletions(-) diff --git a/plugins.json b/plugins.json index 8c21d64..11a4eaf 100644 --- a/plugins.json +++ b/plugins.json @@ -702,7 +702,7 @@ "last_updated": "2026-04-09", "verified": true, "screenshot": "", - "latest_version": "2.2.6" + "latest_version": "2.2.7" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index 75ba1f5..ba05e19 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.2.6", + "version": "2.2.7", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,11 @@ "height": 64 }, "versions": [ + { + "version": "2.2.7", + "released": "2026-04-09", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.2.6", "released": "2026-04-09", diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index eb5b7e4..a79e254 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -16,6 +16,7 @@ import logging import os +import tempfile from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -142,6 +143,51 @@ def _load_font_sized(filename: str, size: int) -> Optional[ImageFont.ImageFont]: return font +# Cache for _load_bdf_font. Key: filename. Value: font or None. +_BDF_FONT_CACHE: Dict[str, Optional[ImageFont.ImageFont]] = {} + +# Persistent temp dir for converted BDF → PIL font files. BdfFontFile.save() +# writes a .pil header + .pbm bitmap pair that ImageFont.load() then reads, +# so those files need to exist on disk for the lifetime of the process. +_BDF_TEMP_DIR: Optional[str] = None + + +def _load_bdf_font(filename: str) -> Optional[ImageFont.ImageFont]: + """Load a BDF bitmap font and return it as an ImageFont. + + PIL's ImageFont.truetype() anti-aliases TTF bitmap fonts, which ruins + the crispness of pixel fonts like 5by7.regular.ttf at small sizes. + BDF files are true fixed-size bitmap fonts — loading them via + PIL.BdfFontFile gives pixel-perfect rendering with no sub-pixel + smoothing. Converts once per process and caches the result. + """ + if filename in _BDF_FONT_CACHE: + return _BDF_FONT_CACHE[filename] + + bdf_path = _find_font_path(filename) + if not bdf_path: + _BDF_FONT_CACHE[filename] = None + return None + + global _BDF_TEMP_DIR + if _BDF_TEMP_DIR is None: + _BDF_TEMP_DIR = tempfile.mkdtemp(prefix="masters_bdf_") + + try: + from PIL import BdfFontFile # local import — only needed here + base = os.path.join(_BDF_TEMP_DIR, os.path.splitext(filename)[0]) + if not os.path.exists(base + ".pil"): + with open(bdf_path, "rb") as f: + BdfFontFile.BdfFontFile(f).save(base) + font = ImageFont.load(base + ".pil") + except Exception as e: + logger.warning(f"Failed to load BDF font {bdf_path}: {e}") + font = None + + _BDF_FONT_CACHE[filename] = font + return font + + class MastersRenderer: """Broadcast-quality Masters Tournament renderer with pagination & scrolling.""" diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py index a540ef9..553196d 100644 --- a/plugins/masters-tournament/masters_renderer_enhanced.py +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -26,7 +26,7 @@ get_hole_info, get_score_description, ) -from masters_renderer import COLORS, MastersRenderer +from masters_renderer import COLORS, MastersRenderer, _load_bdf_font, _load_font_sized logger = logging.getLogger(__name__) @@ -218,31 +218,36 @@ def render_player_card(self, player: Dict, return img - # Minimum canvas dimensions where the hole image is worth showing. - # Below this, we drop the image and use a 2-column text layout. - _HOLE_IMAGE_MIN_W = 96 - _HOLE_IMAGE_MIN_H = 40 + # Vertical-resolution threshold for the compact hole card layout. + # At less than this height, Par/Yards move to the RIGHT of Hole #/Name + # instead of stacking underneath them; at this height or taller, the + # current left-panel-plus-image layout is used. + _HOLE_COMPACT_HEIGHT = 48 def render_hole_card(self, hole_number: int, card_width: Optional[int] = None, card_height: Optional[int] = None) -> Optional[Image.Image]: - """Enhanced hole card with two layout modes: - - 1. **Large enough for an image** (cw >= 96 and ch >= 40): a single - text column on the left with [Hole #, Name, Par, Yards] stacked, - and the hole layout image filling the right side. - - 2. **Smaller canvases** (64x32, narrow Vegas blocks, etc.): a - two-column text-only layout: - ┌─────────────┬─────────────┐ - │ #12 │ Par 3 │ - │ Golden Bell │ 155y │ - │ │ AMEN CORNER │ - └─────────────┴─────────────┘ - - The layout decision uses the effective card dimensions, so Vegas - scroll blocks at e.g. 128x48 get the image layout while a small - 64x32 full panel or an 80x32 Vegas block gets the 2-column text. + """Enhanced hole card with two layout modes, chosen by vertical resolution: + + * **ch >= 48** → existing "big" layout: a single text column on the + left with [Hole #, Name, Par, Yards] stacked top-to-bottom, + and the hole layout image filling the right side. + + * **ch < 48** → compact two-column text-only layout: + + ┌─────────────┬─────────────┐ + │ #12 │ Par 3 │ + │ Golden Bell │ 155y │ + │ │ (AMEN COR) │ + └─────────────┴─────────────┘ + + Par and Yards sit to the RIGHT of the Hole #/Name column + (instead of underneath). No hole image — there isn't enough + vertical room for a useful one below 48px. + + The decision is purely on height so Vegas scroll blocks on a + tall parent panel still get the image layout whenever they're + 48+ tall. """ cw = card_width if card_width is not None else self.width ch = card_height if card_height is not None else self.height @@ -252,7 +257,7 @@ def render_hole_card(self, hole_number: int, width=cw, height=ch) draw = ImageDraw.Draw(img) - if cw >= self._HOLE_IMAGE_MIN_W and ch >= self._HOLE_IMAGE_MIN_H: + if ch >= self._HOLE_COMPACT_HEIGHT: return self._render_hole_card_with_image( img, draw, hole_number, hole_info, cw, ch, ) @@ -374,68 +379,87 @@ def _render_hole_card_compact(self, img, draw, hole_number: int, hole_info: Dict, cw: Optional[int] = None, ch: Optional[int] = None) -> Image.Image: - """Two-column compact hole card for canvases too small for a hole image. + """Compact hole card for vertical resolutions below 48px. - Drops the hole map image entirely and dedicates the canvas to text - so hole #, name, par, yardage, and zone all fit without clipping. + Par and Yards sit to the RIGHT of Hole#/Name instead of underneath + so everything fits in less vertical space. Zone badge is drawn + under Par/Yards when there's room. Layout: ┌─────────────┬─────────────┐ - │ #12 │ Par 3 │ - │ Golden Bell │ 155y │ - │ │ AMEN CORNER │ + │ #12 │ Par 3 │ + │ Golden Bell │ 155y │ + │ │ (AMEN COR) │ └─────────────┴─────────────┘ + + Uses the 5by7 font for the text — slightly narrower than the + default 4x6 so long hole names like "Golden Bell" fit more + comfortably in the left column. """ if cw is None: cw = self.width if ch is None: ch = self.height + # 5x7 BDF bitmap font — pixel-perfect at native 5x7 size. PIL's TTF + # anti-aliasing washes out small pixel fonts, so we load the BDF + # directly via PIL.BdfFontFile for crisp 1:1 glyph rendering. Falls + # back to self.font_detail / self.font_body if the BDF file isn't on + # the search path. + text_font = _load_bdf_font("5x7.bdf") or self.font_detail + hole_font = _load_bdf_font("5x7.bdf") or self.font_body + + # --- LEGACY 4x6 path, kept commented for quick revert if the 5x7 + # --- BDF causes issues on the Pi. Swap the two blocks to revert. + # text_font = _load_font_sized("4x6-font.ttf", 7) or self.font_detail + # hole_font = _load_font_sized("4x6-font.ttf", 8) or self.font_body + col_w = cw // 2 # Divider draw.line([(col_w, 1), (col_w, ch - 2)], fill=COLORS["masters_yellow"]) - line_h = self._text_height(draw, "A", self.font_detail) + 1 + line_h = self._text_height(draw, "Ag", text_font) + 1 - # Left column: hole number (top) + name (centered) + # ── Left column: hole number (top) + name (underneath) ── hole_text = f"#{hole_number}" - hw = self._text_width(draw, hole_text, self.font_body) - hole_h = self._text_height(draw, hole_text, self.font_body) + hw = self._text_width(draw, hole_text, hole_font) + hole_h = self._text_height(draw, hole_text, hole_font) draw.text(((col_w - hw) // 2, 1), hole_text, - fill=COLORS["white"], font=self.font_body) + fill=COLORS["white"], font=hole_font) name_text = hole_info["name"] - # Truncate name to fit left column max_name_w = col_w - 4 - while name_text and self._text_width(draw, name_text, self.font_detail) > max_name_w: + while name_text and self._text_width(draw, name_text, text_font) > max_name_w: name_text = name_text[:-1] name_y = 1 + hole_h + 2 - nw = self._text_width(draw, name_text, self.font_detail) + nw = self._text_width(draw, name_text, text_font) draw.text(((col_w - nw) // 2, name_y), name_text, - fill=COLORS["masters_yellow"], font=self.font_detail) + fill=COLORS["masters_yellow"], font=text_font) - # Right column: Par / yardage / zone stacked + # ── Right column: Par / yardage [/ zone] stacked ── rx = col_w + 3 right_w = cw - rx - 2 y = 1 + par_text = f"Par {hole_info['par']}" draw.text((rx, y), par_text, - fill=COLORS["white"], font=self.font_detail) + fill=COLORS["white"], font=text_font) y += line_h yard_text = f"{hole_info['yardage']}y" draw.text((rx, y), yard_text, - fill=COLORS["light_gray"], font=self.font_detail) + fill=COLORS["light_gray"], font=text_font) y += line_h + # Zone only if there's a full text row of headroom left zone = hole_info.get("zone") - if zone and y + line_h <= ch: + if zone and y + line_h <= ch - 1: zone_text = zone.upper() - while zone_text and self._text_width(draw, zone_text, self.font_detail) > right_w: + while zone_text and self._text_width(draw, zone_text, text_font) > right_w: zone_text = zone_text[:-1] draw.text((rx, y), zone_text, - fill=COLORS["masters_yellow"], font=self.font_detail) + fill=COLORS["masters_yellow"], font=text_font) return img From cfd35def92f2a46fd00366f9efb5e56e799cdd0c Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 10 Apr 2026 13:04:33 -0400 Subject: [PATCH 4/8] fix(masters-tournament): layout fixes for 32px-height displays (128x32, 256x32) Addresses multiple layout issues reported on 2-chain and 4-chain 64x32 displays where the large tier's vertical measurements overflowed 32px. - Add compact tier overrides for wide-short <=32px (header=8, footer=5, row=7) with appropriately sized fonts - Hole card compact layout: text stacked left, course image on right (replaces two-column text-only layout) - Fun facts in vegas mode: render as single-line wide cards for natural horizontal scroll reveal instead of truncating - Fun facts: respect user's enabled setting in vegas mode - Fun facts: calculate scroll steps from display height instead of hardcoding 5; increase advance interval to 3s - Tee times: stack player names vertically on 48+ displays; compact single-line layout on <48px - BDF font search: add Path(__file__)-relative path so 5x7.bdf is found regardless of working directory Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/masters-tournament/manager.py | 30 ++-- .../masters-tournament/masters_renderer.py | 169 ++++++++++++++---- .../masters_renderer_enhanced.py | 95 +++++----- 3 files changed, 198 insertions(+), 96 deletions(-) diff --git a/plugins/masters-tournament/manager.py b/plugins/masters-tournament/manager.py index 4fff105..975d6af 100644 --- a/plugins/masters-tournament/manager.py +++ b/plugins/masters-tournament/manager.py @@ -124,7 +124,7 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man self._last_hole_advance = {} # per-mode hole timers self._hole_switch_interval = config.get("hole_display_duration", 15) self._last_fact_advance = 0 - self._fact_advance_interval = 2 # seconds between scroll steps + self._fact_advance_interval = 3 # seconds between scroll steps self._last_page_advance = {} # per-mode page timers self._page_interval = config.get("page_display_duration", 15) @@ -508,8 +508,13 @@ def _display_fun_facts(self, force_clear: bool) -> bool: if now - self._last_fact_advance >= self._fact_advance_interval: self._fact_scroll += 1 self._last_fact_advance = now - # Move to next fact after scrolling through - if self._fact_scroll > 5: + # Calculate how many scroll steps are needed based on display height. + # Each step reveals one more line; we need enough steps to show all + # wrapped lines of the longest facts (~12 lines at 64px wide). + line_h = 8 # approximate height of detail font + spacing + visible = max(1, (self.display_height - self.renderer.header_height - 8) // line_h) + max_scroll = max(5, 15 // visible) + if self._fact_scroll > max_scroll: self._fact_index += 1 self._fact_scroll = 0 return result @@ -570,13 +575,18 @@ def get_vegas_content(self) -> Optional[List[Image.Image]]: if card: cards.append(card) - # Fun facts - for i in range(5): - card = self.renderer.render_fun_fact( - i, card_width=cw, card_height=ch, - ) - if card: - cards.append(card) + # Fun facts — respect user's enabled setting. + # Use single-line wide cards so horizontal scroll reveals the full text. + fun_facts_enabled = self.config.get("display_modes", {}).get( + "fun_facts", {} + ).get("enabled", True) + if fun_facts_enabled: + for i in range(5): + card = self.renderer.render_fun_fact_vegas( + i, card_height=ch, + ) + if card: + cards.append(card) return cards if cards else None diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index a79e254..0dd2b8a 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -81,6 +81,7 @@ "assets/fonts", "../../../assets/fonts", "../../assets/fonts", + str(Path(__file__).parent.parent.parent / "assets" / "fonts"), str(Path.home() / "Github" / "LEDMatrix" / "assets" / "fonts"), ] @@ -280,6 +281,19 @@ def _configure_tier(self): flag_h = max(8, min(self.row_height + 1, 10)) self.flag_size = (int(flag_h * 1.4), flag_h) + # Wide-short panels with very limited height (128x32, 256x32) need + # compact vertical sizing — the large-tier defaults consume too much + # of the 32px budget (header 11 + footer 6 + row 9 = 26 of 32). + if self.is_wide_short and self.height <= 32: + self.row_height = 7 + self.header_height = 8 + self.footer_height = 5 + self.show_headshot = False + self.headshot_size = 0 + self.show_country = False + flag_h = max(6, min(self.row_height, 7)) + self.flag_size = (int(flag_h * 1.4), flag_h) + # Compute max_players from actual available vertical space. available_h = self.height - self.header_height - self.footer_height - 2 slot_h = self.row_height + self.row_gap @@ -302,6 +316,11 @@ def _load_fonts(self): self.font_score = _load_font("medium") self.font_detail = _load_font("small") + # Wide-short 32px: PressStart2P at 8px is too tall for 8px header + if self.is_wide_short and self.height <= 32: + self.font_header = _load_font("small") + self.font_score = _load_font("small") + # ═══════════════════════════════════════════════════════════ # DRAWING HELPERS # ═══════════════════════════════════════════════════════════ @@ -1119,6 +1138,49 @@ def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, return img + def render_fun_fact_vegas(self, fact_index: int = -1, + card_height: int = 32) -> Optional[Image.Image]: + """Render a fun fact as a single-line wide card for vegas horizontal scroll. + + The card is exactly as wide as needed to fit the full text on one + line below the header, so the vegas scroller reveals it naturally. + """ + ch = card_height + + if fact_index < 0: + fact = get_random_fun_fact() + else: + fact = get_fun_fact_by_index(fact_index) + + font = self.font_detail + # Measure the full single-line text width + tmp = Image.new("RGB", (1, 1)) + tmp_draw = ImageDraw.Draw(tmp) + title = "DID YOU KNOW?" + title_w = self._text_width(tmp_draw, title, self.font_header) + 8 + text_w = self._text_width(tmp_draw, fact, font) + text_h = self._text_height(tmp_draw, "Ag", font) + + # Card wide enough for header title or text, whichever is longer + cw = max(title_w, text_w + 10) + + img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"], + width=cw, height=ch) + draw = ImageDraw.Draw(img) + + # Header + header_h = self.header_height + draw.rectangle([(0, 0), (cw - 1, header_h - 1)], fill=COLORS["masters_green"]) + draw.line([(0, header_h - 1), (cw, header_h - 1)], fill=COLORS["masters_yellow"]) + self._text_shadow(draw, (3, 1), title, self.font_header, COLORS["masters_yellow"]) + + # Single line of text, vertically centered in remaining space + content_top = header_h + 1 + text_y = content_top + max(0, (ch - content_top - text_h) // 2) + draw.text((5, text_y), fact, fill=COLORS["white"], font=font) + + return img + # ═══════════════════════════════════════════════════════════ # TOURNAMENT STATS - Paginated (2 pages) # ═══════════════════════════════════════════════════════════ @@ -1180,51 +1242,88 @@ def render_schedule(self, schedule_data: List[Dict], page: int = 0) -> Optional[ content_top = self.header_height + 2 content_bottom = self.height - self.footer_height - 2 - entry_h = (self.row_height + self.row_gap) * 2 + 2 - rows = max(1, (content_bottom - content_top) // entry_h) + avail_h = content_bottom - content_top two_column = self.is_wide_short cols = 2 if two_column else 1 - per_page = rows * cols - - total_pages = max(1, (len(schedule_data) + per_page - 1) // per_page) - page = page % total_pages - start = page * per_page - entries = schedule_data[start : start + per_page] - col_w = self.width // cols - if two_column: - draw.line([(col_w, content_top), (col_w, content_bottom)], - fill=COLORS["masters_dark"]) # Masters pairings are almost always threesomes; always build text # from the full list and let the width-clipping loop shorten it. - # Dropping the third golfer up front would hide half of who's in the - # group on wide-short layouts. name_budget = 10 if not two_column else 9 - for i, entry in enumerate(entries): - col = i // rows - row = i % rows - cx = col * col_w + 3 - cx_right = (col + 1) * col_w - 3 - y = content_top + row * entry_h - - # Time in yellow - time_text = entry.get("time", "") - draw.text((cx, y), time_text, - fill=COLORS["masters_yellow"], font=self.font_body) - y += self.row_height + 1 + if self.height >= 48: + # ── Standard layout: time on one line, each player stacked below ── + detail_h = self._text_height(draw, "Ag", self.font_detail) + 1 + # Time row + up to 3 player rows + gap + player_rows = min(3, max(1, (avail_h - self.row_height - 1) // detail_h)) + entry_h = self.row_height + 1 + player_rows * detail_h + 2 + rows = max(1, avail_h // entry_h) + per_page = rows * cols + + total_pages = max(1, (len(schedule_data) + per_page - 1) // per_page) + page = page % total_pages + entries = schedule_data[page * per_page : (page + 1) * per_page] + + if two_column: + draw.line([(col_w, content_top), (col_w, content_bottom)], + fill=COLORS["masters_dark"]) + + for i, entry in enumerate(entries): + col = i // rows + row = i % rows + cx = col * col_w + 3 + cx_right = (col + 1) * col_w - 3 + y = content_top + row * entry_h + + time_text = entry.get("time", "") + draw.text((cx, y), time_text, + fill=COLORS["masters_yellow"], font=self.font_body) + y += self.row_height + 1 + + players = entry.get("players", []) or [] + for p in players[:player_rows]: + name = format_player_name(p, name_budget) + while name and self._text_width(draw, name, self.font_detail) > (cx_right - cx - 3): + name = name[:-1] + draw.text((cx + 3, y), name, + fill=COLORS["white"], font=self.font_detail) + y += detail_h + else: + # ── Compact layout (height < 48): tighter spacing ── + # Time + comma-separated players on adjacent lines with minimal gap + entry_h = self.row_height + self.row_gap + self.row_height + rows = max(1, avail_h // entry_h) + per_page = rows * cols + + total_pages = max(1, (len(schedule_data) + per_page - 1) // per_page) + page = page % total_pages + entries = schedule_data[page * per_page : (page + 1) * per_page] + + if two_column: + draw.line([(col_w, content_top), (col_w, content_bottom)], + fill=COLORS["masters_dark"]) + + for i, entry in enumerate(entries): + col = i // rows + row = i % rows + cx = col * col_w + 3 + cx_right = (col + 1) * col_w - 3 + y = content_top + row * entry_h + + time_text = entry.get("time", "") + draw.text((cx, y), time_text, + fill=COLORS["masters_yellow"], font=self.font_detail) + y += self.row_height + self.row_gap - # Players — build from full list, clip to column width - players = entry.get("players", []) or [] - players_text = ", ".join( - format_player_name(p, name_budget) for p in players - ) - while players_text and self._text_width(draw, players_text, self.font_detail) > (cx_right - cx - 3): - players_text = players_text[:-1] - draw.text((cx + 3, y), players_text, - fill=COLORS["white"], font=self.font_detail) + players = entry.get("players", []) or [] + players_text = ", ".join( + format_player_name(p, name_budget) for p in players + ) + while players_text and self._text_width(draw, players_text, self.font_detail) > (cx_right - cx - 3): + players_text = players_text[:-1] + draw.text((cx + 3, y), players_text, + fill=COLORS["white"], font=self.font_detail) self._draw_page_dots(draw, page, total_pages) return img diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py index 553196d..05a5b6a 100644 --- a/plugins/masters-tournament/masters_renderer_enhanced.py +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -261,6 +261,7 @@ def render_hole_card(self, hole_number: int, return self._render_hole_card_with_image( img, draw, hole_number, hole_info, cw, ch, ) + return self._render_hole_card_compact( img, draw, hole_number, hole_info, cw, ch, ) @@ -381,86 +382,78 @@ def _render_hole_card_compact(self, img, draw, hole_number: int, ch: Optional[int] = None) -> Image.Image: """Compact hole card for vertical resolutions below 48px. - Par and Yards sit to the RIGHT of Hole#/Name instead of underneath - so everything fits in less vertical space. Zone badge is drawn - under Par/Yards when there's room. + All text stacked on the left, course image on the right. Layout: - ┌─────────────┬─────────────┐ - │ #12 │ Par 3 │ - │ Golden Bell │ 155y │ - │ │ (AMEN COR) │ - └─────────────┴─────────────┘ - - Uses the 5by7 font for the text — slightly narrower than the - default 4x6 so long hole names like "Golden Bell" fit more - comfortably in the left column. + ┌──────────────┬────────────┐ + │ #12 │ │ + │ Golden Bell │ [course] │ + │ Par 3 155y │ [ map ] │ + │ AMEN CORNER │ │ + └──────────────┴────────────┘ """ if cw is None: cw = self.width if ch is None: ch = self.height - # 5x7 BDF bitmap font — pixel-perfect at native 5x7 size. PIL's TTF - # anti-aliasing washes out small pixel fonts, so we load the BDF - # directly via PIL.BdfFontFile for crisp 1:1 glyph rendering. Falls - # back to self.font_detail / self.font_body if the BDF file isn't on - # the search path. text_font = _load_bdf_font("5x7.bdf") or self.font_detail hole_font = _load_bdf_font("5x7.bdf") or self.font_body - # --- LEGACY 4x6 path, kept commented for quick revert if the 5x7 - # --- BDF causes issues on the Pi. Swap the two blocks to revert. - # text_font = _load_font_sized("4x6-font.ttf", 7) or self.font_detail - # hole_font = _load_font_sized("4x6-font.ttf", 8) or self.font_body - - col_w = cw // 2 - # Divider - draw.line([(col_w, 1), (col_w, ch - 2)], - fill=COLORS["masters_yellow"]) + # Text takes ~half, image gets the rest + text_w = max(36, cw // 2) + img_x = text_w + 1 + img_w = cw - img_x - 1 + max_text_w = text_w - 3 line_h = self._text_height(draw, "Ag", text_font) + 1 + y = 0 - # ── Left column: hole number (top) + name (underneath) ── + # Hole number hole_text = f"#{hole_number}" - hw = self._text_width(draw, hole_text, hole_font) - hole_h = self._text_height(draw, hole_text, hole_font) - draw.text(((col_w - hw) // 2, 1), hole_text, + draw.text((1, y), hole_text, fill=COLORS["white"], font=hole_font) + y += line_h + # Hole name name_text = hole_info["name"] - max_name_w = col_w - 4 - while name_text and self._text_width(draw, name_text, text_font) > max_name_w: + while name_text and self._text_width(draw, name_text, text_font) > max_text_w: name_text = name_text[:-1] - name_y = 1 + hole_h + 2 - nw = self._text_width(draw, name_text, text_font) - draw.text(((col_w - nw) // 2, name_y), name_text, + draw.text((1, y), name_text, fill=COLORS["masters_yellow"], font=text_font) - - # ── Right column: Par / yardage [/ zone] stacked ── - rx = col_w + 3 - right_w = cw - rx - 2 - y = 1 - - par_text = f"Par {hole_info['par']}" - draw.text((rx, y), par_text, - fill=COLORS["white"], font=text_font) y += line_h - yard_text = f"{hole_info['yardage']}y" - draw.text((rx, y), yard_text, - fill=COLORS["light_gray"], font=text_font) + # Par + yardage on same line + info_text = f"Par {hole_info['par']} {hole_info['yardage']}y" + while info_text and self._text_width(draw, info_text, text_font) > max_text_w: + info_text = info_text[:-1] + draw.text((1, y), info_text, + fill=COLORS["white"], font=text_font) y += line_h - # Zone only if there's a full text row of headroom left + # Zone if room zone = hole_info.get("zone") - if zone and y + line_h <= ch - 1: + if zone and y + line_h <= ch: zone_text = zone.upper() - while zone_text and self._text_width(draw, zone_text, text_font) > right_w: + while zone_text and self._text_width(draw, zone_text, text_font) > max_text_w: zone_text = zone_text[:-1] - draw.text((rx, y), zone_text, + draw.text((1, y), zone_text, fill=COLORS["masters_yellow"], font=text_font) + # Divider between text and image + draw.line([(text_w, 0), (text_w, ch - 1)], + fill=COLORS["masters_yellow"]) + + # Course image on the right + hole_img = self.logo_loader.get_hole_image( + hole_number, max_width=img_w, max_height=ch - 2, + ) + if hole_img: + hx = img_x + (img_w - hole_img.width) // 2 + hy = (ch - hole_img.height) // 2 + img.paste(hole_img, (hx, hy), + hole_img if hole_img.mode == "RGBA" else None) + return img def render_live_alert( From 3256cdc174a608b0ec30266013e96a4dab6d707e Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 10 Apr 2026 13:34:50 -0400 Subject: [PATCH 5/8] =?UTF-8?q?fix(masters-tournament):=20review=20fixes?= =?UTF-8?q?=20=E2=80=94=20scroll=20accuracy,=20version=20bump,=20width=20g?= =?UTF-8?q?uards,=20temp=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fun facts scroll: compute max_scroll from actual wrapped line count via new get_fun_fact_line_count() instead of fixed 15//visible heuristic - Bump manifest version to 2.3.0 for new behavior/config changes - Clamp hole card column widths: fall back to text-only layout when card is too narrow for a useful image column (< 20px) - Register atexit cleanup for BDF temp directory so masters_bdf_* dirs don't accumulate in /tmp Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins.json | 4 +- plugins/masters-tournament/manager.py | 11 +-- plugins/masters-tournament/manifest.json | 6 +- .../masters-tournament/masters_renderer.py | 49 ++++++++++ .../masters_renderer_enhanced.py | 97 +++++++++++-------- 5 files changed, 115 insertions(+), 52 deletions(-) diff --git a/plugins.json b/plugins.json index 11a4eaf..2c5aab6 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-04-09", + "last_updated": "2026-04-10", "plugins": [ { "id": "hello-world", @@ -702,7 +702,7 @@ "last_updated": "2026-04-09", "verified": true, "screenshot": "", - "latest_version": "2.2.7" + "latest_version": "2.3.0" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/manager.py b/plugins/masters-tournament/manager.py index 975d6af..3e251d3 100644 --- a/plugins/masters-tournament/manager.py +++ b/plugins/masters-tournament/manager.py @@ -508,12 +508,11 @@ def _display_fun_facts(self, force_clear: bool) -> bool: if now - self._last_fact_advance >= self._fact_advance_interval: self._fact_scroll += 1 self._last_fact_advance = now - # Calculate how many scroll steps are needed based on display height. - # Each step reveals one more line; we need enough steps to show all - # wrapped lines of the longest facts (~12 lines at 64px wide). - line_h = 8 # approximate height of detail font + spacing - visible = max(1, (self.display_height - self.renderer.header_height - 8) // line_h) - max_scroll = max(5, 15 // visible) + # Derive scroll steps from actual wrapped line count for this fact. + total_lines, visible = self.renderer.get_fun_fact_line_count( + self._fact_index, + ) + max_scroll = max(1, total_lines - visible + 1) if self._fact_scroll > max_scroll: self._fact_index += 1 self._fact_scroll = 0 diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index ba05e19..2b4b6c1 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.2.7", + "version": "2.3.0", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -44,8 +44,8 @@ }, "versions": [ { - "version": "2.2.7", - "released": "2026-04-09", + "version": "2.3.0", + "released": "2026-04-10", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index 0dd2b8a..cc548ee 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -153,6 +153,24 @@ def _load_font_sized(filename: str, size: int) -> Optional[ImageFont.ImageFont]: _BDF_TEMP_DIR: Optional[str] = None +def _cleanup_bdf_temp() -> None: + """Remove the BDF temp directory on process exit.""" + global _BDF_TEMP_DIR + if _BDF_TEMP_DIR is None: + return + try: + import shutil + shutil.rmtree(_BDF_TEMP_DIR, ignore_errors=True) + except Exception: + pass + _BDF_TEMP_DIR = None + _BDF_FONT_CACHE.clear() + + +import atexit +atexit.register(_cleanup_bdf_temp) + + def _load_bdf_font(filename: str) -> Optional[ImageFont.ImageFont]: """Load a BDF bitmap font and return it as an ImageFont. @@ -1072,6 +1090,37 @@ def render_past_champions(self, page: int = 0) -> Optional[Image.Image]: # FUN FACTS - Scrolling text # ═══════════════════════════════════════════════════════════ + def get_fun_fact_line_count(self, fact_index: int, + card_width: Optional[int] = None, + card_height: Optional[int] = None) -> tuple: + """Return (total_lines, visible_lines) for a fun fact at this display size.""" + cw = card_width if card_width is not None else self.width + ch = card_height if card_height is not None else self.height + + fact = get_fun_fact_by_index(fact_index) + tmp = Image.new("RGB", (1, 1)) + draw = ImageDraw.Draw(tmp) + + font = self.font_detail + line_h = self._text_height(draw, "Ag", font) + 2 + max_w = cw - 10 + content_top = self.header_height + 4 + + words = fact.split() + lines = 1 + current_line = "" + for word in words: + test = f"{current_line} {word}".strip() + if self._text_width(draw, test, font) <= max_w: + current_line = test + else: + if current_line: + lines += 1 + current_line = word + + visible = max(1, (ch - content_top - 4) // line_h) + return lines, visible + def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, card_width: Optional[int] = None, card_height: Optional[int] = None) -> Optional[Image.Image]: diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py index 05a5b6a..92b4243 100644 --- a/plugins/masters-tournament/masters_renderer_enhanced.py +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -276,11 +276,18 @@ def _render_hole_card_with_image(self, img, draw, hole_number: int, """ # Left panel width: wide enough to fit "Golden Bell" and par/yardage text. # Grows with card width so 256x64 cards get more text room than 128x48. + # If the card is too narrow for a useful image, let text fill the width. left_w = max(38, min(56, cw // 3)) + img_w = cw - left_w - 4 + if img_w < 20: + left_w = cw + + show_image = left_w < cw # Left panel background strip draw.rectangle([(0, 0), (left_w - 1, ch - 1)], fill=COLORS["masters_dark"]) - draw.line([(left_w - 1, 0), (left_w - 1, ch)], fill=COLORS["masters_yellow"]) + if show_image: + draw.line([(left_w - 1, 0), (left_w - 1, ch)], fill=COLORS["masters_yellow"]) line_h = self._text_height(draw, "A", self.font_detail) + 1 max_text_w = left_w - 4 @@ -348,31 +355,32 @@ def _render_hole_card_with_image(self, img, draw, hole_number: int, draw.text(((left_w - lw) // 2, name_y + i * line_h), line, fill=COLORS["masters_yellow"], font=self.font_detail) - # Right side: hole layout image - img_x = left_w + 2 - img_w = cw - img_x - 2 - img_h = ch - 4 - hole_img = self.logo_loader.get_hole_image( - hole_number, - max_width=img_w, - max_height=img_h, - ) - if hole_img: - hx = img_x + (img_w - hole_img.width) // 2 - hy = (ch - hole_img.height) // 2 - img.paste(hole_img, (hx, hy), hole_img if hole_img.mode == "RGBA" else None) - - # Zone badge at bottom-right corner over the hole image - zone = hole_info.get("zone") - if zone and self.tier != "tiny": - badge = zone.upper() - bw = self._text_width(draw, badge, self.font_detail) + 4 - bx = cw - bw - 1 - by = ch - 9 - draw.rectangle([(bx, by), (cw - 1, ch - 1)], - fill=COLORS["masters_dark"]) - draw.text((bx + 2, by + 1), badge, - fill=COLORS["masters_yellow"], font=self.font_detail) + if show_image: + # Right side: hole layout image + img_x = left_w + 2 + actual_img_w = cw - img_x - 2 + img_h = ch - 4 + hole_img = self.logo_loader.get_hole_image( + hole_number, + max_width=actual_img_w, + max_height=img_h, + ) + if hole_img: + hx = img_x + (actual_img_w - hole_img.width) // 2 + hy = (ch - hole_img.height) // 2 + img.paste(hole_img, (hx, hy), hole_img if hole_img.mode == "RGBA" else None) + + # Zone badge at bottom-right corner over the hole image + zone = hole_info.get("zone") + if zone and self.tier != "tiny": + badge = zone.upper() + bw = self._text_width(draw, badge, self.font_detail) + 4 + bx = cw - bw - 1 + by = ch - 9 + draw.rectangle([(bx, by), (cw - 1, ch - 1)], + fill=COLORS["masters_dark"]) + draw.text((bx + 2, by + 1), badge, + fill=COLORS["masters_yellow"], font=self.font_detail) return img @@ -400,10 +408,16 @@ def _render_hole_card_compact(self, img, draw, hole_number: int, text_font = _load_bdf_font("5x7.bdf") or self.font_detail hole_font = _load_bdf_font("5x7.bdf") or self.font_body - # Text takes ~half, image gets the rest - text_w = max(36, cw // 2) + # Text takes ~half, image gets the rest. + # If the card is too narrow for both columns, skip the image. + min_text_w = 36 + min_img_w = 20 + text_w = max(min_text_w, cw // 2) + img_w = cw - text_w - 2 + show_image = img_w >= min_img_w + if not show_image: + text_w = cw img_x = text_w + 1 - img_w = cw - img_x - 1 max_text_w = text_w - 3 line_h = self._text_height(draw, "Ag", text_font) + 1 @@ -440,19 +454,20 @@ def _render_hole_card_compact(self, img, draw, hole_number: int, draw.text((1, y), zone_text, fill=COLORS["masters_yellow"], font=text_font) - # Divider between text and image - draw.line([(text_w, 0), (text_w, ch - 1)], - fill=COLORS["masters_yellow"]) + if show_image: + # Divider between text and image + draw.line([(text_w, 0), (text_w, ch - 1)], + fill=COLORS["masters_yellow"]) - # Course image on the right - hole_img = self.logo_loader.get_hole_image( - hole_number, max_width=img_w, max_height=ch - 2, - ) - if hole_img: - hx = img_x + (img_w - hole_img.width) // 2 - hy = (ch - hole_img.height) // 2 - img.paste(hole_img, (hx, hy), - hole_img if hole_img.mode == "RGBA" else None) + # Course image on the right + hole_img = self.logo_loader.get_hole_image( + hole_number, max_width=img_w, max_height=ch - 2, + ) + if hole_img: + hx = img_x + (img_w - hole_img.width) // 2 + hy = (ch - hole_img.height) // 2 + img.paste(hole_img, (hx, hy), + hole_img if hole_img.mode == "RGBA" else None) return img From d236f4a7fc6be093baef7b5e5119b8c037bba4a7 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 10 Apr 2026 14:03:30 -0400 Subject: [PATCH 6/8] fix(masters-tournament): scroll off-by-one, BDF cleanup logging, extract wrap helper - Fix off-by-one in fun facts scroll: use >= instead of > so the fact advances immediately after showing the last scroll position - BDF temp cleanup: log on failure instead of silently swallowing, use try/finally so cache is always cleared - Extract _wrap_text() helper used by both get_fun_fact_line_count() and render_fun_fact() to eliminate duplicate wrapping logic Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/masters-tournament/manager.py | 2 +- .../masters-tournament/masters_renderer.py | 64 +++++++++---------- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/plugins/masters-tournament/manager.py b/plugins/masters-tournament/manager.py index 3e251d3..fccd36f 100644 --- a/plugins/masters-tournament/manager.py +++ b/plugins/masters-tournament/manager.py @@ -513,7 +513,7 @@ def _display_fun_facts(self, force_clear: bool) -> bool: self._fact_index, ) max_scroll = max(1, total_lines - visible + 1) - if self._fact_scroll > max_scroll: + if self._fact_scroll >= max_scroll: self._fact_index += 1 self._fact_scroll = 0 return result diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index cc548ee..9bf0fad 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -160,11 +160,12 @@ def _cleanup_bdf_temp() -> None: return try: import shutil - shutil.rmtree(_BDF_TEMP_DIR, ignore_errors=True) - except Exception: - pass - _BDF_TEMP_DIR = None - _BDF_FONT_CACHE.clear() + shutil.rmtree(_BDF_TEMP_DIR) + except Exception as e: + logger.debug("Failed to remove BDF temp dir %s: %s", _BDF_TEMP_DIR, e) + finally: + _BDF_TEMP_DIR = None + _BDF_FONT_CACHE.clear() import atexit @@ -1090,6 +1091,24 @@ def render_past_champions(self, page: int = 0) -> Optional[Image.Image]: # FUN FACTS - Scrolling text # ═══════════════════════════════════════════════════════════ + def _wrap_text(self, text: str, max_w: int, + font, draw) -> List[str]: + """Word-wrap *text* to fit within *max_w* pixels using *font*.""" + words = text.split() + lines: List[str] = [] + current_line = "" + for word in words: + test = f"{current_line} {word}".strip() + if self._text_width(draw, test, font) <= max_w: + current_line = test + else: + if current_line: + lines.append(current_line) + current_line = word + if current_line: + lines.append(current_line) + return lines + def get_fun_fact_line_count(self, fact_index: int, card_width: Optional[int] = None, card_height: Optional[int] = None) -> tuple: @@ -1103,23 +1122,11 @@ def get_fun_fact_line_count(self, fact_index: int, font = self.font_detail line_h = self._text_height(draw, "Ag", font) + 2 - max_w = cw - 10 content_top = self.header_height + 4 - words = fact.split() - lines = 1 - current_line = "" - for word in words: - test = f"{current_line} {word}".strip() - if self._text_width(draw, test, font) <= max_w: - current_line = test - else: - if current_line: - lines += 1 - current_line = word - + lines = self._wrap_text(fact, cw - 10, font, draw) visible = max(1, (ch - content_top - 4) // line_h) - return lines, visible + return len(lines), visible def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, card_width: Optional[int] = None, @@ -1151,23 +1158,12 @@ def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, line_h = self._text_height(draw, "Ag", font) + 2 # Extra line spacing max_w = cw - 10 # More horizontal padding - words = fact.split() - lines = [] - current_line = "" - for word in words: - test = f"{current_line} {word}".strip() - if self._text_width(draw, test, font) <= max_w: - current_line = test - else: - if current_line: - lines.append(current_line) - current_line = word - if current_line: - lines.append(current_line) + lines = self._wrap_text(fact, max_w, font, draw) + total_lines = len(lines) # Apply scroll offset (for long facts) visible_lines = max(1, (ch - content_top - 4) // line_h) - if len(lines) > visible_lines: + if total_lines > visible_lines: start_line = scroll_offset % max(1, len(lines) - visible_lines + 1) lines = lines[start_line : start_line + visible_lines] @@ -1178,7 +1174,7 @@ def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, y += line_h # Scroll indicator if text is long - if len(words) > visible_lines * 4: # Rough heuristic + if total_lines > visible_lines: # Small down arrow ax = cw - 6 ay = ch - 6 From 433d469d144c904566919da91c326b14cc6df370 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 10 Apr 2026 14:14:35 -0400 Subject: [PATCH 7/8] fix(masters-tournament): don't silently drop players in stacked tee times When player_rows < len(players), fold remaining players into the last visible line as a comma-separated string instead of silently dropping them. Handles edge case where player_rows == 1 by folding all players into that single line. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../masters-tournament/masters_renderer.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index 9bf0fad..384a507 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -1327,11 +1327,34 @@ def render_schedule(self, schedule_data: List[Dict], page: int = 0) -> Optional[ y += self.row_height + 1 players = entry.get("players", []) or [] - for p in players[:player_rows]: - name = format_player_name(p, name_budget) - while name and self._text_width(draw, name, self.font_detail) > (cx_right - cx - 3): - name = name[:-1] - draw.text((cx + 3, y), name, + max_name_w = cx_right - cx - 3 + if len(players) <= player_rows: + # All players fit on their own line + for p in players: + name = format_player_name(p, name_budget) + while name and self._text_width(draw, name, self.font_detail) > max_name_w: + name = name[:-1] + draw.text((cx + 3, y), name, + fill=COLORS["white"], font=self.font_detail) + y += detail_h + else: + # More players than rows — show first (player_rows-1) + # individually, fold the rest into the last line. + solo = max(0, player_rows - 1) + for p in players[:solo]: + name = format_player_name(p, name_budget) + while name and self._text_width(draw, name, self.font_detail) > max_name_w: + name = name[:-1] + draw.text((cx + 3, y), name, + fill=COLORS["white"], font=self.font_detail) + y += detail_h + # Last line: remaining players comma-separated + overflow = ", ".join( + format_player_name(p, name_budget) for p in players[solo:] + ) + while overflow and self._text_width(draw, overflow, self.font_detail) > max_name_w: + overflow = overflow[:-1] + draw.text((cx + 3, y), overflow, fill=COLORS["white"], font=self.font_detail) y += detail_h else: From d426974825f89e25e981468653a389dbdb5ec590 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 10 Apr 2026 14:23:41 -0400 Subject: [PATCH 8/8] fix(masters-tournament): break oversized words at character boundaries in _wrap_text Words wider than max_w (e.g. long unbroken tokens) are now split character-by-character so no wrapped line exceeds the target width. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/masters-tournament/masters_renderer.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index 384a507..1ea1992 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -1093,11 +1093,25 @@ def render_past_champions(self, page: int = 0) -> Optional[Image.Image]: def _wrap_text(self, text: str, max_w: int, font, draw) -> List[str]: - """Word-wrap *text* to fit within *max_w* pixels using *font*.""" + """Word-wrap *text* to fit within *max_w* pixels using *font*. + + Words wider than *max_w* are broken at character boundaries. + """ words = text.split() lines: List[str] = [] current_line = "" for word in words: + # Break oversized words into chunks that fit. + if self._text_width(draw, word, font) > max_w: + for ch in word: + test = current_line + ch + if self._text_width(draw, test, font) <= max_w: + current_line = test + else: + if current_line: + lines.append(current_line) + current_line = ch + continue test = f"{current_line} {word}".strip() if self._text_width(draw, test, font) <= max_w: current_line = test