From 77d6e971db733190318e6de8f5b07cf1fd16bb9b Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 30 Apr 2025 10:37:59 -0500 Subject: [PATCH] feat: Add configurable TextDisplay module --- config/config.json | 13 +++- src/display_controller.py | 27 ++++--- src/text_display.py | 160 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 src/text_display.py diff --git a/config/config.json b/config/config.json index 9eb83d56..5521bf68 100644 --- a/config/config.json +++ b/config/config.json @@ -42,7 +42,8 @@ "youtube": 20, "mlb_live": 30, "mlb_recent": 20, - "mlb_upcoming": 20 + "mlb_upcoming": 20, + "text_display": 10 } }, "clock": { @@ -142,5 +143,15 @@ "mlb_upcoming": true }, "live_game_duration": 30 + }, + "text_display": { + "enabled": true, + "text": "Your custom text here!", + "font_path": "assets/fonts/5x7.bdf", + "font_size": 7, + "scroll": false, + "scroll_speed": 25, + "text_color": [255, 255, 0], + "background_color": [0, 0, 50] } } \ No newline at end of file diff --git a/src/display_controller.py b/src/display_controller.py index 6acd4b43..d6a0fd27 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -22,6 +22,7 @@ from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManage from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager from src.youtube_display import YouTubeDisplay from src.calendar_manager import CalendarManager +from src.text_display import TextDisplay # Get logger without configuring logger = logging.getLogger(__name__) @@ -47,7 +48,9 @@ class DisplayController: self.news = StockNewsManager(self.config, self.display_manager) if self.config.get('stock_news', {}).get('enabled', False) else None self.calendar = CalendarManager(self.display_manager, self.config) if self.config.get('calendar', {}).get('enabled', False) else None self.youtube = YouTubeDisplay(self.display_manager, self.config) if self.config.get('youtube', {}).get('enabled', False) else None + self.text_display = TextDisplay(self.display_manager, self.config) if self.config.get('text_display', {}).get('enabled', False) else None logger.info(f"Calendar Manager initialized: {'Object' if self.calendar else 'None'}") + logger.info(f"Text Display initialized: {'Object' if self.text_display else 'None'}") logger.info("Display modes initialized in %.3f seconds", time.time() - init_time) # Initialize NHL managers if enabled @@ -105,6 +108,7 @@ class DisplayController: if self.news: self.available_modes.append('stock_news') if self.calendar: self.available_modes.append('calendar') if self.youtube: self.available_modes.append('youtube') + if self.text_display: self.available_modes.append('text_display') # Add NHL display modes if enabled if nhl_enabled: @@ -191,6 +195,7 @@ class DisplayController: if self.news: self.news.update_news_data() if self.calendar: self.calendar.update(time.time()) if self.youtube: self.youtube.update() + if self.text_display: self.text_display.update() # Update NHL managers if self.nhl_live: self.nhl_live.update() @@ -407,26 +412,23 @@ class DisplayController: # Display current mode frame (only for non-live modes) try: if self.current_display_mode == 'clock' and self.clock: - self.clock.display_time(force_clear=self.force_clear) + self.clock.display() elif self.current_display_mode == 'weather_current' and self.weather: - self.weather.display_weather(force_clear=self.force_clear) + self.weather.display_current() elif self.current_display_mode == 'weather_hourly' and self.weather: - self.weather.display_hourly_forecast(force_clear=self.force_clear) + self.weather.display_hourly() elif self.current_display_mode == 'weather_daily' and self.weather: - self.weather.display_daily_forecast(force_clear=self.force_clear) + self.weather.display_daily() elif self.current_display_mode == 'stocks' and self.stocks: - self.stocks.display_stocks(force_clear=self.force_clear) + self.stocks.display() elif self.current_display_mode == 'stock_news' and self.news: - self.news.display_news() + self.news.display() elif self.current_display_mode == 'calendar' and self.calendar: - # Update calendar data if needed - self.calendar.update(current_time) - # Always display the calendar, with force_clear only on mode switch - self.calendar.display(force_clear=self.force_clear) + self.calendar.display() elif self.current_display_mode == 'nhl_recent' and self.nhl_recent: self.nhl_recent.display(force_clear=self.force_clear) @@ -444,7 +446,10 @@ class DisplayController: self.mlb_upcoming.display(force_clear=self.force_clear) elif self.current_display_mode == 'youtube' and self.youtube: - self.youtube.display(force_clear=self.force_clear) + self.youtube.display() + + elif self.current_display_mode == 'text_display' and self.text_display: + self.text_display.display() except Exception as e: logger.error(f"Error updating display for mode {self.current_display_mode}: {e}", exc_info=True) diff --git a/src/text_display.py b/src/text_display.py new file mode 100644 index 00000000..55d9d542 --- /dev/null +++ b/src/text_display.py @@ -0,0 +1,160 @@ +import logging +import time +from PIL import ImageFont +import freetype +import os + +from .display_manager import DisplayManager + +logger = logging.getLogger(__name__) + +class TextDisplay: + def __init__(self, display_manager: DisplayManager, config: dict): + self.display_manager = display_manager + self.config = config.get('text_display', {}) + + self.text = self.config.get('text', "Hello, World!") + self.font_path = self.config.get('font_path', "assets/fonts/PressStart2P-Regular.ttf") + self.font_size = self.config.get('font_size', 8) + self.scroll_enabled = self.config.get('scroll', False) + self.text_color = tuple(self.config.get('text_color', [255, 255, 255])) + self.bg_color = tuple(self.config.get('background_color', [0, 0, 0])) + + self.font = self._load_font() + self.text_width = self._calculate_text_width() + + self.scroll_pos = 0 + self.last_update_time = time.time() + self.scroll_speed = self.config.get('scroll_speed', 30) # Pixels per second + + def _load_font(self): + """Load the specified font file (TTF or BDF).""" + font_path = self.font_path + # Try to resolve relative path from project root + if not os.path.isabs(font_path) and not font_path.startswith('assets/'): + # Assuming relative paths are relative to the project root + # Adjust this logic if paths are relative to src or config + base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + font_path = os.path.join(base_path, font_path) + + elif not os.path.isabs(font_path) and font_path.startswith('assets/'): + # Assuming 'assets/' path is relative to project root + base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + font_path = os.path.join(base_path, font_path) + + + logger.info(f"Attempting to load font: {font_path} at size {self.font_size}") + + if not os.path.exists(font_path): + logger.error(f"Font file not found: {font_path}. Falling back to default.") + return self.display_manager.regular_font # Use default from DisplayManager + + try: + if font_path.lower().endswith('.ttf'): + font = ImageFont.truetype(font_path, self.font_size) + logger.info(f"Loaded TTF font: {self.font_path}") + return font + elif font_path.lower().endswith('.bdf'): + # Use freetype for BDF fonts + face = freetype.Face(font_path) + # BDF fonts often have fixed sizes, freetype handles this + # We might need to adjust how size is used or interpreted for BDF + face.set_pixel_sizes(0, self.font_size) + logger.info(f"Loaded BDF font: {self.font_path} with freetype") + return face + else: + logger.warning(f"Unsupported font type: {font_path}. Falling back.") + return self.display_manager.regular_font + except Exception as e: + logger.error(f"Failed to load font {font_path}: {e}", exc_info=True) + return self.display_manager.regular_font + + def _calculate_text_width(self): + """Calculate the pixel width of the text with the loaded font.""" + try: + return self.display_manager.get_text_width(self.text, self.font) + except Exception as e: + logger.error(f"Error calculating text width: {e}") + return 0 # Default to 0 if calculation fails + + def update(self): + """Update scroll position if scrolling is enabled.""" + if not self.scroll_enabled or self.text_width <= self.display_manager.matrix.width: + self.scroll_pos = 0 # Reset if not scrolling or text fits + return + + current_time = time.time() + delta_time = current_time - self.last_update_time + self.last_update_time = current_time + + # Calculate scroll distance + scroll_delta = delta_time * self.scroll_speed + self.scroll_pos += scroll_delta + + # Reset scroll position when the text has scrolled completely off screen + # Add some padding (e.g., matrix width) before resetting + if self.scroll_pos > self.text_width + self.display_manager.matrix.width: + self.scroll_pos = 0 # Reset to start from the right edge again + + def display(self): + """Draw the text onto the display manager's canvas.""" + self.display_manager.draw.rectangle( + (0, 0, self.display_manager.matrix.width, self.display_manager.matrix.height), + fill=self.bg_color + ) + + matrix_width = self.display_manager.matrix.width + matrix_height = self.display_manager.matrix.height + + # Calculate Y position (center vertically) + # This might need adjustment depending on font metrics + try: + if isinstance(self.font, freetype.Face): + # Estimate height for freetype (BDF) + # Using ascender/descender might be more accurate if available + text_height = self.font.size.height >> 6 + else: + # Use PIL's textbbox for TTF height + bbox = self.display_manager.draw.textbbox((0, 0), self.text, font=self.font) + text_height = bbox[3] - bbox[1] + + y = (matrix_height - text_height) // 2 + # Adjust y based on baseline for PIL fonts if needed + if not isinstance(self.font, freetype.Face): + # Small adjustment often needed for PIL's draw.text + y -= bbox[1] # Subtract the top bearing + + except Exception as e: + logger.warning(f"Could not calculate text height accurately: {e}. Using default.") + y = 0 # Default to top + + if self.scroll_enabled and self.text_width > matrix_width: + # Scrolling text + x = matrix_width - int(self.scroll_pos) + + # Draw text using display_manager's draw_text method + self.display_manager.draw_text( + text=self.text, + x=x, + y=y, + color=self.text_color, + font=self.font # Pass the specific font instance + ) + else: + # Static text (centered horizontally) + x = (matrix_width - self.text_width) // 2 + self.display_manager.draw_text( + text=self.text, + x=x, + y=y, + color=self.text_color, + font=self.font # Pass the specific font instance + ) + # No need to call update_display here, controller should handle it after calling display + + # Add the call to update the display here + self.display_manager.update_display() + + # Reset scroll position for next time if not scrolling + # self.last_update_time = time.time() # Reset time tracking if static +