Source code for core.systems.ui_system

import pygame
from core.ecs import System, Entity
from core.components import Transform
from core.components.ui import (
    ButtonComponent, TextInputComponent, SliderComponent, 
    CheckBoxComponent, UIComponent, HBoxContainerComponent,
    VBoxContainerComponent, GridBoxContainerComponent,
    ImageRenderer, ProgressBarComponent, TextRenderer
)
from core.input import Input
from core.components.script import ScriptComponent
from core.logger import get_logger

_ui_logger = get_logger("ui_system")

[docs] class UISystem(System): required_components = (ButtonComponent, TextInputComponent, SliderComponent, CheckBoxComponent, UIComponent, HBoxContainerComponent, VBoxContainerComponent, GridBoxContainerComponent, ImageRenderer, ProgressBarComponent, TextRenderer) def __init__(self): super().__init__() self.focused_entity = None self.dragging_entity = None self.last_mouse_pressed = False # Initialize font for size calculations if needed if not pygame.font.get_init(): pygame.font.init() self.font_cache = {}
[docs] def update(self, dt: float, entities: list[Entity]): # 1. Update Layouts self._update_layouts(entities) # 2. Handle Input mouse_pos = Input.get_game_mouse_position() mouse_pressed = Input.get_mouse_button(0) # Left click events = Input.get_events() # Filter UI entities if self.world: cached_ui = set(self.world.get_entities_with(UIComponent, Transform)) ui_entities = [entity for entity in entities if entity in cached_ui] else: ui_entities = [] for e in entities: if any(isinstance(c, UIComponent) for c in e.components.values()): ui_entities.append(e) # Reverse for input priority (top-most first) # Assuming entities list order is draw order (Painter's algorithm) ui_entities.reverse() handled_click = False for entity in ui_entities: transform = entity.get_component(Transform) if not transform: continue # Button btn = entity.get_component(ButtonComponent) if btn: if self._handle_button(entity, btn, transform, mouse_pos, mouse_pressed, handled_click): handled_click = True # CheckBox chk = entity.get_component(CheckBoxComponent) if chk: if self._handle_checkbox(entity, chk, transform, mouse_pos, mouse_pressed, handled_click): handled_click = True # Slider slider = entity.get_component(SliderComponent) if slider: if self._handle_slider(entity, slider, transform, mouse_pos, mouse_pressed, handled_click): handled_click = True # TextInput txt_input = entity.get_component(TextInputComponent) if txt_input: if self._handle_text_input( entity, txt_input, transform, mouse_pos, mouse_pressed, events, handled_click, dt ): handled_click = True # Handle dragging outside if self.dragging_entity: # Continue dragging logic even if mouse moved away slider = self.dragging_entity.get_component(SliderComponent) transform = self.dragging_entity.get_component(Transform) if slider and transform: if not mouse_pressed: self.dragging_entity = None slider.is_dragging = False else: self._update_slider_value(slider, transform, mouse_pos) self.last_mouse_pressed = mouse_pressed
def _update_layouts(self, entities: list[Entity]): if self.world: cached_containers = set(self.world.get_entities_with(HBoxContainerComponent, Transform)) cached_containers.update(self.world.get_entities_with(VBoxContainerComponent, Transform)) cached_containers.update(self.world.get_entities_with(GridBoxContainerComponent, Transform)) container_entities = [entity for entity in entities if entity in cached_containers] else: container_entities = entities for entity in container_entities: hbox = entity.get_component(HBoxContainerComponent) vbox = entity.get_component(VBoxContainerComponent) grid = entity.get_component(GridBoxContainerComponent) if not (hbox or vbox or grid): continue transform = entity.get_component(Transform) if not transform: continue children = entity.get_children() if not children: continue start_x = transform.x start_y = transform.y current_x = start_x current_y = start_y # Dimensions tracking max_row_h = 0 max_row_w = 0 # For VBox/Grid grid_width = 0.0 # For Grid valid_children_count = 0 for i, child in enumerate(children): child_transform = child.get_component(Transform) if not child_transform: continue valid_children_count += 1 w, h = self._get_ui_size(child) # Apply scale w *= child_transform.scale_x h *= child_transform.scale_y if hbox: # Only update if changed to avoid jitter/float drift if possible, # but Transform setter handles propagation logic so we just set world pos. child_transform.x = current_x child_transform.y = start_y current_x += w + hbox.spacing max_row_h = max(max_row_h, h) elif vbox: child_transform.x = start_x child_transform.y = current_y current_y += h + vbox.spacing max_row_w = max(max_row_w, w) elif grid: col = i % grid.columns row = i // grid.columns if col == 0 and i > 0: current_x = start_x current_y += max_row_h + grid.spacing_y max_row_h = 0 child_transform.x = current_x child_transform.y = current_y current_x += w + grid.spacing_x max_row_h = max(max_row_h, h) grid_width = max(grid_width, current_x - start_x - grid.spacing_x) # Update container dimensions if hbox: hbox.width = max(0, current_x - start_x - hbox.spacing) if valid_children_count > 0 else 0.0 hbox.height = max_row_h elif vbox: vbox.width = max_row_w vbox.height = max(0, current_y - start_y - vbox.spacing) if valid_children_count > 0 else 0.0 elif grid: grid.width = grid_width grid.height = current_y + max_row_h - start_y def _get_ui_size(self, entity): width = 0 height = 0 # Button btn = entity.get_component(ButtonComponent) if btn: width = max(width, btn.width) height = max(height, btn.height) # Image img = entity.get_component(ImageRenderer) if img: width = max(width, img.width) height = max(height, img.height) # TextInput txt = entity.get_component(TextInputComponent) if txt: width = max(width, txt.width) height = max(height, txt.height) # Slider slider = entity.get_component(SliderComponent) if slider: width = max(width, slider.width) height = max(height, slider.height) # ProgressBar bar = entity.get_component(ProgressBarComponent) if bar: width = max(width, bar.width) height = max(height, bar.height) # CheckBox chk = entity.get_component(CheckBoxComponent) if chk: width = max(width, chk.size) height = max(height, chk.size) # TextRenderer text = entity.get_component(TextRenderer) if text: # Estimate text size font_size = text.font_size # Basic estimation if font not loaded: width ~ 0.6 * size * chars, height ~ size # Or try to use pygame font try: key = (text.font_path, text.font_size) if key not in self.font_cache: self.font_cache[key] = pygame.font.Font(text.font_path, text.font_size) font = self.font_cache[key] size = font.size(text.text) width = max(width, size[0]) height = max(height, size[1]) except: # Fallback estimation width = max(width, len(text.text) * (font_size * 0.6)) height = max(height, font_size) return width, height def _get_rect(self, transform, width, height): w = width * transform.scale_x h = height * transform.scale_y return pygame.Rect(transform.x, transform.y, w, h) def _handle_button(self, entity, btn, transform, mouse_pos, mouse_pressed, handled_click): rect = self._get_rect(transform, btn.width, btn.height) hovered = rect.collidepoint(mouse_pos) if handled_click: btn.is_hovered = False btn.is_pressed = False return False btn.is_hovered = hovered if hovered: if mouse_pressed: btn.is_pressed = True return True # Consume click else: if btn.is_pressed: # Release inside # Clicked! self._trigger_event(entity, "on_click") btn.is_pressed = False return False else: btn.is_pressed = False return False def _handle_checkbox(self, entity, chk, transform, mouse_pos, mouse_pressed, handled_click): rect = self._get_rect(transform, chk.size, chk.size) hovered = rect.collidepoint(mouse_pos) if handled_click: return False if hovered and mouse_pressed and not self.last_mouse_pressed: chk.checked = not chk.checked self._trigger_event(entity, "on_toggle", chk.checked) return True return False def _handle_slider(self, entity, slider, transform, mouse_pos, mouse_pressed, handled_click): rect = self._get_rect(transform, slider.width, slider.height) hovered = rect.collidepoint(mouse_pos) if self.dragging_entity == entity: return True # Already handling if handled_click: return False if hovered and mouse_pressed and not self.last_mouse_pressed: self.dragging_entity = entity slider.is_dragging = True self._update_slider_value(slider, transform, mouse_pos) return True return False def _update_slider_value(self, slider, transform, mouse_pos): rect = self._get_rect(transform, slider.width, slider.height) rel_x = mouse_pos[0] - rect.x pct = max(0.0, min(1.0, rel_x / rect.width)) val_range = slider.max_value - slider.min_value new_val = slider.min_value + (val_range * pct) if new_val != slider.value: slider.value = new_val self._trigger_event(self.dragging_entity, "on_value_changed", slider.value) def _handle_text_input(self, entity, txt_input, transform, mouse_pos, mouse_pressed, events, handled_click, dt: float): rect = self._get_rect(transform, txt_input.width, txt_input.height) hovered = rect.collidepoint(mouse_pos) # Focus handling if mouse_pressed and not self.last_mouse_pressed: if hovered and not handled_click: self.focused_entity = entity txt_input.is_focused = True return True else: if self.focused_entity == entity: self.focused_entity = None txt_input.is_focused = False if self.focused_entity == entity: # Handle text input for event in events: if event.type == pygame.KEYDOWN: if event.key == pygame.K_BACKSPACE: txt_input.text = txt_input.text[:-1] self._trigger_event(entity, "on_text_changed", txt_input.text) elif event.key == pygame.K_RETURN: self._trigger_event(entity, "on_submit", txt_input.text) self.focused_entity = None txt_input.is_focused = False elif event.type == pygame.TEXTINPUT: txt_input.text += event.text self._trigger_event(entity, "on_text_changed", txt_input.text) # Cursor blink (real frame dt so blink rate is consistent across FPS) txt_input.cursor_timer += max(0.0, float(dt)) if txt_input.cursor_timer >= 0.5: txt_input.cursor_timer = 0 txt_input.cursor_visible = not txt_input.cursor_visible else: txt_input.is_focused = False txt_input.cursor_visible = False return False def _trigger_event(self, entity, method_name, *args): # Call method on script if exists script = entity.get_component(ScriptComponent) if script and script.instance: if hasattr(script.instance, method_name): try: getattr(script.instance, method_name)(*args) except Exception as e: _ui_logger.error("Error calling UI callback", method=method_name, entity=entity.name, error=str(e))