597 lines
21 KiB
Python
597 lines
21 KiB
Python
"""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) |