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
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 {}
|
|
|