Source code for simvx.core.ui.containers

"""Layout containers — HBox, VBox, Margin, Grid, FormLayout."""


from __future__ import annotations

import logging
from enum import IntEnum

from ..descriptors import Property
from ..math.types import Vec2
from .core import Control, SizeFlags

log = logging.getLogger(__name__)

__all__ = [
    "Container",
    "SizingMode",
    "ColumnSizing",
    "HBoxContainer",
    "VBoxContainer",
    "MarginContainer",
    "GridContainer",
    "FormLayout",
]


# ============================================================================
# Sizing enums
# ============================================================================


[docs] class SizingMode(IntEnum): """Controls how a container sizes its children along the layout axis.""" FIXED = 0 # children keep their current size (default — backward compatible) CONTENT = 1 # children sized to get_minimum_size() FILL = 2 # children expand to share available space equally EXPAND = 3 # min size guaranteed, remainder distributed by stretch_ratio
[docs] class ColumnSizing(IntEnum): """Controls how a GridContainer column is sized.""" FIXED = 0 # column width = max(child.size.x) in column (default — backward compatible) AUTO = 1 # column width = max(child.get_minimum_size().x) FILL = 2 # column gets share of remaining space
# ============================================================================ # Container — Base for layout containers # ============================================================================
[docs] class Container(Control): """Base class for layout containers. Arranges children according to layout rules. Subclasses override _update_layout() to define how children are positioned. """ separation = Property(5.0, range=(0, 100), hint="Space between children") def __init__(self, **kwargs): super().__init__(**kwargs) self.separation = self.get_theme().get_size("separation", 5.0) self._layout_dirty = True self._last_size = None
[docs] def ready(self): self._layout_dirty = True self._update_layout()
[docs] def add_child(self, node): result = super().add_child(node) self._layout_dirty = True if self._tree: self._update_layout() return result
[docs] def remove_child(self, node): result = super().remove_child(node) self._layout_dirty = True if self._tree: self._update_layout() return result
[docs] def mark_layout_dirty(self): """Mark this container for layout recalculation.""" self._layout_dirty = True
def _update_layout(self): """Override to arrange children.""" pass
[docs] def process(self, dt: float): # Recompute layout only if dirty or size changed current_size = (self.size.x, self.size.y) if self._layout_dirty or current_size != self._last_size: self._last_size = current_size self._layout_dirty = False self._update_layout()
def _control_children(self) -> list[Control]: """Return only Control children (skip non-UI nodes).""" return [c for c in self.children if isinstance(c, Control)] @staticmethod def _place(child: Control, x: float, y: float, w: float, h: float): """Set child position and size, skipping no-ops to avoid invalidation overhead.""" if child._position[0] != x or child._position[1] != y: child.position = Vec2(x, y) if child.size_x != w or child.size_y != h: child.size = Vec2(w, h)
# ============================================================================ # HBoxContainer — Horizontal layout # ============================================================================
[docs] class HBoxContainer(Container): """Arranges children left-to-right. ``sizing`` controls how children are sized along the X axis: - FIXED (default): children keep their current width - CONTENT: each child's width = get_minimum_size().x - FILL: available width shared equally among children - EXPAND: each child gets at least min width, remainder by stretch_ratio Example: hbox = HBoxContainer() hbox.add_child(Button("One")) hbox.add_child(Button("Two")) """ alignment = Property("begin", enum=["begin", "center", "end"], hint="Vertical alignment") def __init__(self, **kwargs): super().__init__(**kwargs) self.alignment = "begin" self.sizing: SizingMode = SizingMode.FIXED
[docs] def get_minimum_size(self) -> Vec2: kids = self._control_children() if not kids: return Vec2(max(0, self.min_size.x), max(0, self.min_size.y)) total_w = sum(max(c.size.x, c.get_minimum_size().x) for c in kids) + self.separation * max(0, len(kids) - 1) max_h = max(max(c.size.y, c.get_minimum_size().y) for c in kids) return Vec2(max(self.min_size.x, total_w), max(self.min_size.y, max_h))
def _update_layout(self): kids = self._control_children() if not kids: return _, _, container_width, container_height = self.get_rect() sep = self.separation n = len(kids) place = Container._place # Pre-compute minimum sizes once (avoids repeated virtual calls) min_sizes = [c.get_minimum_size() for c in kids] # Compute child widths based on sizing mode if self.sizing == SizingMode.CONTENT: widths = [ms.x for ms in min_sizes] elif self.sizing == SizingMode.FILL: avail = container_width - sep * max(0, n - 1) widths = [] for i in range(n): start = round(avail * i / n) end = round(avail * (i + 1) / n) widths.append(end - start) elif self.sizing == SizingMode.EXPAND: mins_x = [ms.x for ms in min_sizes] total_min = sum(mins_x) avail = container_width - sep * max(0, n - 1) remainder = max(0, avail - total_min) total_stretch = sum(c.stretch_ratio for c in kids) or 1.0 float_widths = [m + remainder * (c.stretch_ratio / total_stretch) for m, c in zip(mins_x, kids)] widths = [] cumulative = 0.0 for fw in float_widths: old_snap = round(cumulative) cumulative += fw widths.append(round(cumulative) - old_snap) else: # FIXED widths = [max(c.size.x, ms.x) for c, ms in zip(kids, min_sizes)] x_offset = 0.0 for i, child in enumerate(kids): w = widths[i] # Determine child height based on vertical size flags flags_v = child.size_flags_vertical if flags_v & SizeFlags.FILL: child_h = container_height else: child_h = max(child.size.y, min_sizes[i].y) # Vertical alignment if self.alignment == "center" or flags_v & SizeFlags.SHRINK_CENTER: y_offset = round((container_height - child_h) / 2) elif self.alignment == "end" or flags_v & SizeFlags.SHRINK_END: y_offset = round(container_height - child_h) else: y_offset = 0.0 rw, rh = round(w), round(child_h) place(child, round(x_offset), round(y_offset), rw, rh) x_offset += rw + sep
# ============================================================================ # VBoxContainer — Vertical layout # ============================================================================
[docs] class VBoxContainer(Container): """Arranges children top-to-bottom. ``sizing`` controls how children are sized along the Y axis: - FIXED (default): children keep their current height - CONTENT: each child's height = get_minimum_size().y - FILL: available height shared equally among children - EXPAND: each child gets at least min height, remainder by stretch_ratio Example: vbox = VBoxContainer() vbox.add_child(Label("Title")) vbox.add_child(Button("Start")) """ alignment = Property("begin", enum=["begin", "center", "end"], hint="Horizontal alignment") def __init__(self, **kwargs): super().__init__(**kwargs) self.alignment = "begin" self.sizing: SizingMode = SizingMode.FIXED
[docs] def get_minimum_size(self) -> Vec2: kids = self._control_children() if not kids: return Vec2(max(0, self.min_size.x), max(0, self.min_size.y)) max_w = max(max(c.size.x, c.get_minimum_size().x) for c in kids) total_h = sum(max(c.size.y, c.get_minimum_size().y) for c in kids) + self.separation * max(0, len(kids) - 1) return Vec2(max(self.min_size.x, max_w), max(self.min_size.y, total_h))
def _update_layout(self): kids = self._control_children() if not kids: return _, _, container_width, container_height = self.get_rect() sep = self.separation n = len(kids) place = Container._place # Pre-compute minimum sizes once min_sizes = [c.get_minimum_size() for c in kids] # Compute child heights based on sizing mode if self.sizing == SizingMode.CONTENT: heights = [ms.y for ms in min_sizes] elif self.sizing == SizingMode.FILL: avail = container_height - sep * max(0, n - 1) heights = [] for i in range(n): start = round(avail * i / n) end = round(avail * (i + 1) / n) heights.append(end - start) elif self.sizing == SizingMode.EXPAND: mins_y = [ms.y for ms in min_sizes] total_min = sum(mins_y) avail = container_height - sep * max(0, n - 1) remainder = max(0, avail - total_min) total_stretch = sum(c.stretch_ratio for c in kids) or 1.0 float_heights = [m + remainder * (c.stretch_ratio / total_stretch) for m, c in zip(mins_y, kids)] heights = [] cumulative = 0.0 for fh in float_heights: old_snap = round(cumulative) cumulative += fh heights.append(round(cumulative) - old_snap) else: # FIXED heights = [max(c.size.y, ms.y) for c, ms in zip(kids, min_sizes)] y_offset = 0.0 for i, child in enumerate(kids): h = heights[i] # Determine child width based on horizontal size flags flags_h = child.size_flags_horizontal if flags_h & SizeFlags.FILL: child_w = container_width else: child_w = max(child.size.x, min_sizes[i].x) # Horizontal alignment if self.alignment == "center" or flags_h & SizeFlags.SHRINK_CENTER: x_offset = round((container_width - child_w) / 2) elif self.alignment == "end" or flags_h & SizeFlags.SHRINK_END: x_offset = round(container_width - child_w) else: x_offset = 0.0 rw, rh = round(child_w), round(h) place(child, round(x_offset), round(y_offset), rw, rh) y_offset += rh + sep
# ============================================================================ # MarginContainer — Apply margin around child # ============================================================================
[docs] class MarginContainer(Container): """Adds margin around a single child. Example: margin = MarginContainer(margin=20) margin.add_child(Panel()) """ margin_value = Property(10.0, range=(0, 200), hint="Margin size") def __init__(self, margin: float = 10.0, **kwargs): super().__init__(**kwargs) self.margin_value = margin
[docs] def get_minimum_size(self) -> Vec2: m2 = self.margin_value * 2 w, h = m2, m2 for child in self.children: if isinstance(child, Control): ms = child.get_minimum_size() w = max(w, ms.x + m2) h = max(h, ms.y + m2) break return Vec2(max(self.min_size.x, w), max(self.min_size.y, h))
def _update_layout(self): if not self.children: return for child in self.children: if isinstance(child, Control): _, _, w, h = self.get_rect() child.position = Vec2(self.margin_value, self.margin_value) child.size = Vec2( max(0, w - 2 * self.margin_value), max(0, h - 2 * self.margin_value), ) break
# ============================================================================ # GridContainer — Grid layout # ============================================================================
[docs] class GridContainer(Container): """Arranges children in a grid. ``column_sizing`` controls per-column sizing. When empty (default), all columns use ColumnSizing.FIXED (backward compatible). ``column_stretch`` provides weights for FILL columns (default 1.0 each). Example: grid = GridContainer(columns=3) for i in range(9): grid.add_child(Button(f"Btn {i}")) """ columns = Property(2, range=(1, 20), hint="Number of columns") def __init__(self, columns: int = 2, **kwargs): super().__init__(**kwargs) self.columns = columns self.column_sizing: list[ColumnSizing] = [] self.column_stretch: list[float] = [] def _get_column_mode(self, col: int) -> ColumnSizing: """Get sizing mode for a column (defaults to FIXED).""" if col < len(self.column_sizing): return self.column_sizing[col] return ColumnSizing.FIXED def _get_column_stretch(self, col: int) -> float: """Get stretch weight for a column (defaults to 1.0).""" if col < len(self.column_stretch): return self.column_stretch[col] return 1.0
[docs] def get_minimum_size(self) -> Vec2: kids = self._control_children() if not kids: return Vec2(max(0, self.min_size.x), max(0, self.min_size.y)) ncols = max(1, int(self.columns)) # Build rows rows: list[list[Control]] = [] row: list[Control] = [] for child in kids: row.append(child) if len(row) >= ncols: rows.append(row) row = [] if row: rows.append(row) # Column widths = max effective size per column col_widths = [0.0] * ncols for r in rows: for c, child in enumerate(r): col_widths[c] = max(col_widths[c], child.size.x, child.get_minimum_size().x) # Row heights = max effective size per row row_heights = [0.0] * len(rows) for ri, r in enumerate(rows): for child in r: row_heights[ri] = max(row_heights[ri], child.size.y, child.get_minimum_size().y) w = sum(col_widths) + self.separation * max(0, ncols - 1) h = sum(row_heights) + self.separation * max(0, len(rows) - 1) return Vec2(max(self.min_size.x, w), max(self.min_size.y, h))
def _update_layout(self): kids = self._control_children() if not kids: return ncols = max(1, int(self.columns)) _, _, container_width, _ = self.get_rect() sep = self.separation # Build rows of children rows: list[list[Control]] = [] row: list[Control] = [] for child in kids: row.append(child) if len(row) >= ncols: rows.append(row) row = [] if row: rows.append(row) place = Container._place # If no column_sizing is set, use old behavior (all FIXED = equal split) if not self.column_sizing: avail = container_width - sep * (ncols - 1) col_starts = [round(avail * c / ncols) for c in range(ncols + 1)] y_offset = 0.0 for r in rows: row_height = 0.0 for ci, child in enumerate(r): cw = col_starts[ci + 1] - col_starts[ci] x_offset = col_starts[ci] + ci * sep ry = round(y_offset) place(child, round(x_offset), ry, cw, child.size_y) row_height = max(row_height, child.size.y) y_offset += round(row_height) + sep return # Compute column widths based on per-column modes col_widths = [0.0] * ncols fill_cols = [] for c in range(ncols): mode = self._get_column_mode(c) if mode == ColumnSizing.AUTO: # Max get_minimum_size().x in this column for r in rows: if c < len(r): col_widths[c] = max(col_widths[c], r[c].get_minimum_size().x) elif mode == ColumnSizing.FIXED: # Max child.size.x in this column for r in rows: if c < len(r): col_widths[c] = max(col_widths[c], r[c].size.x) elif mode == ColumnSizing.FILL: fill_cols.append(c) # Still compute minimum as a floor for r in rows: if c < len(r): col_widths[c] = max(col_widths[c], r[c].get_minimum_size().x) # Distribute remaining space among FILL columns if fill_cols: used = sum(col_widths[c] for c in range(ncols) if c not in fill_cols) used += sep * max(0, ncols - 1) fill_min = sum(col_widths[c] for c in fill_cols) remaining = max(0, container_width - used) total_stretch = sum(self._get_column_stretch(c) for c in fill_cols) or 1.0 if remaining > fill_min: for c in fill_cols: col_widths[c] = remaining * (self._get_column_stretch(c) / total_stretch) # Compute row heights — always respect minimum as a floor row_heights = [0.0] * len(rows) for ri, r in enumerate(rows): for ci, child in enumerate(r): h = max(child.size.y, child.get_minimum_size().y) row_heights[ri] = max(row_heights[ri], h) # Position and size children y_offset = 0.0 for ri, r in enumerate(rows): x_offset = 0.0 rh = round(row_heights[ri]) for ci, child in enumerate(r): rcw = round(col_widths[ci]) place(child, round(x_offset), round(y_offset), rcw, rh) x_offset += rcw + sep y_offset += rh + sep
# ============================================================================ # FormLayout — Two-column label + widget layout # ============================================================================
[docs] class FormLayout(GridContainer): """Two-column form layout with label-widget pairs. Column 0 (labels): AUTO — auto-sizes to the widest label's content. Column 1 (widgets): FILL — gets all remaining space. Example: form = FormLayout() form.add_field("Name", TextEdit()) form.add_field("Speed", SpinBox(min_val=0, max_val=100)) """ def __init__(self, **kwargs): kwargs.setdefault("columns", 2) super().__init__(**kwargs) self.column_sizing = [ColumnSizing.AUTO, ColumnSizing.FILL]
[docs] def add_field(self, label_text: str, widget: Control) -> Control: """Add a label-widget pair to the form. Returns the widget for further configuration. """ from .widgets import Label label = Label(label_text) self.add_child(label) self.add_child(widget) return widget