Compare commits

..

8 Commits

12 changed files with 357 additions and 53 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ uv.lock
.idea/ .idea/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
simulation_output/

View File

@ -42,6 +42,22 @@ HUD_MARGIN = 10
LINE_HEIGHT = 20 LINE_HEIGHT = 20
SELECTION_THRESHOLD = 3 # pixels SELECTION_THRESHOLD = 3 # pixels
# Unified Panel Styling System (based on tree widget design)
PANEL_BACKGROUND_COLOR = (30, 30, 40) # Dark blue-gray background
PANEL_SELECTED_COLOR = (50, 100, 150) # Blue highlight for selected elements
PANEL_HOVER_COLOR = (60, 60, 80) # Dark blue highlight for interactive elements
PANEL_TEXT_COLOR = (200, 200, 200) # Light gray text
PANEL_ICON_COLOR = (150, 150, 150) # Medium gray icons
PANEL_BORDER_COLOR = (220, 220, 220) # Light gray borders/dividers
# Panel spacing and dimensions
PANEL_DIVIDER_WIDTH = 0 # No divider lines between panels
PANEL_BORDER_WIDTH = 2 # Border width for emphasis elements
PANEL_INTERNAL_PADDING = 8 # Standard padding inside panels
PANEL_TIGHT_SPACING = 4 # Tight spacing between components
PANEL_NODE_HEIGHT = 20 # Height for list/grid items
PANEL_INDENTATION = 20 # Indentation per hierarchy level
# Simulation settings # Simulation settings
FOOD_SPAWNING = True FOOD_SPAWNING = True
FOOD_OBJECTS_COUNT = 500 FOOD_OBJECTS_COUNT = 500

View File

@ -24,9 +24,9 @@ class OutputConfig:
enabled: bool = True enabled: bool = True
directory: str = "simulation_output" directory: str = "simulation_output"
formats: List[str] = field(default_factory=lambda: ['json']) formats: List[str] = field(default_factory=lambda: ['json'])
collect_metrics: bool = True collect_metrics: bool = False
collect_entities: bool = True collect_entities: bool = False
collect_evolution: bool = True collect_evolution: bool = False
metrics_interval: int = 100 metrics_interval: int = 100
entities_interval: int = 1000 entities_interval: int = 1000
evolution_interval: int = 1000 evolution_interval: int = 1000

View File

@ -127,7 +127,9 @@ class InputHandler:
# Zoom in viewport # Zoom in viewport
self.camera.handle_zoom(event.y) self.camera.handle_zoom(event.y)
elif inspector_rect and inspector_rect.collidepoint(mouse_x, mouse_y) and self.hud.tree_widget: elif inspector_rect and inspector_rect.collidepoint(mouse_x, mouse_y) and self.hud.tree_widget:
# Scroll tree widget in inspector - convert to local coordinates if needed # Scroll tree widget in inspector
if not viewport_rect.collidepoint(mouse_x, mouse_y):
# Convert to local coordinates if needed
if not hasattr(event, 'pos'): if not hasattr(event, 'pos'):
event.pos = (mouse_x - inspector_rect.x, mouse_y - inspector_rect.y) event.pos = (mouse_x - inspector_rect.x, mouse_y - inspector_rect.y)
else: else:

View File

@ -269,9 +269,11 @@ class SimulationEngine:
# Update tree widget # Update tree widget
self.hud.update_tree_widget(deltatime) self.hud.update_tree_widget(deltatime)
# Draw panel backgrounds first (before pygame_gui UI)
self.hud.render_panel_backgrounds(self.screen)
# Draw UI elements # Draw UI elements
self.hud.manager.draw_ui(self.screen) self.hud.manager.draw_ui(self.screen)
self.hud.draw_splitters(self.screen)
# Render tree widget # Render tree widget
self.hud.render_tree_widget(self.screen) self.hud.render_tree_widget(self.screen)

View File

