""" Display Helper Handles common display operations and layouts for LED matrix displays. Extracted from LEDMatrix core to provide reusable functionality for plugins. """ import logging from typing import Any, Dict, Optional, Tuple from PIL import Image, ImageDraw, ImageFont class DisplayHelper: """ Helper class for common display operations and layouts. Provides functionality for: - Creating base images and overlays - Common layout patterns (scorebug, ticker, etc.) - Image compositing and manipulation - Display dimension utilities """ def __init__(self, display_width: int, display_height: int, logger: Optional[logging.Logger] = None): """ Initialize the DisplayHelper. Args: display_width: Width of the LED matrix display display_height: Height of the LED matrix display logger: Optional logger instance """ self.display_width = display_width self.display_height = display_height self.logger = logger or logging.getLogger(__name__) def create_base_image(self, background_color: Tuple[int, int, int] = (0, 0, 0), mode: str = 'RGB') -> Image.Image: """ Create a base image for the display. Args: background_color: Background color (R, G, B) mode: Image mode ('RGB', 'RGBA', etc.) Returns: PIL Image object """ return Image.new(mode, (self.display_width, self.display_height), background_color) def create_overlay(self, background_color: Tuple[int, int, int, int] = (0, 0, 0, 0)) -> Image.Image: """ Create an overlay image for compositing. Args: background_color: Background color with alpha (R, G, B, A) Returns: PIL Image object with alpha channel """ return Image.new('RGBA', (self.display_width, self.display_height), background_color) def composite_images(self, base_image: Image.Image, overlay_image: Image.Image) -> Image.Image: """ Composite overlay onto base image. Args: base_image: Base image (RGB or RGBA) overlay_image: Overlay image (should be RGBA) Returns: Composited image """ if base_image.mode != 'RGBA': base_image = base_image.convert('RGBA') if overlay_image.mode != 'RGBA': overlay_image = overlay_image.convert('RGBA') return Image.alpha_composite(base_image, overlay_image) def draw_scorebug_layout(self, game_data: Dict[str, Any], fonts: Dict[str, ImageFont.ImageFont], home_logo: Optional[Image.Image] = None, away_logo: Optional[Image.Image] = None) -> Image.Image: """ Draw a standard scorebug layout for sports games. Args: game_data: Dictionary containing game information fonts: Dictionary of loaded fonts home_logo: Home team logo (optional) away_logo: Away team logo (optional) Returns: PIL Image with scorebug layout """ # Create base image and overlay main_img = self.create_base_image() overlay = self.create_overlay() draw = ImageDraw.Draw(overlay) # Extract game data home_score = str(game_data.get('home_score', '0')) away_score = str(game_data.get('away_score', '0')) home_abbr = game_data.get('home_abbr', 'HOME') away_abbr = game_data.get('away_abbr', 'AWAY') status_text = game_data.get('status_text', '') period_text = game_data.get('period_text', '') clock = game_data.get('clock', '') # Draw logos if provided if home_logo and away_logo: self._draw_logos(main_img, home_logo, away_logo) # Draw one combined top line (period/status/clock all share y=1 — # drawing them separately overprinted each other). top_line = " ".join(p for p in [period_text, status_text, clock] if p) if top_line: self._draw_centered_text(draw, top_line, fonts.get('time', fonts.get('status')), y_position=1) # Draw scores (center) score_text = f"{away_score}-{home_score}" self._draw_centered_text(draw, score_text, fonts.get('score'), y_position=self.display_height // 2 - 3) # Draw team abbreviations (bottom) if away_abbr: self._draw_text_with_outline(draw, away_abbr, (0, self.display_height - 12), fonts.get('team')) if home_abbr: text_width = draw.textlength(home_abbr, font=fonts.get('team')) self._draw_text_with_outline(draw, home_abbr, (self.display_width - text_width, self.display_height - 12), fonts.get('team')) # Composite and return final_img = self.composite_images(main_img, overlay) return final_img.convert('RGB') def draw_ticker_layout(self, text: str, font: ImageFont.ImageFont, background_color: Tuple[int, int, int] = (0, 0, 0), text_color: Tuple[int, int, int] = (255, 255, 255), scroll_speed: int = 1) -> Image.Image: """ Draw a ticker/scrolling text layout. Renders a single static frame with the text at the left edge; the caller advances the scroll by re-rendering or shifting. The scroll_speed parameter is accepted for API compatibility but does not affect this frame. (Previously the text was drawn starting at x=display_width — entirely off-canvas — so every frame was blank.) Args: text: Text to display font: Font to use background_color: Background color text_color: Text color scroll_speed: Accepted for compatibility; unused per-frame Returns: PIL Image with ticker layout """ img = self.create_base_image(background_color) draw = ImageDraw.Draw(img) self._draw_text_with_outline(draw, text, (0, self.display_height // 2 - 6), font, fill=text_color) return img def draw_centered_text(self, text: str, font: ImageFont.ImageFont, background_color: Tuple[int, int, int] = (0, 0, 0), text_color: Tuple[int, int, int] = (255, 255, 255)) -> Image.Image: """ Draw centered text on the display. Args: text: Text to display font: Font to use background_color: Background color text_color: Text color Returns: PIL Image with centered text """ img = self.create_base_image(background_color) draw = ImageDraw.Draw(img) # Calculate center position text_width = draw.textlength(text, font=font) text_height = 12 # Approximate height x = (self.display_width - text_width) // 2 y = (self.display_height - text_height) // 2 # Draw text self._draw_text_with_outline(draw, text, (x, y), font, fill=text_color) return img def draw_error_message(self, message: str = "Error") -> Image.Image: """ Draw a simple error message. Args: message: Error message to display Returns: PIL Image with error message """ # Dark red background, white text font = ImageFont.load_default() return self.draw_centered_text(message, font, (50, 0, 0), (255, 255, 255)) def draw_no_data_message(self, message: str = "No Data") -> Image.Image: """ Draw a no data message. Args: message: Message to display Returns: PIL Image with no data message """ font = ImageFont.load_default() return self.draw_centered_text(message, font, (0, 0, 0), (150, 150, 150)) def get_display_dimensions(self) -> Tuple[int, int]: """ Get display dimensions. Returns: (width, height) tuple """ return (self.display_width, self.display_height) def is_portrait(self) -> bool: """ Check if display is in portrait orientation. Returns: True if height > width """ return self.display_height > self.display_width def is_landscape(self) -> bool: """ Check if display is in landscape orientation. Returns: True if width > height """ return self.display_width > self.display_height def get_center_position(self) -> Tuple[int, int]: """ Get center position of the display. Returns: (x, y) center position """ return (self.display_width // 2, self.display_height // 2) def _draw_logos(self, img: Image.Image, home_logo: Image.Image, away_logo: Image.Image) -> None: """Draw team logos on the image.""" center_y = self.display_height // 2 # Home logo (right side) if home_logo: home_x = self.display_width - home_logo.width + 10 home_y = center_y - (home_logo.height // 2) img.paste(home_logo, (home_x, home_y), home_logo) # Away logo (left side) if away_logo: away_x = -10 away_y = center_y - (away_logo.height // 2) img.paste(away_logo, (away_x, away_y), away_logo) def _draw_centered_text(self, draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, y_position: int) -> None: """Draw centered text at specified y position.""" text_width = draw.textlength(text, font=font) x = (self.display_width - text_width) // 2 self._draw_text_with_outline(draw, text, (x, y_position), font) def _draw_text_with_outline(self, draw: ImageDraw.ImageDraw, text: str, position: Tuple[int, int], font: ImageFont.ImageFont, fill: Tuple[int, int, int] = (255, 255, 255), outline_color: Tuple[int, int, int] = (0, 0, 0)) -> None: """Draw text with outline for better readability.""" x, y = position # Draw outline for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: draw.text((x + dx, y + dy), text, font=font, fill=outline_color) # Draw main text draw.text((x, y), text, font=font, fill=fill)