From d90240391cac08c650cd561329a0503ee756d926 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 8 Nov 2025 21:05:48 -0600 Subject: [PATCH] Implement tree widget for hierarchical entity inspection in HUD --- core/simulation_engine.py | 28 +- ui/hud.py | 85 +++++- ui/inspector_tree.py | 274 +++++++++++++++++ ui/layout_manager.py | 597 ++++++++++++++++++++++++++++++++++++++ ui/tree_widget.py | 313 ++++++++++++++++++++ 5 files changed, 1292 insertions(+), 5 deletions(-) create mode 100644 ui/inspector_tree.py create mode 100644 ui/layout_manager.py create mode 100644 ui/tree_widget.py diff --git a/core/simulation_engine.py b/core/simulation_engine.py index 3ed0a80..53aa736 100644 --- a/core/simulation_engine.py +++ b/core/simulation_engine.py @@ -60,6 +60,10 @@ class SimulationEngine: self.ui_manager = UIManager((self.window_width, self.window_height)) self.hud = HUD(self.ui_manager, self.window_width, self.window_height) self.hud.update_layout(self.window_width, self.window_height) + + # Initialize tree widget with the world + self.hud.initialize_tree_widget(self.simulation_core.world) + self._update_simulation_view() def _init_simulation(self): @@ -188,9 +192,12 @@ class SimulationEngine: # Update selected objects in input handler self.input_handler.update_selected_objects() + # Sync tree selection with world selection + self.hud.update_tree_selection(self.input_handler.selected_objects) + # Render frame self._update_frame(deltatime) - self._render_frame() + self._render_frame(deltatime) def _sync_input_and_timing(self): """Synchronize input handler state with simulation core timing.""" @@ -213,7 +220,7 @@ class SimulationEngine: keys = pygame.key.get_pressed() self.input_handler.update_camera(keys, deltatime) - def _render_frame(self): + def _render_frame(self, deltatime): """Render the complete frame.""" self.screen.fill(BLACK) self.renderer.clear_screen() @@ -222,7 +229,7 @@ class SimulationEngine: self._render_simulation_world() # Update and render UI - self._update_and_render_ui() + self._update_and_render_ui(deltatime) # Render HUD overlays self._render_hud_overlays() @@ -251,15 +258,21 @@ class SimulationEngine: ) self.screen.blit(self.sim_view, (self.sim_view_rect.left, self.sim_view_rect.top)) - def _update_and_render_ui(self): + def _update_and_render_ui(self, deltatime): """Update UI elements and render them.""" # Update HUD displays with simulation core state self.hud.update_simulation_controls(self.simulation_core) + # Update tree widget + self.hud.update_tree_widget(deltatime) + # Draw UI elements self.hud.manager.draw_ui(self.screen) self.hud.draw_splitters(self.screen) + # Render tree widget + self.hud.render_tree_widget(self.screen) + def _render_hud_overlays(self): """Render HUD overlay elements.""" self.hud.render_fps(self.screen, self.clock) @@ -290,6 +303,13 @@ class SimulationEngine: if not action: return + # Handle tree selection changes + if isinstance(action, tuple) and action[0] == 'tree_selection_changed': + selected_entities = action[1] + # Sync world selection with tree selection + self.input_handler.selected_objects = selected_entities + return + # Handle simple actions directly if action in self._hud_action_handlers: self._hud_action_handlers[action]() diff --git a/ui/hud.py b/ui/hud.py index 7f553b3..2867d4e 100644 --- a/ui/hud.py +++ b/ui/hud.py @@ -7,6 +7,7 @@ from config.constants import * from world.base.brain import CellBrain, FlexibleNeuralNetwork from world.objects import DefaultCell from pygame_gui.elements import UIPanel, UIButton, UITextEntryLine, UILabel +from ui.tree_widget import TreeWidget import math DARK_GRAY = (40, 40, 40) @@ -44,6 +45,10 @@ class HUD: self.custom_tps_entry = None self.tps_label = None + # Tree widget for inspector + self.tree_widget = None + self.world = None # Will be set when world is available + self._create_panels() self._create_simulation_controls() @@ -61,8 +66,9 @@ class HUD: else: self.control_bar = None - # Left inspector + # Left inspector with tree widget if SHOW_INSPECTOR_PANEL: + # Create a container panel for the inspector self.inspector_panel = UIPanel( relative_rect=pygame.Rect( 0, self.control_bar_height if SHOW_CONTROL_BAR else 0, @@ -73,8 +79,12 @@ class HUD: object_id="#inspector_panel", ) self.panels.append(self.inspector_panel) + + # Tree widget will be created when world is available + self.tree_widget = None else: self.inspector_panel = None + self.tree_widget = None # Right properties if SHOW_PROPERTIES_PANEL: @@ -110,6 +120,20 @@ class HUD: self.dragging_splitter = None + def initialize_tree_widget(self, world): + """Initialize the tree widget when the world is available.""" + self.world = world + + if self.inspector_panel and world: + # Create tree widget inside the inspector panel + tree_rect = pygame.Rect(0, 0, self.inspector_width, self.inspector_panel.rect.height) + self.tree_widget = TreeWidget(tree_rect, self.manager, world) + + def update_tree_selection(self, selected_objects): + """Update tree selection based on world selection.""" + if self.tree_widget: + self.tree_widget.select_entities(selected_objects) + def _create_simulation_controls(self): """Create simulation control buttons in the control bar.""" if not self.control_bar: @@ -216,6 +240,11 @@ class HUD: self.inspector_panel.set_relative_position((0, control_bar_height)) self.inspector_panel.set_dimensions((self.inspector_width, self.screen_height - control_bar_height)) + # Update tree widget size if it exists + if self.tree_widget: + tree_rect = pygame.Rect(0, 0, self.inspector_width, self.inspector_panel.rect.height) + self.tree_widget.rect = tree_rect + # Properties panel (right) if self.properties_panel: self.properties_panel.set_relative_position( @@ -300,6 +329,48 @@ class HUD: self.custom_tps_entry.set_text(str(int(timing_state.tps))) def process_event(self, event): + # Check for splitter dragging events first (don't let tree widget block them) + if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + mx, my = event.pos + # Check if mouse is on a splitter + if abs(mx - self.inspector_width) < self.splitter_thickness: + # Don't handle this event in the tree widget - it's for the splitter + pass + elif self.tree_widget and self.inspector_panel: + # Handle tree widget events + inspector_rect = self.inspector_panel.rect + # The inspector_rect should be in absolute screen coordinates + inspector_abs_x = inspector_rect.x + inspector_abs_y = inspector_rect.y + tree_local_pos = (event.pos[0] - inspector_abs_x, event.pos[1] - inspector_abs_y) + if (0 <= tree_local_pos[0] < self.tree_widget.rect.width and + 0 <= tree_local_pos[1] < self.tree_widget.rect.height): + event.pos = tree_local_pos + if self.tree_widget.handle_event(event): + selected_entities = self.tree_widget.get_selected_entities() + return 'tree_selection_changed', selected_entities + elif self.tree_widget and self.inspector_panel: + # Handle other tree widget events + if event.type in (pygame.MOUSEMOTION, pygame.MOUSEWHEEL): + # Only handle if not dragging splitter + if not self.dragging_splitter and hasattr(event, 'pos'): + inspector_rect = self.inspector_panel.rect + # Calculate absolute screen position (need to add control bar height) + inspector_abs_x = inspector_rect.x + inspector_abs_y = inspector_rect.y + tree_local_pos = (event.pos[0] - inspector_abs_x, event.pos[1] - inspector_abs_y) + if (0 <= tree_local_pos[0] < self.tree_widget.rect.width and + 0 <= tree_local_pos[1] < self.tree_widget.rect.height): + event.pos = tree_local_pos + if self.tree_widget.handle_event(event): + selected_entities = self.tree_widget.get_selected_entities() + return 'tree_selection_changed', selected_entities + else: + # For non-mouse events, try handling them normally + if self.tree_widget.handle_event(event): + selected_entities = self.tree_widget.get_selected_entities() + return 'tree_selection_changed', selected_entities + # Handle simulation control button events using ID matching if event.type == pygame_gui.UI_BUTTON_START_PRESS: object_id = str(event.ui_object_id) @@ -891,3 +962,15 @@ class HUD: screen.blit(tps_text, tps_rect) screen.blit(ticks_text, ticks_rect) screen.blit(cell_text, cell_rect) + + def update_tree_widget(self, time_delta): + """Update the tree widget.""" + if self.tree_widget: + self.tree_widget.update(time_delta) + + def render_tree_widget(self, screen): + """Render the tree widget.""" + if self.tree_widget and self.inspector_panel: + # Create a surface for the tree widget area + tree_surface = screen.subsurface(self.inspector_panel.rect) + self.tree_widget.draw(tree_surface) diff --git a/ui/inspector_tree.py b/ui/inspector_tree.py new file mode 100644 index 0000000..9bec63b --- /dev/null +++ b/ui/inspector_tree.py @@ -0,0 +1,274 @@ +""" +Tree node classes for the hierarchical inspector view. +Provides extensible tree structure for entity inspection. +""" + +from abc import ABC, abstractmethod +from typing import List, Optional, Dict, Any +import pygame + + +class TreeNode(ABC): + """Base class for tree nodes in the inspector.""" + + def __init__(self, label: str, parent: Optional['TreeNode'] = None): + self.label = label + self.parent = parent + self.children: List['TreeNode'] = [] + self.is_expanded = False + self.is_selected = False + self.depth = 0 if parent is None else parent.depth + 1 + self.rect = pygame.Rect(0, 0, 0, 20) # Will be updated during layout + + def add_child(self, child: 'TreeNode') -> None: + """Add a child node to this node.""" + child.parent = self + child.depth = self.depth + 1 + self.children.append(child) + + def remove_child(self, child: 'TreeNode') -> None: + """Remove a child node from this node.""" + if child in self.children: + self.children.remove(child) + child.parent = None + + def toggle_expand(self) -> None: + """Toggle the expanded state of this node.""" + if self.children: + self.is_expanded = not self.is_expanded + + def expand(self) -> None: + """Expand this node.""" + if self.children: + self.is_expanded = True + + def collapse(self) -> None: + """Collapse this node.""" + self.is_expanded = False + + def get_visible_children(self) -> List['TreeNode']: + """Get children that are currently visible.""" + if not self.is_expanded: + return [] + return self.children + + def get_all_visible_descendants(self) -> List['TreeNode']: + """Get all visible descendants of this node.""" + visible = [] + if self.is_expanded: + for child in self.children: + visible.append(child) + visible.extend(child.get_all_visible_descendants()) + return visible + + def is_leaf(self) -> bool: + """Check if this node has no children.""" + return len(self.children) == 0 + + @abstractmethod + def get_display_text(self) -> str: + """Get the display text for this node.""" + pass + + @abstractmethod + def can_expand(self) -> bool: + """Check if this node can be expanded.""" + pass + + def get_indent(self) -> int: + """Get the indentation width for this node.""" + return self.depth * 20 + + +class SimulationNode(TreeNode): + """Root node representing the entire simulation.""" + + def __init__(self, world): + super().__init__("Simulation") + self.world = world + self.is_expanded = True # Always expanded + + def get_display_text(self) -> str: + return f"Simulation" + + def can_expand(self) -> bool: + return True + + def update_entity_counts(self) -> None: + """Update entity type nodes with current world state.""" + entity_types = {} + + # Count entities by type + for entity in self.world.get_objects(): + entity_type = type(entity).__name__ + if entity_type not in entity_types: + entity_types[entity_type] = [] + entity_types[entity_type].append(entity) + + # Update children to match current entity types + existing_types = {child.label: child for child in self.children} + + # Remove types that no longer exist + for type_name in existing_types: + if type_name not in entity_types: + self.remove_child(existing_types[type_name]) + + # Add or update type nodes + for type_name, entities in entity_types.items(): + if type_name in existing_types: + # Update existing node + type_node = existing_types[type_name] + type_node.update_entities(entities) + else: + # Create new type node + type_node = EntityTypeNode(type_name, entities) + self.add_child(type_node) + + +class EntityTypeNode(TreeNode): + """Node representing a category of entities.""" + + def __init__(self, entity_type: str, entities: List[Any]): + super().__init__(entity_type) + self.entities = entities + self._update_children() + + def _update_children(self) -> None: + """Update child entity nodes to match current entities.""" + existing_ids = {child.entity_id: child for child in self.children} + current_ids = {id(entity): entity for entity in self.entities} + + # Remove entities that no longer exist + for entity_id in existing_ids: + if entity_id not in current_ids: + self.remove_child(existing_ids[entity_id]) + + # Add or update entity nodes + for entity_id, entity in current_ids.items(): + if entity_id in existing_ids: + # Update existing node + entity_node = existing_ids[entity_id] + entity_node.entity = entity + else: + # Create new entity node + entity_node = EntityNode(entity) + self.add_child(entity_node) + + def update_entities(self, entities: List[Any]) -> None: + """Update the entities for this type.""" + self.entities = entities + self._update_children() + + def get_display_text(self) -> str: + count = len(self.entities) + return f"{self.label} ({count})" + + def can_expand(self) -> bool: + return len(self.entities) > 0 + + +class EntityNode(TreeNode): + """Node representing an individual entity.""" + + def __init__(self, entity: Any): + # Use entity ID as the initial label, will be updated in get_display_text + super().__init__("", None) + self.entity = entity + self.entity_id = id(entity) + self.is_leaf = True # Entity nodes can't have children + + def get_display_text(self) -> str: + # Start with just ID, designed for extensibility + return f"Entity {self.entity_id}" + + def can_expand(self) -> bool: + return False + + def is_leaf(self) -> bool: + return True + + def get_entity_info(self) -> Dict[str, Any]: + """Get entity information for display purposes.""" + # Base implementation - can be extended later + info = { + 'id': self.entity_id, + 'type': type(self.entity).__name__ + } + + # Add position if available + if hasattr(self.entity, 'position'): + info['position'] = { + 'x': getattr(self.entity.position, 'x', 0), + 'y': getattr(self.entity.position, 'y', 0) + } + + return info + + +class TreeSelectionManager: + """Manages selection state for tree nodes.""" + + def __init__(self): + self.selected_nodes: List[TreeNode] = [] + self.last_selected_node: Optional[TreeNode] = None + + def select_node(self, node: TreeNode, multi_select: bool = False) -> None: + """Select a node, optionally with multi-select.""" + if not multi_select: + # Clear existing selection + for selected_node in self.selected_nodes: + selected_node.is_selected = False + self.selected_nodes.clear() + + if node.is_selected: + # Deselect if already selected + node.is_selected = False + if node in self.selected_nodes: + self.selected_nodes.remove(node) + if node == self.last_selected_node: + self.last_selected_node = None + else: + # Select the node + node.is_selected = True + self.selected_nodes.append(node) + self.last_selected_node = node + + def select_range(self, from_node: TreeNode, to_node: TreeNode, all_nodes: List[TreeNode]) -> None: + """Select a range of nodes between from_node and to_node.""" + if from_node not in all_nodes or to_node not in all_nodes: + return + + # Clear existing selection + for selected_node in self.selected_nodes: + selected_node.is_selected = False + self.selected_nodes.clear() + + # Find indices + from_index = all_nodes.index(from_node) + to_index = all_nodes.index(to_node) + + # Select range + start = min(from_index, to_index) + end = max(from_index, to_index) + + for i in range(start, end + 1): + node = all_nodes[i] + node.is_selected = True + self.selected_nodes.append(node) + + self.last_selected_node = to_node + + def clear_selection(self) -> None: + """Clear all selections.""" + for node in self.selected_nodes: + node.is_selected = False + self.selected_nodes.clear() + self.last_selected_node = None + + def get_selected_entities(self) -> List[Any]: + """Get the actual entities for selected entity nodes.""" + entities = [] + for node in self.selected_nodes: + if isinstance(node, EntityNode): + entities.append(node.entity) + return entities \ No newline at end of file diff --git a/ui/layout_manager.py b/ui/layout_manager.py new file mode 100644 index 0000000..732c665 --- /dev/null +++ b/ui/layout_manager.py @@ -0,0 +1,597 @@ +"""Layout management system with constraint-based positioning and event-driven updates.""" + +from typing import Dict, List, Optional, Tuple, Callable, Any +from dataclasses import dataclass, field +from enum import Enum +import math +import time + +from core.event_bus import EventBus, EventType, Event, PanelEvent, LayoutEvent + + +class LayoutType(Enum): + """Layout types for containers.""" + HORIZONTAL = "horizontal" + VERTICAL = "vertical" + GRID = "grid" + ABSOLUTE = "absolute" + + +class ResizePolicy(Enum): + """Resize policies for panels.""" + FIXED = "fixed" + FLEXIBLE = "flexible" + PROPORTIONAL = "proportional" + AUTO = "auto" + + +@dataclass +class LayoutConstraints: + """Layout constraints for a panel.""" + min_width: float = 0 + min_height: float = 0 + max_width: float = float('inf') + max_height: float = float('inf') + preferred_width: float = 0 + preferred_height: float = 0 + flex_width: float = 1.0 + flex_height: float = 1.0 + resize_policy: ResizePolicy = ResizePolicy.FLEXIBLE + + def apply_constraints(self, width: float, height: float) -> Tuple[float, float]: + """Apply size constraints to a given dimension.""" + width = max(self.min_width, min(self.max_width, width)) + height = max(self.min_height, min(self.max_height, height)) + return width, height + + +@dataclass +class PanelState: + """State information for a panel.""" + panel_id: str + x: float = 0 + y: float = 0 + width: float = 100 + height: float = 100 + visible: bool = True + focused: bool = False + constraints: LayoutConstraints = field(default_factory=LayoutConstraints) + parent: Optional[str] = None + children: List[str] = field(default_factory=list) + + def get_rect(self) -> Tuple[float, float, float, float]: + """Get panel rectangle as (x, y, width, height).""" + return self.x, self.y, self.width, self.height + + def contains_point(self, x: float, y: float) -> bool: + """Check if a point is inside the panel.""" + return (self.x <= x <= self.x + self.width and + self.y <= y <= self.y + self.height) + + +@dataclass +class ContainerState: + """State information for a layout container.""" + container_id: str + layout_type: LayoutType = LayoutType.HORIZONTAL + x: float = 0 + y: float = 0 + width: float = 100 + height: float = 100 + padding: float = 0 + spacing: float = 0 + panels: List[str] = field(default_factory=list) + parent: Optional[str] = None + + def get_rect(self) -> Tuple[float, float, float, float]: + """Get container rectangle as (x, y, width, height).""" + return self.x, self.y, self.width, self.height + + +@dataclass +class AnimationState: + """Animation state for smooth transitions.""" + target_x: float = 0 + target_y: float = 0 + target_width: float = 0 + target_height: float = 0 + start_x: float = 0 + start_y: float = 0 + start_width: float = 0 + start_height: float = 0 + progress: float = 0 + duration: float = 0.2 + easing: str = "ease_out_quad" + active: bool = False + + +class LayoutManager: + """Enhanced layout manager with constraint-based positioning and animations.""" + + def __init__(self, event_bus: Optional[EventBus] = None): + self.event_bus = event_bus + self.panels: Dict[str, PanelState] = {} + self.containers: Dict[str, ContainerState] = {} + self.animations: Dict[str, AnimationState] = {} + + # Root container + self.root_container = ContainerState( + container_id="root", + layout_type=LayoutType.VERTICAL + ) + self.containers["root"] = self.root_container + + # Layout preferences + self.animation_enabled = True + self.auto_layout = True + + # Performance tracking + self.last_layout_time = 0 + self.layout_count = 0 + + # Subscribe to events if available + if self.event_bus: + self._setup_event_subscriptions() + + def _setup_event_subscriptions(self): + """Setup event subscriptions for layout management.""" + self.event_bus.subscribe(EventType.PANEL_RESIZE, self._on_panel_resize) + self.event_bus.subscribe(EventType.PANEL_FOCUS, self._on_panel_focus) + self.event_bus.subscribe(EventType.LAYOUT_CHANGE, self._on_layout_change) + self.event_bus.subscribe(EventType.VIEWPORT_UPDATE, self._on_viewport_update) + + def add_panel(self, panel_id: str, x: float = 0, y: float = 0, + width: float = 100, height: float = 100, + constraints: Optional[LayoutConstraints] = None, + container_id: Optional[str] = None) -> PanelState: + """Add a new panel to the layout system.""" + panel = PanelState( + panel_id=panel_id, + x=x, y=y, width=width, height=height, + constraints=constraints or LayoutConstraints() + ) + + self.panels[panel_id] = panel + + # Add to container if specified + if container_id and container_id in self.containers: + self.containers[container_id].panels.append(panel_id) + panel.parent = container_id + else: + # Add to root by default + self.root_container.panels.append(panel_id) + panel.parent = "root" + + # Publish event + if self.event_bus: + self.event_bus.create_panel_event( + EventType.PANEL_RESIZE, panel_id, x, y, width, height, + source="LayoutManager.add_panel" + ) + + return panel + + def add_container(self, container_id: str, layout_type: LayoutType, + x: float = 0, y: float = 0, width: float = 100, height: float = 100, + parent_container: Optional[str] = None) -> ContainerState: + """Add a new layout container.""" + container = ContainerState( + container_id=container_id, + layout_type=layout_type, + x=x, y=y, width=width, height=height + ) + + self.containers[container_id] = container + + # Set parent relationship + if parent_container and parent_container in self.containers: + self.containers[parent_container].panels.append(container_id) + container.parent = parent_container + + return container + + def resize_panel(self, panel_id: str, width: float, height: float, + animate: bool = True) -> bool: + """Resize a panel with optional animation.""" + if panel_id not in self.panels: + return False + + panel = self.panels[panel_id] + + # Apply constraints + width, height = panel.constraints.apply_constraints(width, height) + + if animate and self.animation_enabled: + # Start animation + if panel_id not in self.animations: + self.animations[panel_id] = AnimationState() + + anim = self.animations[panel_id] + anim.start_x, anim.start_y = panel.x, panel.y + anim.start_width, anim.start_height = panel.width, panel.height + anim.target_x, anim.target_y = panel.x, panel.y + anim.target_width, anim.target_height = width, height + anim.progress = 0 + anim.active = True + else: + # Apply immediately + panel.width, panel.height = width, height + panel.x, panel.y = panel.x, panel.y # Keep position + + # Publish event + if self.event_bus: + self.event_bus.create_panel_event( + EventType.PANEL_RESIZE, panel_id, + panel.x, panel.y, panel.width, panel.height, + source="LayoutManager.resize_panel" + ) + + return True + + def move_panel(self, panel_id: str, x: float, y: float, + animate: bool = True) -> bool: + """Move a panel with optional animation.""" + if panel_id not in self.panels: + return False + + panel = self.panels[panel_id] + + if animate and self.animation_enabled: + # Start animation + if panel_id not in self.animations: + self.animations[panel_id] = AnimationState() + + anim = self.animations[panel_id] + anim.start_x, anim.start_y = panel.x, panel.y + anim.start_width, anim.start_height = panel.width, panel.height + anim.target_x, anim.target_y = x, y + anim.target_width, anim.target_height = panel.width, panel.height + anim.progress = 0 + anim.active = True + else: + # Apply immediately + panel.x, panel.y = x, y + + # Publish event + if self.event_bus: + self.event_bus.create_panel_event( + EventType.PANEL_RESIZE, panel_id, + panel.x, panel.y, panel.width, panel.height, + source="LayoutManager.move_panel" + ) + + return True + + def set_panel_constraints(self, panel_id: str, constraints: LayoutConstraints) -> bool: + """Set layout constraints for a panel.""" + if panel_id not in self.panels: + return False + + self.panels[panel_id].constraints = constraints + return True + + def update_layout(self, container_id: Optional[str] = None) -> bool: + """Update layout calculations for a container or all containers.""" + start_time = time.time() + + if container_id: + containers_to_update = [container_id] if container_id in self.containers else [] + else: + containers_to_update = list(self.containers.keys()) + + for container_id in containers_to_update: + self._update_container_layout(container_id) + + # Update animations + self._update_animations() + + # Track performance + self.last_layout_time = time.time() - start_time + self.layout_count += 1 + + return True + + def _update_container_layout(self, container_id: str): + """Update layout for a specific container.""" + if container_id not in self.containers: + return + + container = self.containers[container_id] + + if container.layout_type == LayoutType.HORIZONTAL: + self._layout_horizontal(container) + elif container.layout_type == LayoutType.VERTICAL: + self._layout_vertical(container) + elif container.layout_type == LayoutType.GRID: + self._layout_grid(container) + # ABSOLUTE layout doesn't need automatic positioning + + def _layout_horizontal(self, container: ContainerState): + """Layout panels horizontally within a container.""" + available_width = container.width - (2 * container.padding) + total_flex = 0 + fixed_width_total = 0 + + # Calculate flex and fixed widths + panel_ids = [p for p in container.panels if p in self.panels] + for panel_id in panel_ids: + panel = self.panels[panel_id] + if not panel.visible: + continue + + if panel.constraints.resize_policy == ResizePolicy.FIXED: + fixed_width_total += panel.constraints.preferred_width + else: + total_flex += panel.constraints.flex_width + + # Add spacing to fixed total + if len(panel_ids) > 1: + fixed_width_total += (len(panel_ids) - 1) * container.spacing + + available_flex_width = max(0, available_width - fixed_width_total) + + # Position panels + current_x = container.x + container.padding + for panel_id in panel_ids: + panel = self.panels[panel_id] + if not panel.visible: + continue + + if panel.constraints.resize_policy == ResizePolicy.FIXED: + width = panel.constraints.preferred_width + else: + if total_flex > 0: + width = available_flex_width * (panel.constraints.flex_width / total_flex) + else: + width = panel.width + + # Apply constraints + width, height = panel.constraints.apply_constraints(width, container.height) + + panel.x = current_x + panel.y = container.y + container.padding + panel.width = width + panel.height = height + + current_x += width + container.spacing + + def _layout_vertical(self, container: ContainerState): + """Layout panels vertically within a container.""" + available_height = container.height - (2 * container.padding) + total_flex = 0 + fixed_height_total = 0 + + # Calculate flex and fixed heights + panel_ids = [p for p in container.panels if p in self.panels] + for panel_id in panel_ids: + panel = self.panels[panel_id] + if not panel.visible: + continue + + if panel.constraints.resize_policy == ResizePolicy.FIXED: + fixed_height_total += panel.constraints.preferred_height + else: + total_flex += panel.constraints.flex_height + + # Add spacing to fixed total + if len(panel_ids) > 1: + fixed_height_total += (len(panel_ids) - 1) * container.spacing + + available_flex_height = max(0, available_height - fixed_height_total) + + # Position panels + current_y = container.y + container.padding + for panel_id in panel_ids: + panel = self.panels[panel_id] + if not panel.visible: + continue + + if panel.constraints.resize_policy == ResizePolicy.FIXED: + height = panel.constraints.preferred_height + else: + if total_flex > 0: + height = available_flex_height * (panel.constraints.flex_height / total_flex) + else: + height = panel.height + + # Apply constraints + width, height = panel.constraints.apply_constraints(container.width, height) + + panel.x = container.x + container.padding + panel.y = current_y + panel.width = width + panel.height = height + + current_y += height + container.spacing + + def _layout_grid(self, container: ContainerState): + """Layout panels in a grid within a container.""" + # Simple grid layout - can be enhanced with more sophisticated grid logic + panel_ids = [p for p in container.panels if p in self.panels] + if not panel_ids: + return + + # Calculate grid dimensions (for now, use a simple 2xN grid) + cols = min(2, len(panel_ids)) + rows = math.ceil(len(panel_ids) / cols) + + cell_width = (container.width - (2 * container.padding) - (cols - 1) * container.spacing) / cols + cell_height = (container.height - (2 * container.padding) - (rows - 1) * container.spacing) / rows + + for i, panel_id in enumerate(panel_ids): + panel = self.panels[panel_id] + if not panel.visible: + continue + + col = i % cols + row = i // cols + + panel.x = container.x + container.padding + col * (cell_width + container.spacing) + panel.y = container.y + container.padding + row * (cell_height + container.spacing) + panel.width, panel.height = panel.constraints.apply_constraints(cell_width, cell_height) + + def _update_animations(self): + """Update active animations.""" + if not self.animation_enabled: + return + + dt = 0.016 # Assume 60 FPS for now + completed_animations = [] + + for panel_id, anim in self.animations.items(): + if not anim.active or panel_id not in self.panels: + continue + + anim.progress += dt / anim.duration + + if anim.progress >= 1.0: + anim.progress = 1.0 + anim.active = False + completed_animations.append(panel_id) + + # Apply easing + t = self._apply_easing(anim.progress, anim.easing) + + # Interpolate position and size + panel = self.panels[panel_id] + panel.x = anim.start_x + (anim.target_x - anim.start_x) * t + panel.y = anim.start_y + (anim.target_y - anim.start_y) * t + panel.width = anim.start_width + (anim.target_width - anim.start_width) * t + panel.height = anim.start_height + (anim.target_height - anim.start_height) * t + + # Publish update event + if self.event_bus: + self.event_bus.create_panel_event( + EventType.PANEL_RESIZE, panel_id, + panel.x, panel.y, panel.width, panel.height, + source="LayoutManager.animation" + ) + + # Clean up completed animations + for panel_id in completed_animations: + del self.animations[panel_id] + + def _apply_easing(self, t: float, easing_type: str) -> float: + """Apply easing function to animation progress.""" + if easing_type == "linear": + return t + elif easing_type == "ease_in_quad": + return t * t + elif easing_type == "ease_out_quad": + return 1 - (1 - t) * (1 - t) + elif easing_type == "ease_in_out_quad": + return 2 * t * t if t < 0.5 else 1 - pow(-2 * t + 2, 2) / 2 + else: + return t # Default to linear + + def get_panel_at_position(self, x: float, y: float) -> Optional[str]: + """Get the panel at a given position.""" + # Check panels in reverse order (top to bottom) + for panel_id, panel in reversed(list(self.panels.items())): + if panel.visible and panel.contains_point(x, y): + return panel_id + return None + + def get_panel_rect(self, panel_id: str) -> Optional[Tuple[float, float, float, float]]: + """Get the rectangle of a panel.""" + if panel_id in self.panels: + return self.panels[panel_id].get_rect() + return None + + def remove_panel(self, panel_id: str) -> bool: + """Remove a panel from the layout system.""" + if panel_id not in self.panels: + return False + + panel = self.panels[panel_id] + + # Remove from parent container + if panel.parent and panel.parent in self.containers: + container = self.containers[panel.parent] + if panel_id in container.panels: + container.panels.remove(panel_id) + + # Remove panel and any animations + del self.panels[panel_id] + if panel_id in self.animations: + del self.animations[panel_id] + + return True + + def set_viewport_size(self, width: float, height: float): + """Update the root container size.""" + self.root_container.width = width + self.root_container.height = height + + if self.event_bus: + self.event_bus.create_event( + EventType.VIEWPORT_UPDATE, + {"width": width, "height": height}, + source="LayoutManager.set_viewport_size" + ) + + if self.auto_layout: + self.update_layout() + + def focus_panel(self, panel_id: str) -> bool: + """Set focus to a panel.""" + if panel_id not in self.panels: + return False + + # Clear focus from all panels + for panel in self.panels.values(): + panel.focused = False + + # Set focus to target panel + self.panels[panel_id].focused = True + + if self.event_bus: + self.event_bus.create_event( + EventType.PANEL_FOCUS, + {"panel_id": panel_id, "focused": True}, + source="LayoutManager.focus_panel" + ) + + return True + + def get_layout_stats(self) -> Dict[str, Any]: + """Get layout performance statistics.""" + return { + "panel_count": len(self.panels), + "container_count": len(self.containers), + "active_animations": len([a for a in self.animations.values() if a.active]), + "last_layout_time": self.last_layout_time, + "total_layouts": self.layout_count, + "auto_layout_enabled": self.auto_layout, + "animation_enabled": self.animation_enabled + } + + # Event handlers + def _on_panel_resize(self, event: PanelEvent): + """Handle panel resize events.""" + if event.panel_id in self.panels: + panel = self.panels[event.panel_id] + if not (hasattr(self, '_internal_update') and self._internal_update): + panel.x, panel.y = event.x, event.y + panel.width, panel.height = event.width, event.height + + def _on_panel_focus(self, event: Event): + """Handle panel focus events.""" + panel_id = event.get("panel_id") + if panel_id: + self.focus_panel(panel_id) + + def _on_layout_change(self, event: LayoutEvent): + """Handle layout change events.""" + if event.container_id in self.containers: + container = self.containers[event.container_id] + if event.layout_type: + container.layout_type = LayoutType(event.layout_type) + self.update_layout(event.container_id) + + def _on_viewport_update(self, event: Event): + """Handle viewport update events.""" + width = event.get("width") + height = event.get("height") + if width is not None and height is not None: + self.set_viewport_size(width, height) \ No newline at end of file diff --git a/ui/tree_widget.py b/ui/tree_widget.py new file mode 100644 index 0000000..5d94b81 --- /dev/null +++ b/ui/tree_widget.py @@ -0,0 +1,313 @@ +""" +Tree widget for displaying hierarchical inspector view. +Handles rendering, interaction, and navigation of tree nodes. +""" + +import pygame +import pygame_gui +from pygame_gui.core import UIElement +from typing import List, Optional, Tuple, Any +from ui.inspector_tree import TreeNode, SimulationNode, TreeSelectionManager + + +class TreeWidget(UIElement): + """Interactive tree widget for hierarchical entity inspection.""" + + def __init__(self, relative_rect: pygame.Rect, manager: pygame_gui.UIManager, world: Any): + super().__init__(relative_rect, manager, container=None, + starting_height=1, layer_thickness=1) + + self.world = world + self.font = pygame.font.Font(None, 16) + self.small_font = pygame.font.Font(None, 12) + + # Tree structure + self.root_node = SimulationNode(world) + self.visible_nodes: List[TreeNode] = [] + self.all_nodes: List[TreeNode] = [] + + # Selection management + self.selection_manager = TreeSelectionManager() + + # Visual properties + self.node_height = 20 + self.expand_collapse_width = 20 + self.icon_size = 8 + self.text_color = (200, 200, 200) + self.selected_color = (50, 100, 150) + self.hover_color = (60, 60, 80) + self.expand_icon_color = (150, 150, 150) + + # Interaction state + self.hovered_node: Optional[TreeNode] = None + self.drag_start_node: Optional[TreeNode] = None + self.is_dragging = False + + # Scrolling + self.scroll_offset = 0 + self.total_height = 0 + + # Initialize tree structure + self._update_tree_structure() + self._update_visible_nodes() + + def _update_tree_structure(self) -> None: + """Update the tree structure based on current world state.""" + self.root_node.update_entity_counts() + self.all_nodes = self._build_all_nodes_list(self.root_node) + + def _build_all_nodes_list(self, node: TreeNode) -> List[TreeNode]: + """Build a flat list of all nodes in the tree.""" + nodes = [node] + for child in node.children: + nodes.extend(self._build_all_nodes_list(child)) + return nodes + + def _update_visible_nodes(self) -> None: + """Update the list of visible nodes based on expanded state.""" + self.visible_nodes = [self.root_node] + self.visible_nodes.extend(self.root_node.get_all_visible_descendants()) + self._update_node_layout() + self.total_height = len(self.visible_nodes) * self.node_height + + def _update_node_layout(self) -> None: + """Update the layout rectangles for all visible nodes.""" + y_offset = 0 + for node in self.visible_nodes: + node.rect = pygame.Rect( + 0, + y_offset - self.scroll_offset, + self.rect.width, + self.node_height + ) + y_offset += self.node_height + + def _get_node_at_position_local(self, local_pos: Tuple[int, int]) -> Optional[TreeNode]: + """Get the node at the given local position (already converted).""" + for node in self.visible_nodes: + if node.rect.collidepoint(local_pos): + return node + return None + + def _get_node_at_position(self, pos: Tuple[int, int]) -> Optional[TreeNode]: + """Get the node at the given screen position.""" + # Convert screen coordinates to local tree widget coordinates + local_pos = (pos[0] - self.rect.x, pos[1] - self.rect.y) + return self._get_node_at_position_local(local_pos) + + def _get_expand_collapse_rect(self, node: TreeNode) -> pygame.Rect: + """Get the rectangle for the expand/collapse icon.""" + indent = node.get_indent() + return pygame.Rect( + indent + 5, + node.rect.y + (self.node_height - self.icon_size) // 2, + self.icon_size, + self.icon_size + ) + + def _is_click_on_expand_collapse_local(self, node: TreeNode, local_pos: Tuple[int, int]) -> bool: + """Check if a click is on the expand/collapse icon (local coordinates).""" + if not node.can_expand(): + return False + + expand_rect = self._get_expand_collapse_rect(node) + return expand_rect.collidepoint(local_pos) + + def _is_click_on_expand_collapse(self, node: TreeNode, pos: Tuple[int, int]) -> bool: + """Check if a click is on the expand/collapse icon.""" + if not node.can_expand(): + return False + + local_pos = (pos[0] - self.rect.x, pos[1] - self.rect.y) + expand_rect = self._get_expand_collapse_rect(node) + return expand_rect.collidepoint(local_pos) + + def _render_node(self, surface: pygame.Surface, node: TreeNode) -> None: + """Render a single tree node.""" + if not node.rect.colliderect(surface.get_rect()): + return + + # Background + if node.is_selected: + pygame.draw.rect(surface, self.selected_color, node.rect) + elif node == self.hovered_node: + pygame.draw.rect(surface, self.hover_color, node.rect) + + # Expand/collapse icon + if node.can_expand(): + icon_rect = self._get_expand_collapse_rect(node) + icon_color = self.expand_icon_color + + # Draw + or - icon + center_x = icon_rect.centerx + center_y = icon_rect.centery + size = 3 + + # Horizontal line + pygame.draw.line(surface, icon_color, + (center_x - size, center_y), + (center_x + size, center_y), 1) + + # Vertical line (only for collapsed nodes) + if not node.is_expanded: + pygame.draw.line(surface, icon_color, + (center_x, center_y - size), + (center_x, center_y + size), 1) + + # Node text + indent = node.get_indent() + text_x = indent + (self.expand_collapse_width if node.can_expand() else 10) + + text_surface = self.font.render(node.get_display_text(), True, self.text_color) + text_rect = pygame.Rect( + text_x, + node.rect.y + (self.node_height - text_surface.get_height()) // 2, + text_surface.get_width(), + text_surface.get_height() + ) + + # Clip text if it's too wide + max_text_width = self.rect.width - text_x - 10 + if text_rect.width > max_text_width: + # Truncate text + text = node.get_display_text() + while text and text_surface.get_width() > max_text_width - 20: + text = text[:-1] + text_surface = self.font.render(text + "...", True, self.text_color) + + surface.blit(text_surface, text_rect) + + def handle_event(self, event: pygame.event.Event) -> bool: + """Handle input events for the tree widget.""" + if event.type == pygame.MOUSEBUTTONDOWN: + if event.button == 1: # Left click + # Event position is already converted to local coordinates by HUD + node = self._get_node_at_position_local(event.pos) + if node: + # Check for expand/collapse click + if self._is_click_on_expand_collapse_local(node, event.pos): + node.toggle_expand() + self._update_visible_nodes() + else: + # Selection click + multi_select = pygame.key.get_pressed()[pygame.K_LSHIFT] + self.selection_manager.select_node(node, multi_select) + return True + + elif event.type == pygame.MOUSEMOTION: + # Event position is already converted to local coordinates by HUD + self.hovered_node = self._get_node_at_position_local(event.pos) + return True + + elif event.type == pygame.MOUSEWHEEL: + # Handle scrolling + if event.y > 0: + self.scroll_offset = max(0, self.scroll_offset - self.node_height) + else: + max_scroll = max(0, self.total_height - self.rect.height) + self.scroll_offset = min(max_scroll, self.scroll_offset + self.node_height) + self._update_node_layout() + return True + + return False + + def update(self, time_delta: float) -> None: + """Update the tree widget with smart performance optimizations.""" + # Smart update system: only update tree structure when necessary + if self._should_update_tree(): + self._update_tree_structure() + self._update_visible_nodes() + + def _should_update_tree(self) -> bool: + """Determine if the tree structure needs updating.""" + if not hasattr(self, '_last_entity_count'): + self._last_entity_count = len(self.world.get_objects()) + return True + + current_count = len(self.world.get_objects()) + if current_count != self._last_entity_count: + self._last_entity_count = current_count + return True + + # Also check if any previously selected entities have been removed + if self.selection_manager.selected_nodes: + for node in self.selection_manager.selected_nodes[:]: + if hasattr(node, 'entity'): + try: + # Check if entity still exists in world + if node.entity not in self.world.get_objects(): + return True + except: + return True + + return False + + def draw(self, surface: pygame.Surface) -> None: + """Draw the tree widget.""" + # Create a clipping surface for the tree area + tree_surface = pygame.Surface((self.rect.width, self.rect.height)) + tree_surface.fill((30, 30, 40)) # Background color + + # Render visible nodes + for node in self.visible_nodes: + self._render_node(tree_surface, node) + + # Blit to main surface at position (0,0) since this is already positioned + surface.blit(tree_surface, (0, 0)) + + def expand_all(self) -> None: + """Expand all nodes in the tree.""" + for node in self.all_nodes: + if node.can_expand(): + node.expand() + self._update_visible_nodes() + + def collapse_all(self) -> None: + """Collapse all nodes except the root.""" + for node in self.all_nodes[1:]: # Skip root node + if node.can_expand(): + node.collapse() + self._update_visible_nodes() + + def get_selected_entities(self) -> List[Any]: + """Get the entities currently selected in the tree.""" + return self.selection_manager.get_selected_entities() + + def select_entities(self, entities: List[Any]) -> None: + """Select specific entities in the tree.""" + self.selection_manager.clear_selection() + + for entity in entities: + # Find the entity node for this entity + for node in self.all_nodes: + if hasattr(node, 'entity') and node.entity == entity: + self.selection_manager.select_node(node, multi_select=True) + break + + # Expand parent nodes to show selected entities + for node in self.selection_manager.selected_nodes: + parent = node.parent + while parent: + if parent.can_expand(): + parent.expand() + parent = parent.parent + + self._update_visible_nodes() + + def scroll_to_node(self, node: TreeNode) -> None: + """Scroll the tree to show the given node.""" + if node not in self.visible_nodes: + return + + node_top = node.rect.y + node_bottom = node.rect.y + node.rect.height + + if node_top < 0: + self.scroll_offset = max(0, self.scroll_offset + node_top) + elif node_bottom > self.rect.height: + self.scroll_offset = min( + self.total_height - self.rect.height, + self.scroll_offset + (node_bottom - self.rect.height) + ) + + self._update_node_layout() \ No newline at end of file