"""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
# ============================================================================