@ -12,6 +12,11 @@ from output import MetricsCollector, EntityCollector, EvolutionCollector
from output.formatters.json_formatter import JSONFormatter from output.formatters.json_formatter import JSONFormatter
from output.formatters.csv_formatter import CSVFormatter from output.formatters.csv_formatter import CSVFormatter
from output.writers.file_writer import FileWriter from output.writers.file_writer import FileWriter
try:
from tqdm import tqdm
TQDM_AVAILABLE = True
except ImportError:
TQDM_AVAILABLE = False
@dataclass @dataclass
@ -56,6 +61,12 @@ class HeadlessSimulationEngine:
'evolution': [] 'evolution': []
} }
# Progress tracking
self.files_written = 0
self.last_progress_update = 0
self.progress_update_interval = 1.0 # Update progress every second
self.progress_bar = None
# Setup signal handlers for graceful shutdown # Setup signal handlers for graceful shutdown
signal.signal(signal.SIGINT, self._signal_handler) signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler) signal.signal(signal.SIGTERM, self._signal_handler)
@ -88,6 +99,120 @@ class HeadlessSimulationEngine:
return collectors return collectors
def _init_progress_bar(self):
"""Initialize progress bar for simulation."""
if not TQDM_AVAILABLE:
return
# Determine progress total based on configuration
if self.config.max_ticks:
total = self.config.max_ticks
unit = 'ticks'
elif self.config.max_duration:
total = int(self.config.max_duration)
unit = 'sec'
else:
# No clear total - create indeterminate progress bar
total = None
unit = 'ticks'
if total:
self.progress_bar = tqdm(
total=total,
unit=unit,
desc="Simulation",
leave=True, # Keep the bar when done
bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]'
)
else:
self.progress_bar = tqdm(
unit='ticks',
desc="Simulation",
leave=True,
bar_format='{l_bar}{bar}| {n_fmt} [{elapsed}, {rate_fmt}]'
)
def _update_progress_bar(self):
"""Update progress bar with current status."""
current_time = time.time()
if current_time - self.last_progress_update < self.progress_update_interval:
return
current_tick = self.simulation_core.state.total_ticks
tps = self.simulation_core.state.actual_tps
elapsed = current_time - self.start_time
if TQDM_AVAILABLE and self.progress_bar:
# Use tqdm progress bar
if self.config.max_ticks:
# Update based on tick progress
progress = min(current_tick, self.config.max_ticks)
self.progress_bar.n = progress
self.progress_bar.set_postfix({
'TPS': f'{tps:.1f}',
'Files': self.files_written
})
elif self.config.max_duration:
# Update based on elapsed time
progress = min(elapsed, self.config.max_duration)
self.progress_bar.n = int(progress)
self.progress_bar.set_postfix({
'TPS': f'{tps:.1f}',
'Files': self.files_written,
'Tick': current_tick
})
else:
# Indeterminate progress
self.progress_bar.n = current_tick
self.progress_bar.set_postfix({
'TPS': f'{tps:.1f}',
'Files': self.files_written
})
self.progress_bar.refresh()
else:
# Simple text-based progress
eta_text = ""
if self.config.max_ticks and current_tick > 0:
tick_rate = current_tick / elapsed if elapsed > 0 else 0
remaining_ticks = self.config.max_ticks - current_tick
eta_seconds = remaining_ticks / tick_rate if tick_rate > 0 else 0
eta_minutes, eta_seconds = divmod(eta_seconds, 60)
eta_text = f"ETA: {int(eta_minutes)}m{int(eta_seconds)}s"
elif self.config.max_duration:
remaining_seconds = self.config.max_duration - elapsed
eta_minutes, eta_seconds = divmod(remaining_seconds, 60)
eta_text = f"ETA: {int(eta_minutes)}m{int(eta_seconds)}s"
# Calculate progress percentage if we have a limit
progress_pct = ""
if self.config.max_ticks:
pct = (current_tick / self.config.max_ticks) * 100
progress_pct = f"{pct:.1f}%"
elif self.config.max_duration:
pct = (elapsed / self.config.max_duration) * 100
progress_pct = f"{pct:.1f}%"
progress_line = f"[{current_time - self.start_time:.1f}s] "
if progress_pct:
progress_line += f"Progress: {progress_pct} "
progress_line += f"Tick: {current_tick} TPS: {tps:.1f} Files: {self.files_written}"
if eta_text:
progress_line += f" {eta_text}"
# Overwrite the previous line (using carriage return)
print(f"\r{progress_line}", end="", flush=True)
self.last_progress_update = current_time
def _close_progress_bar(self):
"""Close the progress bar."""
if not TQDM_AVAILABLE and self.running:
# Print a newline to clear the text progress line
print()
elif TQDM_AVAILABLE and self.progress_bar:
self.progress_bar.close()
def run(self) -> Dict[str, Any]: def run(self) -> Dict[str, Any]:
"""Run the headless simulation.""" """Run the headless simulation."""
# Determine if we should run at max speed # Determine if we should run at max speed
@ -107,10 +232,12 @@ class HeadlessSimulationEngine:
self.start_time = time.time() self.start_time = time.time()
self.simulation_core.start() self.simulation_core.start()
# Initialize progress bar
self._init_progress_bar()
# Enable sprint mode for maximum speed if not real-time mode # Enable sprint mode for maximum speed if not real-time mode
if max_speed_mode: if max_speed_mode:
self.simulation_core.timing.set_sprint_mode(True) self.simulation_core.timing.set_sprint_mode(True)
print("Running at maximum speed (sprint mode enabled)")
last_batch_time = time.time() last_batch_time = time.time()
batch_interval = 5.0 batch_interval = 5.0
@ -137,6 +264,9 @@ class HeadlessSimulationEngine:
self._write_batch_data() self._write_batch_data()
last_batch_time = time.time() last_batch_time = time.time()
# Update progress bar
self._update_progress_bar()
# Real-time delay if needed # Real-time delay if needed
if self.config.real_time: if self.config.real_time:
time.sleep(0.016) # ~60 FPS time.sleep(0.016) # ~60 FPS
@ -206,14 +336,17 @@ class HeadlessSimulationEngine:
} }
formatted_data = formatter.format(combined_data) formatted_data = formatter.format(combined_data)
self.file_writer.write(formatted_data, filename) self.file_writer.write(formatted_data, filename)
self.files_written += 1
# Clear written data # Clear written data
data_list.clear() data_list.clear()
print(f"Wrote batch data at tick {self.simulation_core.state.total_ticks}")
def _finalize(self): def _finalize(self):
"""Finalize simulation and write remaining data.""" """Finalize simulation and write remaining data."""
# Close progress bar
self._close_progress_bar()
print("Finalizing simulation...") print("Finalizing simulation...")
# Write any remaining data # Write any remaining data
@ -224,12 +357,14 @@ class HeadlessSimulationEngine:
if 'json' in self.formatters: if 'json' in self.formatters:
summary_data = self.formatters['json'].format(summary) summary_data = self.formatters['json'].format(summary)
self.file_writer.write(summary_data, "simulation_summary.json") self.file_writer.write(summary_data, "simulation_summary.json")
self.files_written += 1
# Stop simulation # Stop simulation
self.simulation_core.stop() self.simulation_core.stop()
self.file_writer.close() self.file_writer.close()
print("Simulation completed") print("Simulation completed")
print(f"Total files written: {self.files_written}")
def _get_summary(self) -> Dict[str, Any]: def _get_summary(self) -> Dict[str, Any]:
"""Get simulation summary.""" """Get simulation summary."""

