diff --git a/assets/stocks/.gitkeep b/assets/stocks/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/display_controller.py b/src/display_controller.py index ddc555ee..96c5f3d9 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -17,11 +17,23 @@ class DisplayController: self.config_manager = ConfigManager() self.config = self.config_manager.load_config() self.display_manager = DisplayManager(self.config.get('display', {})) - self.clock = Clock(display_manager=self.display_manager) - self.weather = WeatherManager(self.config, self.display_manager) - self.stocks = StockManager(self.config, self.display_manager) - self.news = StockNewsManager(self.config, self.display_manager) - self.current_display = 'clock' + + # Only initialize enabled modules + self.clock = Clock(display_manager=self.display_manager) if self.config.get('clock', {}).get('enabled', False) else None + self.weather = WeatherManager(self.config, self.display_manager) if self.config.get('weather', {}).get('enabled', False) else None + self.stocks = StockManager(self.config, self.display_manager) if self.config.get('stocks', {}).get('enabled', False) else None + self.news = StockNewsManager(self.config, self.display_manager) if self.config.get('stock_news', {}).get('enabled', False) else None + + # Set initial display to first enabled module + self.current_display = 'clock' # Default + if not self.clock: + if self.weather: + self.current_display = 'weather' + elif self.stocks: + self.current_display = 'stocks' + elif self.news: + self.current_display = 'stock_news' + self.weather_mode = 'current' # current, hourly, or daily self.last_switch = time.time() self.force_clear = True # Start with a clear screen @@ -58,12 +70,12 @@ class DisplayController: next_display = None if self.current_display == 'clock': - if self.config.get('weather', {}).get('enabled', False): + if self.weather: next_display = 'weather' self.weather_mode = 'current' - elif self.config.get('stocks', {}).get('enabled', False): + elif self.stocks: next_display = 'stocks' - elif self.config.get('stock_news', {}).get('enabled', False): + elif self.news: next_display = 'stock_news' else: next_display = 'clock' @@ -76,34 +88,34 @@ class DisplayController: next_display = 'weather' self.weather_mode = 'daily' else: # daily - if self.config.get('stocks', {}).get('enabled', False): + if self.stocks: next_display = 'stocks' - elif self.config.get('stock_news', {}).get('enabled', False): + elif self.news: next_display = 'stock_news' - elif self.config.get('clock', {}).get('enabled', False): + elif self.clock: next_display = 'clock' else: next_display = 'weather' self.weather_mode = 'current' elif self.current_display == 'stocks': - if self.config.get('stock_news', {}).get('enabled', False): + if self.news: next_display = 'stock_news' - elif self.config.get('clock', {}).get('enabled', False): + elif self.clock: next_display = 'clock' - elif self.config.get('weather', {}).get('enabled', False): + elif self.weather: next_display = 'weather' self.weather_mode = 'current' else: next_display = 'stocks' else: # stock_news - if self.config.get('clock', {}).get('enabled', False): + if self.clock: next_display = 'clock' - elif self.config.get('weather', {}).get('enabled', False): + elif self.weather: next_display = 'weather' self.weather_mode = 'current' - elif self.config.get('stocks', {}).get('enabled', False): + elif self.stocks: next_display = 'stocks' else: next_display = 'stock_news' @@ -117,10 +129,10 @@ class DisplayController: # Display current screen try: - if self.current_display == 'clock' and self.config.get('clock', {}).get('enabled', False): + if self.current_display == 'clock' and self.clock: self.clock.display_time(force_clear=self.force_clear) time.sleep(self.update_interval) - elif self.current_display == 'weather' and self.config.get('weather', {}).get('enabled', False): + elif self.current_display == 'weather' and self.weather: if self.weather_mode == 'current': self.weather.display_weather(force_clear=self.force_clear) elif self.weather_mode == 'hourly': @@ -128,10 +140,10 @@ class DisplayController: else: # daily self.weather.display_daily_forecast(force_clear=self.force_clear) time.sleep(self.update_interval) - elif self.current_display == 'stocks' and self.config.get('stocks', {}).get('enabled', False): + elif self.current_display == 'stocks' and self.stocks: + # For stocks, we want to update as fast as possible without delay self.stocks.display_stocks(force_clear=self.force_clear) - time.sleep(self.update_interval) - elif self.current_display == 'stock_news' and self.config.get('stock_news', {}).get('enabled', False): + elif self.current_display == 'stock_news' and self.news: # For news, we want to update as fast as possible without delay self.news.display_news() except Exception as e: diff --git a/src/display_manager.py b/src/display_manager.py index b4b47ca6..12f93821 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -62,7 +62,7 @@ class DisplayManager: # Initialize font with Press Start 2P try: - self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) # Reduced from 10 to 8 logger.info("Initial font loaded successfully") except Exception as e: logger.error(f"Failed to load initial font: {e}") @@ -81,17 +81,17 @@ class DisplayManager: # Draw a diagonal line self.draw.line([0, 0, self.matrix.width-1, self.matrix.height-1], fill=(0, 255, 0)) - # Draw some text - self.draw.text((10, 10), "TEST", font=self.font, fill=(0, 0, 255)) + # Draw some text - changed from "TEST" to "Initializing" with smaller font + self.draw.text((10, 10), "Initializing", font=self.font, fill=(0, 0, 255)) # Update the display once after everything is drawn self.update_display() - time.sleep(2) + time.sleep(0.5) # Reduced from 1 second to 0.5 seconds for faster animation def update_display(self): """Update the display using double buffering with proper sync.""" try: - # Copy the current image to the offscreen canvas + # Copy the current image to the offscreen canvas self.offscreen_canvas.SetImage(self.image) # Swap buffers immediately @@ -119,32 +119,25 @@ class DisplayManager: logger.error(f"Error clearing display: {e}") def _load_fonts(self): - """Load fonts optimized for LED matrix display.""" + """Load fonts with proper error handling.""" try: - # Use Press Start 2P font - perfect for LED matrix displays - font_path = "assets/fonts/PressStart2P-Regular.ttf" + # Load regular font (Press Start 2P) + self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + logger.info("Regular font loaded successfully") - # For 32px height matrix, optimized sizes for pixel-perfect display - large_size = 10 # Large text for time and main info - small_size = 8 # Small text for secondary information + # Load small font (Press Start 2P at smaller size) + self.small_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + logger.info("Small font loaded successfully") - try: - self.font = ImageFont.truetype(font_path, large_size) - self.small_font = ImageFont.truetype(font_path, small_size) - logger.info(f"Loaded Press Start 2P font: {font_path} (large: {large_size}px, small: {small_size}px)") - except Exception as e: - logger.warning(f"Failed to load Press Start 2P font, falling back to default: {e}") - self.font = ImageFont.load_default() - self.small_font = ImageFont.load_default() - except Exception as e: logger.error(f"Error in font loading: {e}") - self.font = ImageFont.load_default() - self.small_font = self.font + # Fallback to default font + self.regular_font = ImageFont.load_default() + self.small_font = self.regular_font def draw_text(self, text: str, x: int = None, y: int = None, color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False) -> None: """Draw text on the display with improved clarity.""" - font = self.small_font if small_font else self.font + font = self.small_font if small_font else self.regular_font # Get text dimensions including ascenders and descenders bbox = self.draw.textbbox((0, 0), text, font=font) diff --git a/src/stock_manager.py b/src/stock_manager.py index 40630fed..d47b581b 100644 --- a/src/stock_manager.py +++ b/src/stock_manager.py @@ -8,6 +8,9 @@ from datetime import datetime import os import urllib.parse import re +from PIL import Image, ImageDraw, ImageFont +import numpy as np +import hashlib # Configure logging logging.basicConfig(level=logging.INFO) @@ -21,7 +24,51 @@ class StockManager: self.last_update = 0 self.stock_data = {} self.current_stock_index = 0 - self.display_mode = 'info' # 'info' or 'chart' + self.scroll_position = 0 + self.cached_text_image = None + self.cached_text = None + + # Get scroll settings from config with faster defaults + self.scroll_speed = self.stocks_config.get('scroll_speed', 1) + self.scroll_delay = self.stocks_config.get('scroll_delay', 0.001) + + # Initialize frame rate tracking + self.frame_count = 0 + self.last_frame_time = time.time() + self.last_fps_log_time = time.time() + self.frame_times = [] + + # Try to use the assets/stocks directory from the repository + self.logo_dir = os.path.join('assets', 'stocks') + self.use_temp_dir = False + + # Check if we can write to the logo directory + try: + if not os.path.exists(self.logo_dir): + try: + os.makedirs(self.logo_dir, mode=0o755, exist_ok=True) + logger.info(f"Created logo directory: {self.logo_dir}") + except (PermissionError, OSError) as e: + logger.warning(f"Cannot create logo directory: {str(e)}. Using temporary directory instead.") + self.use_temp_dir = True + elif not os.access(self.logo_dir, os.W_OK): + logger.warning(f"Cannot write to logo directory: {self.logo_dir}. Using temporary directory instead.") + self.use_temp_dir = True + + # If we need to use a temporary directory, create it + if self.use_temp_dir: + import tempfile + self.logo_dir = tempfile.mkdtemp(prefix='stock_logos_') + logger.info(f"Using temporary directory for logos: {self.logo_dir}") + + except Exception as e: + logger.error(f"Error setting up logo directory: {str(e)}") + # Fall back to using a temporary directory + import tempfile + self.logo_dir = tempfile.mkdtemp(prefix='stock_logos_') + self.use_temp_dir = True + logger.info(f"Using temporary directory for logos: {self.logo_dir}") + self.headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } @@ -179,8 +226,8 @@ class StockManager: ) # Calculate chart dimensions - chart_height = 22 # Increased height since we're using smaller fonts - chart_y = 7 # Start closer to symbol due to smaller font + chart_height = 16 # Reduced from 22 to make chart smaller + chart_y = 8 # Slightly adjusted starting position width = self.display_manager.matrix.width # Get min and max prices for scaling @@ -213,7 +260,7 @@ class StockManager: price_text = f"${data['price']:.2f} ({data['change']:+.1f}%)" self.display_manager.draw_text( price_text, - y=30, # Near bottom + y=28, # Moved down slightly from 30 to give more space color=color, small_font=True # Use small font ) @@ -275,8 +322,367 @@ class StockManager: else: logger.error("Failed to fetch data for any configured stocks") + def _download_stock_logo(self, symbol: str) -> str: + """Download and save stock logo for a given symbol. + + Args: + symbol: Stock symbol (e.g., 'AAPL', 'MSFT') + + Returns: + Path to the saved logo image, or None if download failed + """ + try: + # Create a filename based on the symbol + filename = f"{symbol.lower()}.png" + filepath = os.path.join(self.logo_dir, filename) + + # Check if we already have the logo + if os.path.exists(filepath): + return filepath + + # Try to find logo from various sources + # 1. Yahoo Finance + yahoo_url = f"https://logo.clearbit.com/{symbol.lower()}.com" + + # 2. Alternative source if Yahoo fails + alt_url = f"https://storage.googleapis.com/iex/api/logos/{symbol}.png" + + # Try Yahoo first + response = requests.get(yahoo_url, headers=self.headers, timeout=5) + if response.status_code == 200 and 'image' in response.headers.get('Content-Type', ''): + try: + # Verify it's a valid image before saving + from io import BytesIO + img = Image.open(BytesIO(response.content)) + img.verify() # Verify it's a valid image + + # Try to save the file + try: + with open(filepath, 'wb') as f: + f.write(response.content) + logger.info(f"Downloaded logo for {symbol} from Yahoo") + return filepath + except PermissionError: + logger.warning(f"Permission denied when saving logo for {symbol}. Using in-memory logo instead.") + # Return a temporary path that won't be used for saving + return f"temp_{symbol.lower()}.png" + except Exception as e: + logger.warning(f"Invalid image data from Yahoo for {symbol}: {str(e)}") + + # Try alternative source + response = requests.get(alt_url, headers=self.headers, timeout=5) + if response.status_code == 200 and 'image' in response.headers.get('Content-Type', ''): + try: + # Verify it's a valid image before saving + from io import BytesIO + img = Image.open(BytesIO(response.content)) + img.verify() # Verify it's a valid image + + # Try to save the file + try: + with open(filepath, 'wb') as f: + f.write(response.content) + logger.info(f"Downloaded logo for {symbol} from alternative source") + return filepath + except PermissionError: + logger.warning(f"Permission denied when saving logo for {symbol}. Using in-memory logo instead.") + # Return a temporary path that won't be used for saving + return f"temp_{symbol.lower()}.png" + except Exception as e: + logger.warning(f"Invalid image data from alternative source for {symbol}: {str(e)}") + + # Try a third source - company.com domain + company_url = f"https://logo.clearbit.com/{symbol.lower()}.com" + if company_url != yahoo_url: # Avoid duplicate request + response = requests.get(company_url, headers=self.headers, timeout=5) + if response.status_code == 200 and 'image' in response.headers.get('Content-Type', ''): + try: + # Verify it's a valid image before saving + from io import BytesIO + img = Image.open(BytesIO(response.content)) + img.verify() # Verify it's a valid image + + # Try to save the file + try: + with open(filepath, 'wb') as f: + f.write(response.content) + logger.info(f"Downloaded logo for {symbol} from company domain") + return filepath + except PermissionError: + logger.warning(f"Permission denied when saving logo for {symbol}. Using in-memory logo instead.") + # Return a temporary path that won't be used for saving + return f"temp_{symbol.lower()}.png" + except Exception as e: + logger.warning(f"Invalid image data from company domain for {symbol}: {str(e)}") + + logger.warning(f"Could not download logo for {symbol}") + return None + + except Exception as e: + logger.error(f"Error downloading logo for {symbol}: {str(e)}") + return None + + def _get_stock_logo(self, symbol: str) -> Image.Image: + """Get stock logo image, or create a text-based fallback. + + Args: + symbol: Stock symbol (e.g., 'AAPL', 'MSFT') + + Returns: + PIL Image of the logo or text-based fallback + """ + # Try to download the logo if we don't have it + logo_path = self._download_stock_logo(symbol) + + if logo_path: + try: + # Check if this is a temporary path (in-memory logo) + if logo_path.startswith("temp_"): + # For temporary paths, we need to download the logo again + # since we couldn't save it to disk + symbol_lower = symbol.lower() + yahoo_url = f"https://logo.clearbit.com/{symbol_lower}.com" + alt_url = f"https://storage.googleapis.com/iex/api/logos/{symbol}.png" + company_url = f"https://logo.clearbit.com/{symbol_lower}.com" + + # Try all sources + for url in [yahoo_url, alt_url, company_url]: + try: + response = requests.get(url, headers=self.headers, timeout=5) + if response.status_code == 200 and 'image' in response.headers.get('Content-Type', ''): + try: + # Create image from response content + from io import BytesIO + logo = Image.open(BytesIO(response.content)) + # Verify it's a valid image + logo.verify() + # Reopen after verify + logo = Image.open(BytesIO(response.content)) + + # Convert to RGBA if not already + if logo.mode != 'RGBA': + logo = logo.convert('RGBA') + + # Resize to fit in the display (assuming square logo) + max_size = min(self.display_manager.matrix.width // 2, + self.display_manager.matrix.height // 2) + logo = logo.resize((max_size, max_size), Image.LANCZOS) + + return logo + except Exception as e: + logger.warning(f"Invalid image data from {url} for {symbol}: {str(e)}") + continue + except Exception as e: + logger.warning(f"Error downloading from {url} for {symbol}: {str(e)}") + continue + else: + # Normal case: open the saved logo file + try: + logo = Image.open(logo_path) + # Verify it's a valid image + logo.verify() + # Reopen after verify + logo = Image.open(logo_path) + + # Convert to RGBA if not already + if logo.mode != 'RGBA': + logo = logo.convert('RGBA') + + # Resize to fit in the display (assuming square logo) + max_size = min(self.display_manager.matrix.width // 2, + self.display_manager.matrix.height // 2) + logo = logo.resize((max_size, max_size), Image.LANCZOS) + + return logo + except Exception as e: + logger.warning(f"Invalid image file for {symbol}: {str(e)}") + except Exception as e: + logger.error(f"Error processing logo for {symbol}: {str(e)}") + + # Fallback to text-based logo + return None + + def _create_stock_display(self, symbol: str, price: float, change: float, change_percent: float) -> Image.Image: + """Create a display image for a stock with logo, symbol, price, and change. + + Args: + symbol: Stock symbol (e.g., 'AAPL', 'MSFT') + price: Current stock price + change: Price change + change_percent: Percentage change + + Returns: + PIL Image of the stock display + """ + # Create a wider image for scrolling + width = self.display_manager.matrix.width * 2 # Reduced from 3x to 2x since we'll handle spacing in display_stocks + height = self.display_manager.matrix.height + image = Image.new('RGB', (width, height), color=(0, 0, 0)) + draw = ImageDraw.Draw(image) + + # Draw large stock logo on the left + logo = self._get_stock_logo(symbol) + if logo: + # Position logo on the left side with minimal spacing + logo_x = 0 # Reduced from 2 to 0 + logo_y = (height - logo.height) // 2 + image.paste(logo, (logo_x, logo_y), logo) + + # Draw symbol, price, and change in the center + # Use the fonts from display_manager + regular_font = self.display_manager.regular_font + small_font = self.display_manager.small_font + + # Create smaller versions of the fonts for symbol and price + symbol_font = ImageFont.truetype(self.display_manager.regular_font.path, + int(self.display_manager.regular_font.size * 0.8)) # 80% of regular size + price_font = ImageFont.truetype(self.display_manager.regular_font.path, + int(self.display_manager.regular_font.size * 0.8)) # 80% of regular size + + # Calculate text dimensions for proper spacing + symbol_text = symbol + price_text = f"${price:.2f}" + change_text = f"{change:+.2f} ({change_percent:+.1f}%)" + + # Get the height of each text element + symbol_bbox = draw.textbbox((0, 0), symbol_text, font=symbol_font) + price_bbox = draw.textbbox((0, 0), price_text, font=price_font) + change_bbox = draw.textbbox((0, 0), change_text, font=small_font) + + symbol_height = symbol_bbox[3] - symbol_bbox[1] + price_height = price_bbox[3] - price_bbox[1] + change_height = change_bbox[3] - change_bbox[1] + + # Calculate total height needed for all text + total_text_height = symbol_height + price_height + change_height + 2 # 2 pixels for spacing + + # Calculate starting y position to center the text block + start_y = (height - total_text_height) // 2 + + # Draw symbol - moved even closer to the logo + symbol_width = symbol_bbox[2] - symbol_bbox[0] + symbol_x = width // 4 # Moved from width//3 to width//4 to bring text even closer to logo + symbol_y = start_y + draw.text((symbol_x, symbol_y), symbol_text, font=symbol_font, fill=(255, 255, 255)) + + # Draw price - aligned with symbol + price_width = price_bbox[2] - price_bbox[0] + price_x = symbol_x + (symbol_width - price_width) // 2 # Center price under symbol + price_y = symbol_y + symbol_height + 1 # 1 pixel spacing + draw.text((price_x, price_y), price_text, font=price_font, fill=(255, 255, 255)) + + # Draw change with color based on value - aligned with price + change_width = change_bbox[2] - change_bbox[0] + change_x = price_x + (price_width - change_width) // 2 # Center change under price + change_y = price_y + price_height + 1 # 1 pixel spacing + change_color = (0, 255, 0) if change >= 0 else (255, 0, 0) + draw.text((change_x, change_y), change_text, font=small_font, fill=change_color) + + # Draw mini chart on the right + if symbol in self.stock_data and 'price_history' in self.stock_data[symbol]: + price_history = self.stock_data[symbol]['price_history'] + if len(price_history) >= 2: # Need at least 2 points to draw a line + # Extract prices from price history + chart_data = [p['price'] for p in price_history] + + # Calculate chart dimensions - 50% wider + chart_width = int(width // 2.5) # Reduced from width//2 to width//2.5 (20% smaller) + chart_height = height // 1.5 + chart_x = width - chart_width + 5 # Keep the same right margin + chart_y = (height - chart_height) // 2 + + # Find min and max prices for scaling + min_price = min(chart_data) + max_price = max(chart_data) + + # Add padding to avoid flat lines when prices are very close + price_range = max_price - min_price + if price_range < 0.01: # If prices are very close + min_price -= 0.01 + max_price += 0.01 + price_range = 0.02 + + # Calculate points for the line + points = [] + for i, price in enumerate(chart_data): + x = chart_x + (i * chart_width) // (len(chart_data) - 1) + # Invert y-axis (higher price = lower y value) + y = chart_y + chart_height - ((price - min_price) / price_range * chart_height) + points.append((x, y)) + + # Draw the line + if len(points) >= 2: + draw.line(points, fill=(0, 255, 0) if change >= 0 else (255, 0, 0), width=2) + + # Draw dots at each point + for point in points: + draw.ellipse([point[0]-2, point[1]-2, point[0]+2, point[1]+2], + fill=(0, 255, 0) if change >= 0 else (255, 0, 0)) + + # Return the full image without cropping + return image + + def _update_stock_display(self, symbol: str, data: Dict[str, Any], width: int, height: int) -> None: + """Update the stock display with smooth scrolling animation.""" + try: + # Create the full scrolling image + full_image = self._create_stock_display(symbol, data['price'], data['change'], data['change'] / data['open'] * 100) + scroll_width = width * 2 # Double width for smooth scrolling + + # Scroll the image smoothly + for scroll_pos in range(0, scroll_width - width, 15): # Increased scroll speed to match news ticker + # Create visible portion + visible_image = full_image.crop((scroll_pos, 0, scroll_pos + width, height)) + + # Convert to RGB and create numpy array + rgb_image = visible_image.convert('RGB') + image_array = np.array(rgb_image) + + # Update display + self.display_manager.update_display(image_array) + + # Small delay for smooth animation + time.sleep(0.01) # Reduced delay to 10ms for smoother scrolling + + # Show final position briefly + final_image = full_image.crop((scroll_width - width, 0, scroll_width, height)) + rgb_image = final_image.convert('RGB') + image_array = np.array(rgb_image) + self.display_manager.update_display(image_array) + time.sleep(0.5) # Brief pause at the end + + except Exception as e: + logger.error(f"Error updating stock display for {symbol}: {str(e)}") + # Show error state + self._show_error_state(width, height) + + def _log_frame_rate(self): + """Log frame rate statistics.""" + current_time = time.time() + + # Calculate instantaneous frame time + frame_time = current_time - self.last_frame_time + self.frame_times.append(frame_time) + + # Keep only last 100 frames for average + if len(self.frame_times) > 100: + self.frame_times.pop(0) + + # Log FPS every second + if current_time - self.last_fps_log_time >= 1.0: + avg_frame_time = sum(self.frame_times) / len(self.frame_times) + avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0 + instant_fps = 1.0 / frame_time if frame_time > 0 else 0 + + logger.info(f"Frame stats - Avg FPS: {avg_fps:.1f}, Current FPS: {instant_fps:.1f}, Frame time: {frame_time*1000:.2f}ms") + self.last_fps_log_time = current_time + self.frame_count = 0 + + self.last_frame_time = current_time + self.frame_count += 1 + def display_stocks(self, force_clear: bool = False): - """Display stock information on the LED matrix.""" + """Display stock information with continuous scrolling animation.""" if not self.stocks_config.get('enabled', False): return @@ -288,59 +694,80 @@ class StockManager: logger.warning("No stock data available to display") return - # Get the current stock to display + # Get all symbols symbols = list(self.stock_data.keys()) if not symbols: return - current_symbol = symbols[self.current_stock_index] - data = self.stock_data[current_symbol] + # Create a continuous scrolling image if needed + if self.cached_text_image is None or force_clear: + # Create a very wide image that contains all stocks in sequence + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + + # Calculate total width needed for all stocks + # Each stock needs width*2 for scrolling, plus consistent gaps between elements + stock_gap = width // 3 # Gap between stocks + element_gap = width // 6 # Gap between elements within a stock + total_width = sum(width * 2 for _ in symbols) + stock_gap * (len(symbols) - 1) + element_gap * (len(symbols) * 2 - 1) + + # Create the full image + full_image = Image.new('RGB', (total_width, height), (0, 0, 0)) + draw = ImageDraw.Draw(full_image) + + # Draw each stock in sequence with consistent spacing + current_x = 0 + for symbol in symbols: + data = self.stock_data[symbol] + + # Create stock display for this symbol + stock_image = self._create_stock_display(symbol, data['price'], data['change'], data['change'] / data['open'] * 100) + + # Paste this stock image into the full image + full_image.paste(stock_image, (current_x, 0)) + + # Move to next position with consistent spacing + current_x += width * 2 + element_gap + + # Add extra gap between stocks + if symbol != symbols[-1]: # Don't add gap after the last stock + current_x += stock_gap + + # Cache the full image + self.cached_text_image = full_image + self.scroll_position = 0 + self.last_update = time.time() - # Toggle between info and chart display - if self.display_mode == 'info': - # Clear the display + # Clear the display if requested + if force_clear: self.display_manager.clear() - - # Draw the stock symbol at the top with regular font - self.display_manager.draw_text( - data['symbol'], - y=2, # Near top - color=(255, 255, 255) # White for symbol - ) - - # Draw the price in the middle with small font - price_text = f"${data['price']:.2f}" - self.display_manager.draw_text( - price_text, - y=14, # Middle - color=(0, 255, 0) if data['change'] >= 0 else (255, 0, 0), # Green for up, red for down - small_font=True # Use small font - ) - - # Draw the change percentage at the bottom with small font - change_text = f"({data['change']:+.1f}%)" - self.display_manager.draw_text( - change_text, - y=24, # Near bottom - color=(0, 255, 0) if data['change'] >= 0 else (255, 0, 0), # Green for up, red for down - small_font=True # Use small font - ) - - # Update the display - self.display_manager.update_display() - - # Switch to chart mode next time - self.display_mode = 'chart' - else: # chart mode - self._draw_chart(current_symbol, data) - # Switch back to info mode next time - self.display_mode = 'info' + self.scroll_position = 0 - # Add a delay to make each display visible - time.sleep(3) + # Calculate the visible portion of the image + width = self.display_manager.matrix.width + total_width = self.cached_text_image.width - # Move to next stock for next update - self.current_stock_index = (self.current_stock_index + 1) % len(symbols) + # Update scroll position with small increments + self.scroll_position = (self.scroll_position + self.scroll_speed) % total_width - # If we've shown all stocks, signal completion by returning True - return self.current_stock_index == 0 \ No newline at end of file + # Calculate the visible portion + visible_portion = self.cached_text_image.crop(( + self.scroll_position, 0, + self.scroll_position + width, self.display_manager.matrix.height + )) + + # Copy the visible portion to the display + self.display_manager.image.paste(visible_portion, (0, 0)) + self.display_manager.update_display() + + # Log frame rate + self._log_frame_rate() + + # Add a small delay between frames + time.sleep(self.scroll_delay) + + # If we've scrolled through the entire image, reset + if self.scroll_position == 0: + return True + + return False \ No newline at end of file