You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

444 lines
17 KiB

#!/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 = {
'<DEL': 'DEL',
'SPACE': 'SPC'
}
return char_map.get(char, char)
def draw_input_field(self, text: str, x: int, y: int, width: int, height: int,
active: bool = False, placeholder: str = ''):
"""Draw an input text field"""
# Field styling
if active:
bg_color = 'white'
border_color = 'blue'
text_color = 'black'
else:
bg_color = 'light_gray'
border_color = 'gray'
text_color = 'black' if text else 'gray'
self.draw_panel(x, y, width, height, bg_color, border_color, 2)
# Display text or placeholder
display_text = text if text else placeholder
self.draw_text(display_text, x + 10, y + 8,
'black' if text else 'gray', 'medium')
def draw_progress_bar(self, value: int, max_value: int, x: int, y: int,
width: int, height: int = 20,
bg_color: str = 'dark_gray', fill_color: str = 'green'):
"""Draw a progress bar"""
# Background
self.draw_panel(x, y, width, height, bg_color, 'gray')
# Fill
if max_value > 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 {}