#!/usr/bin/env python3 """ Reusable UI Components for SDL2-based applications Provides common graphics operations and UI elements """ import os import time from typing import Dict, List, Optional, Tuple, Any import sdl2 import sdl2.ext class UIColors: """Standard color palette for UI components""" def __init__(self): self.colors = { 'white': sdl2.ext.Color(255, 255, 255), 'black': sdl2.ext.Color(0, 0, 0), 'gray': sdl2.ext.Color(128, 128, 128), 'light_gray': sdl2.ext.Color(200, 200, 200), 'dark_gray': sdl2.ext.Color(64, 64, 64), 'blue': sdl2.ext.Color(70, 130, 200), 'light_blue': sdl2.ext.Color(120, 180, 255), 'green': sdl2.ext.Color(50, 200, 50), 'light_green': sdl2.ext.Color(100, 255, 100), 'red': sdl2.ext.Color(200, 50, 50), 'yellow': sdl2.ext.Color(255, 220, 0), 'orange': sdl2.ext.Color(255, 165, 0), 'purple': sdl2.ext.Color(150, 50, 200) } def get(self, color_name: str): """Get color by name""" return self.colors.get(color_name, self.colors['white']) class FontManager: """Manages multiple font sizes for the application""" def __init__(self, font_path: Optional[str] = None): self.font_path = font_path or self._find_default_font() self.fonts = {} self._initialize_fonts() def _find_default_font(self) -> Optional[str]: """Find a suitable default font""" font_paths = [ "assets/decterm.ttf", "./assets/terminal.ttf", "./assets/AmaticSC-Regular.ttf" ] for path in font_paths: if os.path.exists(path): return path return None def _initialize_fonts(self): """Initialize font managers for different sizes""" sizes = { 'title': 36, 'large': 28, 'medium': 22, 'small': 18, 'tiny': 14 } for name, size in sizes.items(): self.fonts[name] = sdl2.ext.FontManager( font_path=self.font_path, size=size ) def get(self, size: str): """Get font manager by size name""" return self.fonts.get(size, self.fonts['medium']) class UIRenderer: """Main UI rendering class with reusable drawing operations""" def __init__(self, renderer: sdl2.ext.Renderer, window_size: Tuple[int, int] = (640, 480)): self.renderer = renderer self.window_width, self.window_height = window_size self.colors = UIColors() self.fonts = FontManager() self.sprite_factory = sdl2.ext.SpriteFactory(renderer=renderer) def clear_screen(self, color_name: str = 'black'): """Clear screen with specified color""" self.renderer.clear(self.colors.get(color_name)) def present(self): """Present the rendered frame""" self.renderer.present() def draw_background_pattern(self, pattern_type: str = 'gradient'): """Draw subtle background patterns""" if pattern_type == 'gradient': for y in range(0, self.window_height, 20): alpha = int(20 * (1 - y / self.window_height)) if alpha > 5: color = sdl2.ext.Color(alpha, alpha, alpha * 2) self.renderer.draw_line((0, y, self.window_width, y), color) def draw_text(self, text: str, x: int, y: int, color_name: str = 'white', font_size: str = 'medium', center: bool = False) -> Tuple[int, int]: """Draw text on screen with improved styling""" if not text: return (0, 0) color = self.colors.get(color_name) font = self.fonts.get(font_size) text_sprite = self.sprite_factory.from_text(text, color=color, fontmanager=font) if center: x = x - text_sprite.size[0] // 2 text_sprite.position = (x, y) self.renderer.copy(text_sprite, dstrect=text_sprite.position) return text_sprite.size def draw_panel(self, x: int, y: int, width: int, height: int, bg_color: str = 'dark_gray', border_color: str = 'gray', border_width: int = 2): """Draw a styled panel/box""" bg = self.colors.get(bg_color) border = self.colors.get(border_color) # Fill background self.renderer.fill((x, y, width, height), bg) # Draw border for i in range(border_width): self.renderer.draw_rect((x + i, y + i, width - 2*i, height - 2*i), border) def draw_button(self, text: str, x: int, y: int, width: int, height: int, selected: bool = False, disabled: bool = False, font_size: str = 'medium'): """Draw a styled button""" # Button colors based on state if disabled: bg_color = 'black' border_color = 'dark_gray' text_color = 'dark_gray' elif selected: bg_color = 'blue' border_color = 'light_blue' text_color = 'white' else: bg_color = 'dark_gray' border_color = 'gray' text_color = 'light_gray' # Draw button background and border self.draw_panel(x, y, width, height, bg_color, border_color) # Draw button text centered text_x = x + width // 2 text_y = y + height // 2 - 12 # Approximate text height offset self.draw_text(text, text_x, text_y, text_color, font_size, center=True) def draw_header(self, title: str, subtitle: str = '', y_pos: int = 0, height: int = 80, bg_color: str = 'dark_gray'): """Draw a header section with title and optional subtitle""" self.renderer.fill((0, y_pos, self.window_width, height), self.colors.get(bg_color)) title_y = y_pos + 15 self.draw_text(title, self.window_width // 2, title_y, 'light_blue', 'title', center=True) if subtitle: subtitle_y = title_y + 35 self.draw_text(subtitle, self.window_width // 2, subtitle_y, 'light_green', 'medium', center=True) def draw_footer_help(self, help_text: str, y_pos: int = None): """Draw footer with help text""" if y_pos is None: y_pos = self.window_height - 60 self.draw_panel(10, y_pos, self.window_width - 20, 40, 'black', 'dark_gray') self.draw_text(help_text, self.window_width // 2, y_pos + 15, 'light_gray', 'tiny', center=True) def draw_list_item(self, text: str, x: int, y: int, width: int, height: int, selected: bool = False, secondary_text: str = '', indicator: str = ''): """Draw a list item with optional secondary text and indicator""" # Background bg_color = 'blue' if selected else 'dark_gray' border_color = 'light_blue' if selected else 'gray' text_color = 'white' if selected else 'light_gray' self.draw_panel(x, y, width, height, bg_color, border_color) # Main text self.draw_text(text, x + 10, y + 5, text_color, 'medium') # Secondary text if secondary_text: secondary_color = 'light_gray' if selected else 'gray' self.draw_text(secondary_text, x + 10, y + 22, secondary_color, 'tiny') # Indicator (like ★ for active item) if indicator: indicator_x = x + width - 30 indicator_color = 'light_green' if selected else 'yellow' self.draw_text(indicator, indicator_x, y + height // 2 - 8, indicator_color, 'small', center=True) def draw_error_dialog(self, message: str, title: str = "ERROR"): """Draw an error dialog overlay""" # Semi-transparent overlay overlay_color = sdl2.ext.Color(0, 0, 0, 128) self.renderer.fill((0, 0, self.window_width, self.window_height), overlay_color) # Error dialog box dialog_width = 400 dialog_height = 120 dialog_x = (self.window_width - dialog_width) // 2 dialog_y = (self.window_height - dialog_height) // 2 # Dialog background with red border self.draw_panel(dialog_x, dialog_y, dialog_width, dialog_height, 'black', 'red', border_width=4) # Error title self.draw_text(title, dialog_x + dialog_width // 2, dialog_y + 20, 'red', 'large', center=True) # Error message self.draw_text(message, dialog_x + dialog_width // 2, dialog_y + 50, 'white', 'medium', center=True) # Dismiss instruction self.draw_text("Press any key to continue...", dialog_x + dialog_width // 2, dialog_y + 80, 'light_gray', 'small', center=True) def draw_virtual_keyboard(self, keyboard_layout: List[List[str]], cursor_x: int, cursor_y: int, start_x: int = 50, start_y: int = 150): """Draw a virtual keyboard interface""" key_width = 45 key_height = 30 for row_idx, row in enumerate(keyboard_layout): row_y = start_y + row_idx * (key_height + 5) # Special handling for bottom row (commands) if row_idx == len(keyboard_layout) - 1: key_widths = [80] * len(row) # Wider keys for commands x_offset = start_x + 90 # Center the bottom row else: key_widths = [key_width] * len(row) x_offset = start_x current_x = x_offset for col_idx, char in enumerate(row): selected = (row_idx == cursor_y and col_idx == cursor_x) # Key styling if selected: bg_color = 'blue' border_color = 'light_blue' text_color = 'white' else: bg_color = 'dark_gray' border_color = 'gray' text_color = 'light_gray' self.draw_panel(current_x, row_y, key_widths[col_idx], key_height, bg_color, border_color) # Key text display_char = self._format_keyboard_char(char) text_x = current_x + key_widths[col_idx] // 2 text_y = row_y + 8 self.draw_text(display_char, text_x, text_y, text_color, 'tiny', center=True) current_x += key_widths[col_idx] + 5 def _format_keyboard_char(self, char: str) -> str: """Format keyboard character for display""" char_map = { ' 0: fill_width = int(width * (value / max_value)) if fill_width > 0: self.renderer.fill((x + 2, y + 2, fill_width - 4, height - 4), self.colors.get(fill_color)) # Value text text = f"{value}/{max_value}" text_x = x + width // 2 text_y = y + 3 self.draw_text(text, text_x, text_y, 'white', 'tiny', center=True) def draw_menu_navigation_arrows(self, selected_index: int, total_items: int, x: int, y: int): """Draw navigation arrows for selected menu items""" if selected_index > 0: self.draw_text("▲", x, y - 15, 'white', 'small', center=True) if selected_index < total_items - 1: self.draw_text("▼", x, y + 15, 'white', 'small', center=True) class GamepadInputHandler: """Handle gamepad input with debouncing and mapping""" def __init__(self, debounce_delay: float = 0.15): self.gamepad = None self.button_states = {} self.last_button_time = {} self.debounce_delay = debounce_delay self.init_gamepad() def init_gamepad(self): """Initialize gamepad support""" sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK | sdl2.SDL_INIT_GAMECONTROLLER) num_joysticks = sdl2.SDL_NumJoysticks() if num_joysticks > 0: self.gamepad = sdl2.SDL_JoystickOpen(0) if self.gamepad: print(f"Gamepad detected: {sdl2.SDL_JoystickName(self.gamepad).decode()}") else: print("No gamepad detected - using keyboard fallback") def can_process_input(self, input_key: str, current_time: float) -> bool: """Check if enough time has passed to process input (debouncing)""" if input_key not in self.last_button_time: self.last_button_time[input_key] = current_time return True if current_time - self.last_button_time[input_key] > self.debounce_delay: self.last_button_time[input_key] = current_time return True return False def get_gamepad_input(self) -> Dict[str, bool]: """Get current gamepad input state""" if not self.gamepad: return {} current_time = time.time() inputs = {} # D-pad navigation hat_state = sdl2.SDL_JoystickGetHat(self.gamepad, 0) if hat_state & sdl2.SDL_HAT_UP and self.can_process_input('up', current_time): inputs['up'] = True if hat_state & sdl2.SDL_HAT_DOWN and self.can_process_input('down', current_time): inputs['down'] = True if hat_state & sdl2.SDL_HAT_LEFT and self.can_process_input('left', current_time): inputs['left'] = True if hat_state & sdl2.SDL_HAT_RIGHT and self.can_process_input('right', current_time): inputs['right'] = True # Buttons button_count = sdl2.SDL_JoystickNumButtons(self.gamepad) for i in range(min(button_count, 16)): if sdl2.SDL_JoystickGetButton(self.gamepad, i): button_key = f'button_{i}' if self.can_process_input(button_key, current_time): inputs[button_key] = True return inputs def cleanup(self): """Cleanup gamepad resources""" if self.gamepad: sdl2.SDL_JoystickClose(self.gamepad) self.gamepad = None class ScreenManager: """Manage different screens/states in the application""" def __init__(self): self.current_screen = "main_menu" self.screen_stack = [] self.selected_index = 0 self.screen_data = {} def push_screen(self, screen_name: str, data: Dict[str, Any] = None): """Push a new screen onto the stack""" self.screen_stack.append({ 'screen': self.current_screen, 'index': self.selected_index, 'data': self.screen_data.copy() }) self.current_screen = screen_name self.selected_index = 0 self.screen_data = data or {} def pop_screen(self): """Pop the previous screen from the stack""" if self.screen_stack: previous = self.screen_stack.pop() self.current_screen = previous['screen'] self.selected_index = previous['index'] self.screen_data = previous['data'] def set_screen(self, screen_name: str, data: Dict[str, Any] = None): """Set current screen without using stack""" self.current_screen = screen_name self.selected_index = 0 self.screen_data = data or {}