View File

@ -51,6 +51,26 @@ def main():
action="store_true", action="store_true",
help="Run in real-time mode (instead of as fast as possible)" help="Run in real-time mode (instead of as fast as possible)"
) )
parser.add_argument(
"--collect-metrics",
action="store_true",
help="Enable metrics data collection"
)
parser.add_argument(
"--collect-entities",
action="store_true",
help="Enable entity data collection"
)
parser.add_argument(
"--collect-evolution",
action="store_true",
help="Enable evolution data collection"
)
parser.add_argument(
"--collect-all",
action="store_true",
help="Enable all data collection (metrics, entities, evolution)"
)
parser.add_argument( parser.add_argument(
"--collect-every-tick", "--collect-every-tick",
action="store_true", action="store_true",
@ -90,11 +110,35 @@ def main():
headless_config.output.directory = args.output_dir headless_config.output.directory = args.output_dir
if args.real_time: if args.real_time:
headless_config.output.real_time = True headless_config.output.real_time = True
# Handle data collection arguments - only enable if explicitly requested
# Start with all disabled (default)
headless_config.output.collect_metrics = False
headless_config.output.collect_entities = False
headless_config.output.collect_evolution = False
if args.collect_all:
# Enable all collection
headless_config.output.collect_metrics = True
headless_config.output.collect_entities = True
headless_config.output.collect_evolution = True
else:
# Enable specific collection types
if args.collect_metrics:
headless_config.output.collect_metrics = True
if args.collect_entities:
headless_config.output.collect_entities = True
if args.collect_evolution:
headless_config.output.collect_evolution = True
if args.collect_every_tick: if args.collect_every_tick:
# Set all collection intervals to 1 for every-tick collection # Set all collection intervals to 1 for every-tick collection
headless_config.output.metrics_interval = 1 headless_config.output.metrics_interval = 1
headless_config.output.entities_interval = 1 headless_config.output.entities_interval = 1
headless_config.output.evolution_interval = 1 headless_config.output.evolution_interval = 1
# Also enable all collection if using --collect-every-tick (backward compatibility)
headless_config.output.collect_metrics = True
headless_config.output.collect_entities = True
headless_config.output.collect_evolution = True
except Exception as e: except Exception as e:
print(f"Error loading configuration: {e}") print(f"Error loading configuration: {e}")

View File

@ -12,6 +12,7 @@ dependencies = [
"pygame>=2.6.1", "pygame>=2.6.1",
"pygame-gui>=0.6.14", "pygame-gui>=0.6.14",
"pytest>=8.3.5", "pytest>=8.3.5",
"tqdm>=4.67.1",
] ]
[dependency-groups] [dependency-groups]

114
ui/hud.py
View File

@ -10,9 +10,26 @@ from pygame_gui.elements import UIPanel, UIButton, UITextEntryLine, UILabel
from ui.tree_widget import TreeWidget from ui.tree_widget import TreeWidget
import math import math
# Custom HUD colors (preserving existing functionality)
DARK_GRAY = (40, 40, 40) DARK_GRAY = (40, 40, 40)
DARKER_GRAY = (25, 25, 25) DARKER_GRAY = (25, 25, 25)
def create_panel_style(manager: pygame_gui.UIManager) -> dict:
"""Create unified styling dictionary for panels."""
return {
'panel_background': PANEL_BACKGROUND_COLOR,
'border_color': PANEL_BORDER_COLOR,
'text_color': PANEL_TEXT_COLOR,
'internal_padding': PANEL_INTERNAL_PADDING,
'border_width': PANEL_BORDER_WIDTH
}
def render_panel_divider(surface: pygame.Surface, rect: pygame.Rect):
"""Render a thin divider line between panels."""
pygame.draw.rect(surface, PANEL_BORDER_COLOR, rect, PANEL_DIVIDER_WIDTH)
# Panel visibility constants # Panel visibility constants
SHOW_CONTROL_BAR = True SHOW_CONTROL_BAR = True
SHOW_INSPECTOR_PANEL = True SHOW_INSPECTOR_PANEL = True
@ -50,13 +67,16 @@ class HUD:
self.world = None # Will be set when world is available self.world = None # Will be set when world is available
self._last_tree_selection = None # Track last selection to avoid unnecessary updates self._last_tree_selection = None # Track last selection to avoid unnecessary updates
# Initialize unified panel styling
self.panel_style = create_panel_style(self.manager)
self._create_panels() self._create_panels()
self._create_simulation_controls() self._create_simulation_controls()
def _create_panels(self): def _create_panels(self):
self.panels = [] self.panels = []
# Top control bar # Top control bar - full width, positioned at top
if SHOW_CONTROL_BAR: if SHOW_CONTROL_BAR:
self.control_bar = UIPanel( self.control_bar = UIPanel(
relative_rect=pygame.Rect(0, 0, self.screen_width, self.control_bar_height), relative_rect=pygame.Rect(0, 0, self.screen_width, self.control_bar_height),
@ -67,34 +87,35 @@ class HUD:
else: else:
self.control_bar = None self.control_bar = None
# Left inspector with tree widget # Calculate vertical position for side panels (edge-to-edge with control bar)
side_panel_top = self.control_bar_height if SHOW_CONTROL_BAR else 0
side_panel_height = self.screen_height - side_panel_top
# Left inspector panel - edge-to-edge with control bar, no gap
if SHOW_INSPECTOR_PANEL: if SHOW_INSPECTOR_PANEL:
# Create a container panel for the inspector
self.inspector_panel = UIPanel( self.inspector_panel = UIPanel(
relative_rect=pygame.Rect( relative_rect=pygame.Rect(
0, self.control_bar_height if SHOW_CONTROL_BAR else 0, 0, side_panel_top, # Start right at control bar edge
self.inspector_width, self.inspector_width,
self.screen_height - (self.control_bar_height if SHOW_CONTROL_BAR else 0) side_panel_height # Extend to bottom edge
), ),
manager=self.manager, manager=self.manager,
object_id="#inspector_panel", object_id="#inspector_panel",
) )
self.panels.append(self.inspector_panel) self.panels.append(self.inspector_panel)
# Tree widget will be created when world is available
self.tree_widget = None self.tree_widget = None
else: else:
self.inspector_panel = None self.inspector_panel = None
self.tree_widget = None self.tree_widget = None
# Right properties # Right properties panel - edge-to-edge with control bar, no gap
if SHOW_PROPERTIES_PANEL: if SHOW_PROPERTIES_PANEL:
self.properties_panel = UIPanel( self.properties_panel = UIPanel(
relative_rect=pygame.Rect( relative_rect=pygame.Rect(
self.screen_width - self.properties_width, self.screen_width - self.properties_width, # Precisely at right edge
self.control_bar_height if SHOW_CONTROL_BAR else 0, side_panel_top, # Align with control bar
self.properties_width, self.properties_width,
self.screen_height - (self.control_bar_height if SHOW_CONTROL_BAR else 0) side_panel_height # Extend to bottom edge
), ),
manager=self.manager, manager=self.manager,
object_id="#properties_panel", object_id="#properties_panel",
@ -103,13 +124,16 @@ class HUD:
else: else:
self.properties_panel = None self.properties_panel = None
# Bottom console # Bottom console panel - edge-to-edge with side panels, no gap
if SHOW_CONSOLE_PANEL: if SHOW_CONSOLE_PANEL:
console_left = self.inspector_width if SHOW_INSPECTOR_PANEL else 0
console_width = self.screen_width - console_left - (self.properties_width if SHOW_PROPERTIES_PANEL else 0)
self.console_panel = UIPanel( self.console_panel = UIPanel(
relative_rect=pygame.Rect( relative_rect=pygame.Rect(
self.inspector_width if SHOW_INSPECTOR_PANEL else 0, console_left, # Start right at inspector edge
self.screen_height - self.console_height, self.screen_height - self.console_height, # Exactly at bottom edge
self.screen_width - (self.inspector_width if SHOW_INSPECTOR_PANEL else 0) - (self.properties_width if SHOW_PROPERTIES_PANEL else 0), console_width, # Fill space between side panels
self.console_height self.console_height
), ),
manager=self.manager, manager=self.manager,
@ -139,17 +163,55 @@ class HUD:
self.tree_widget.select_entities(selected_objects) self.tree_widget.select_entities(selected_objects)
self._last_tree_selection = current_selection self._last_tree_selection = current_selection
def render_panel_backgrounds(self, screen: pygame.Surface):
"""Render panel backgrounds with consistent colors before UI elements."""
# Render control bar background to match inspector panel
if SHOW_CONTROL_BAR and self.control_bar:
control_bg_rect = pygame.Rect(
0, 0,
self.screen_width,
self.control_bar_height
)
pygame.draw.rect(screen, PANEL_BACKGROUND_COLOR, control_bg_rect)
# Render console panel background to match inspector panel
if SHOW_CONSOLE_PANEL and self.console_panel:
console_bg_rect = pygame.Rect(
self.console_panel.rect.x,
self.console_panel.rect.y,
self.console_panel.rect.width,
self.console_panel.rect.height
)
pygame.draw.rect(screen, PANEL_BACKGROUND_COLOR, console_bg_rect)
# Render properties panel background to match inspector panel
if SHOW_PROPERTIES_PANEL and self.properties_panel:
properties_bg_rect = pygame.Rect(
self.properties_panel.rect.x,
self.properties_panel.rect.y,
self.properties_panel.rect.width,
self.properties_panel.rect.height
)
pygame.draw.rect(screen, PANEL_BACKGROUND_COLOR, properties_bg_rect)
def render_panel_dividers(self, screen: pygame.Surface):
"""Render consistent panel sliders with proper rendering order."""
# Render consistent panel sliders
self.draw_splitters(screen)
def _create_simulation_controls(self): def _create_simulation_controls(self):
"""Create simulation control buttons in the control bar.""" """Create simulation control buttons in the control bar."""
if not self.control_bar: if not self.control_bar:
return return
# Button layout constants # Button layout constants (using standardized spacing)
button_width = 40 button_width = 40
button_height = 32 button_height = 32
button_spacing = 8 button_spacing = PANEL_TIGHT_SPACING # Using standardized tight spacing
start_x = 20 start_x = PANEL_INTERNAL_PADDING # Using standardized internal padding
start_y = 8 start_y = PANEL_TIGHT_SPACING # Using standardized tight spacing
# Play/Pause button # Play/Pause button
self.play_pause_button = UIButton( self.play_pause_button = UIButton(
@ -368,11 +430,16 @@ class HUD:
if (0 <= tree_local_pos[0] < self.tree_widget.rect.width and if (0 <= tree_local_pos[0] < self.tree_widget.rect.width and
0 <= tree_local_pos[1] < self.tree_widget.rect.height): 0 <= tree_local_pos[1] < self.tree_widget.rect.height):
event.pos = tree_local_pos event.pos = tree_local_pos
# This is the handle_event call that is being run even when the mouse is not over the tree widget
if self.tree_widget.handle_event(event): if self.tree_widget.handle_event(event):
selected_entities = self.tree_widget.get_selected_entities() selected_entities = self.tree_widget.get_selected_entities()
return 'tree_selection_changed', selected_entities return 'tree_selection_changed', selected_entities
else: else:
# For non-mouse events, try handling them normally # Mouse left the tree widget area, clear hover
self.tree_widget.clear_hover()
else:
# Handle specific mouse events in tree widget (but not wheel - handled by InputHandler)
if event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP):
if self.tree_widget.handle_event(event): if self.tree_widget.handle_event(event):
selected_entities = self.tree_widget.get_selected_entities() selected_entities = self.tree_widget.get_selected_entities()
return 'tree_selection_changed', selected_entities return 'tree_selection_changed', selected_entities
@ -439,8 +506,8 @@ class HUD:
self.update_layout(self.screen_width, self.screen_height) self.update_layout(self.screen_width, self.screen_height)
def draw_splitters(self, screen): def draw_splitters(self, screen):
# Draw draggable splitters for visual feedback # Draw draggable splitters for visual feedback with consistent styling
indicator_color = (220, 220, 220) indicator_color = PANEL_ICON_COLOR # Use standardized icon color for consistency
indicator_size = 6 # Length of indicator line indicator_size = 6 # Length of indicator line
indicator_gap = 4 # Gap between indicator lines indicator_gap = 4 # Gap between indicator lines
indicator_count = 3 # Number of indicator lines indicator_count = 3 # Number of indicator lines
@ -980,3 +1047,6 @@ class HUD:
# Create a surface for the tree widget area # Create a surface for the tree widget area
tree_surface = screen.subsurface(self.inspector_panel.rect) tree_surface = screen.subsurface(self.inspector_panel.rect)
self.tree_widget.draw(tree_surface) self.tree_widget.draw(tree_surface)
# Render panel dividers for visual consistency
self.render_panel_dividers(screen)

View File

@ -6,6 +6,7 @@ Provides extensible tree structure for entity inspection.
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
import pygame import pygame
from config.constants import PANEL_NODE_HEIGHT, PANEL_INDENTATION
class TreeNode(ABC): class TreeNode(ABC):
@ -18,7 +19,7 @@ class TreeNode(ABC):
self.is_expanded = False self.is_expanded = False
self.is_selected = False self.is_selected = False
self.depth = 0 if parent is None else parent.depth + 1 self.depth = 0 if parent is None else parent.depth + 1
self.rect = pygame.Rect(0, 0, 0, 20) # Will be updated during layout self.rect = pygame.Rect(0, 0, 0, PANEL_NODE_HEIGHT) # Will be updated during layout
def add_child(self, child: 'TreeNode') -> None: def add_child(self, child: 'TreeNode') -> None:
"""Add a child node to this node.""" """Add a child node to this node."""
@ -77,7 +78,7 @@ class TreeNode(ABC):
def get_indent(self) -> int: def get_indent(self) -> int:
"""Get the indentation width for this node.""" """Get the indentation width for this node."""
return self.depth * 20 return self.depth * PANEL_INDENTATION
class SimulationNode(TreeNode): class SimulationNode(TreeNode):
@ -123,6 +124,8 @@ class SimulationNode(TreeNode):
# Create new type node # Create new type node
type_node = EntityTypeNode(type_name, entities) type_node = EntityTypeNode(type_name, entities)
self.add_child(type_node) self.add_child(type_node)
# Now update children after this node has correct depth
type_node._update_children()
class EntityTypeNode(TreeNode): class EntityTypeNode(TreeNode):
@ -131,7 +134,7 @@ class EntityTypeNode(TreeNode):
def __init__(self, entity_type: str, entities: List[Any]): def __init__(self, entity_type: str, entities: List[Any]):
super().__init__(entity_type) super().__init__(entity_type)
self.entities = entities self.entities = entities
self._update_children() # Don't call _update_children() here - parent will call after adding this node
def _update_children(self) -> None: def _update_children(self) -> None:
"""Update child entity nodes to match current entities.""" """Update child entity nodes to match current entities."""

View File

@ -8,6 +8,10 @@ import pygame_gui
from pygame_gui.core import UIElement from pygame_gui.core import UIElement
from typing import List, Optional, Tuple, Any from typing import List, Optional, Tuple, Any
from ui.inspector_tree import TreeNode, SimulationNode, TreeSelectionManager from ui.inspector_tree import TreeNode, SimulationNode, TreeSelectionManager
from config.constants import (
PANEL_BACKGROUND_COLOR, PANEL_SELECTED_COLOR, PANEL_HOVER_COLOR,
PANEL_TEXT_COLOR, PANEL_ICON_COLOR, PANEL_NODE_HEIGHT, PANEL_INDENTATION
)
class TreeWidget(UIElement): class TreeWidget(UIElement):
@ -29,19 +33,20 @@ class TreeWidget(UIElement):
# Selection management # Selection management
self.selection_manager = TreeSelectionManager() self.selection_manager = TreeSelectionManager()
# Visual properties # Visual properties (using standardized panel styling)
self.node_height = 20 self.node_height = PANEL_NODE_HEIGHT
self.expand_collapse_width = 20 self.expand_collapse_width = PANEL_INDENTATION
self.icon_size = 8 self.icon_size = 8
self.text_color = (200, 200, 200) self.text_color = PANEL_TEXT_COLOR
self.selected_color = (50, 100, 150) self.selected_color = PANEL_SELECTED_COLOR
self.hover_color = (60, 60, 80) self.hover_color = PANEL_HOVER_COLOR
self.expand_icon_color = (150, 150, 150) self.expand_icon_color = PANEL_ICON_COLOR
# Interaction state # Interaction state
self.hovered_node: Optional[TreeNode] = None self.hovered_node: Optional[TreeNode] = None
self.drag_start_node: Optional[TreeNode] = None self.drag_start_node: Optional[TreeNode] = None
self.is_dragging = False self.is_dragging = False
self.last_mouse_pos: Optional[Tuple[int, int]] = None
# Scrolling # Scrolling
self.scroll_offset = 0 self.scroll_offset = 0
@ -133,6 +138,7 @@ class TreeWidget(UIElement):
elif node == self.hovered_node: elif node == self.hovered_node:
pygame.draw.rect(surface, self.hover_color, node.rect) pygame.draw.rect(surface, self.hover_color, node.rect)
# Expand/collapse icon # Expand/collapse icon
if node.can_expand(): if node.can_expand():
icon_rect = self._get_expand_collapse_rect(node) icon_rect = self._get_expand_collapse_rect(node)
@ -182,6 +188,7 @@ class TreeWidget(UIElement):
if event.type == pygame.MOUSEBUTTONDOWN: if event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 1: # Left click if event.button == 1: # Left click
# Event position is already converted to local coordinates by HUD # Event position is already converted to local coordinates by HUD
self.last_mouse_pos = event.pos
node = self._get_node_at_position_local(event.pos) node = self._get_node_at_position_local(event.pos)
if node: if node:
# Check for expand/collapse click # Check for expand/collapse click
@ -196,6 +203,7 @@ class TreeWidget(UIElement):
elif event.type == pygame.MOUSEMOTION: elif event.type == pygame.MOUSEMOTION:
# Event position is already converted to local coordinates by HUD # Event position is already converted to local coordinates by HUD
self.last_mouse_pos = event.pos
self.hovered_node = self._get_node_at_position_local(event.pos) self.hovered_node = self._get_node_at_position_local(event.pos)
return True return True
@ -217,6 +225,9 @@ class TreeWidget(UIElement):
if self._should_update_tree(): if self._should_update_tree():
self._update_tree_structure() self._update_tree_structure()
self._update_visible_nodes() self._update_visible_nodes()
# Recalculate hover state based on last mouse position after structure changes
if self.last_mouse_pos:
self.hovered_node = self._get_node_at_position_local(self.last_mouse_pos)
def _should_update_tree(self) -> bool: def _should_update_tree(self) -> bool:
"""Determine if the tree structure needs updating.""" """Determine if the tree structure needs updating."""
@ -246,7 +257,7 @@ class TreeWidget(UIElement):
"""Draw the tree widget.""" """Draw the tree widget."""
# Create a clipping surface for the tree area # Create a clipping surface for the tree area
tree_surface = pygame.Surface((self.rect.width, self.rect.height)) tree_surface = pygame.Surface((self.rect.width, self.rect.height))
tree_surface.fill((30, 30, 40)) # Background color tree_surface.fill(PANEL_BACKGROUND_COLOR) # Using standardized background color
# Set up clipping rect to prevent rendering outside bounds # Set up clipping rect to prevent rendering outside bounds
clip_rect = pygame.Rect(0, 0, self.rect.width, self.rect.height) clip_rect = pygame.Rect(0, 0, self.rect.width, self.rect.height)
@ -317,3 +328,8 @@ class TreeWidget(UIElement):
) )
self._update_node_layout() self._update_node_layout()
def clear_hover(self) -> None:
"""Clear the hover state (called when mouse leaves tree widget)."""
self.hovered_node = None
self.last_mouse_pos = None

14
uv.lock generated
View File

@ -146,6 +146,7 @@ dependencies = [
{ name = "pygame" }, { name = "pygame" },
{ name = "pygame-gui" }, { name = "pygame-gui" },
{ name = "pytest" }, { name = "pytest" },
{ name = "tqdm" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@ -165,6 +166,7 @@ requires-dist = [
{ name = "pygame", specifier = ">=2.6.1" }, { name = "pygame", specifier = ">=2.6.1" },
{ name = "pygame-gui", specifier = ">=0.6.14" }, { name = "pygame-gui", specifier = ">=0.6.14" },
{ name = "pytest", specifier = ">=8.3.5" }, { name = "pytest", specifier = ">=8.3.5" },
{ name = "tqdm", specifier = ">=4.67.1" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@ -987,6 +989,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/55/a7/535c44c7bea4578e48281d83c615219f3ab19e6abc67625ef637c73987be/tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7", size = 443596, upload-time = "2025-05-22T18:15:37.433Z" }, { url = "https://files.pythonhosted.org/packages/55/a7/535c44c7bea4578e48281d83c615219f3ab19e6abc67625ef637c73987be/tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7", size = 443596, upload-time = "2025-05-22T18:15:37.433Z" },
] ]
[[package]]
name = "tqdm"
version = "4.67.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.14.0" version = "4.14.0"