[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/andevsr/pythonnative/main/src/pythonnative/layout.py [Back]  [Original]

"""Pure-Python flexbox layout engine.

Computes positions and sizes for every node in a layout tree based on
CSS flexbox-inspired style properties. Inspired by Facebook's Yoga and
React Native's layout system, but implemented entirely in Python so
PythonNative does not depend on a native layout library.

The engine is invoked by the reconciler after each commit pass:

1. The reconciler maintains a parallel
   [`LayoutNode`][pythonnative.layout.LayoutNode] tree (cached across
   passes: clean subtrees keep their nodes, dirty ones are rebuilt).
2. [`calculate_layout`][pythonnative.layout.calculate_layout] is called
   with the viewport size; it recursively determines each node's
   ``(x, y, width, height)`` relative to its parent's coordinate space.
   Nodes that are **not dirty** and are measured under the same
   constraints as the previous pass return their memoized size without
   recursing, which turns full-tree layout into dirty-subtree layout.
3. The reconciler walks the tree again and emits a frame op for each
   native view whose frame actually changed.

The algorithm supports:

- **Flex containers**: ``flex_direction`` (``row``/``column`` and their
  reverse variants), ``justify_content`` (``flex_start`` / ``center`` /
  ``flex_end`` / ``space_between`` / ``space_around`` / ``space_evenly``),
  ``align_items`` (``stretch`` / ``flex_start`` / ``center`` /
  ``flex_end``), and ``align_self`` overrides per child.
- **Wrapping**: ``flex_wrap`` (``nowrap`` / ``wrap`` / ``wrap_reverse``)
  with ``align_content`` controlling how lines share leftover
  cross-axis space (``stretch`` default, plus the justify palette).
- **Direction**: ``direction: "rtl"`` flips row layouts, ``start`` /
  ``end`` edge keys (``margin_start``, ``padding_end``, absolute
  ``start`` / ``end`` insets) resolve against the inherited direction.
- **Sizing**: explicit ``width`` / ``height`` (numbers or percentages),
  ``min_width`` / ``max_width`` / ``min_height`` / ``max_height``
  constraints, ``aspect_ratio``, and content-based sizing via the
  optional ``measure`` callback.
- **Flex distribution**: ``flex`` (RN shorthand for grow factor with
  ``flex_basis: 0``), ``flex_grow``, ``flex_shrink``, ``flex_basis``.
- **Absolute positioning**: ``position: "absolute"`` with ``top``,
  ``right``, ``bottom``, ``left`` (and ``start`` / ``end``) insets.
  Absolute children are positioned relative to the parent's padding box
  and do not participate in flex distribution.
- **Spacing**: ``padding`` / ``margin`` (scalar, dict, or per-edge
  keys), inter-child ``spacing`` (aliases: ``gap``, ``column_gap`` /
  ``row_gap`` per axis).

Example:
    ```python
    from pythonnative.layout import LayoutNode, calculate_layout

    root = LayoutNode(
        style={"flex_direction": "row", "padding": 8, "spacing": 4},
        children=[
            LayoutNode(style={"width": 80, "height": 40}),
            LayoutNode(style={"flex": 1, "height": 40}),
            LayoutNode(style={"width": 60, "height": 40}),
        ],
    )
    calculate_layout(root, 320, 200)
    # root.children[0].x == 8, .width == 80
    # root.children[1].x == 92, .width == 156  (filled by flex: 1)
    # root.children[2].x == 252, .width == 60
    ```
"""

import math
from typing import Any, Callable, Dict, List, Optional, Tuple

# ======================================================================
# Public constants
# ======================================================================

LAYOUT_STYLE_KEYS = frozenset(
    {
        "width",
        "height",
        "min_width",
        "max_width",
        "min_height",
        "max_height",
        "flex",
        "flex_grow",
        "flex_shrink",
        "flex_basis",
        "flex_wrap",
        "align_self",
        "align_content",
        "position",
        "top",
        "right",
        "bottom",
        "left",
        "start",
        "end",
        "margin",
        "margin_top",
        "margin_bottom",
        "margin_left",
        "margin_right",
        "margin_start",
        "margin_end",
        "margin_horizontal",
        "margin_vertical",
        "padding",
        "padding_top",
        "padding_bottom",
        "padding_left",
        "padding_right",
        "padding_start",
        "padding_end",
        "padding_horizontal",
        "padding_vertical",
        "flex_direction",
        "justify_content",
        "align_items",
        "spacing",
        "gap",
        "row_gap",
        "column_gap",
        "aspect_ratio",
        "direction",
    }
)
"""Style keys that affect layout (and are consumed by the layout engine)."""

# Flex direction values
FLEX_DIRECTION_COLUMN = "column"
FLEX_DIRECTION_COLUMN_REVERSE = "column_reverse"
FLEX_DIRECTION_ROW = "row"
FLEX_DIRECTION_ROW_REVERSE = "row_reverse"

# flex_wrap values
WRAP_NOWRAP = "nowrap"
WRAP_WRAP = "wrap"
WRAP_REVERSE = "wrap_reverse"

# justify_content / align_content values
JUSTIFY_FLEX_START = "flex_start"
JUSTIFY_CENTER = "center"
JUSTIFY_FLEX_END = "flex_end"
JUSTIFY_SPACE_BETWEEN = "space_between"
JUSTIFY_SPACE_AROUND = "space_around"
JUSTIFY_SPACE_EVENLY = "space_evenly"

# align_items / align_self values
ALIGN_AUTO = "auto"
ALIGN_FLEX_START = "flex_start"
ALIGN_CENTER = "center"
ALIGN_FLEX_END = "flex_end"
ALIGN_STRETCH = "stretch"

# position values
POSITION_RELATIVE = "relative"
POSITION_ABSOLUTE = "absolute"

# direction values
DIRECTION_LTR = "ltr"
DIRECTION_RTL = "rtl"

# Friendly aliases on cross-axis alignment props.
_ALIGN_ALIASES = {
    "start": ALIGN_FLEX_START,
    "leading": ALIGN_FLEX_START,
    "top": ALIGN_FLEX_START,
    "end": ALIGN_FLEX_END,
    "trailing": ALIGN_FLEX_END,
    "bottom": ALIGN_FLEX_END,
    "fill": ALIGN_STRETCH,
}

_JUSTIFY_ALIASES = {
    "start": JUSTIFY_FLEX_START,
    "leading": JUSTIFY_FLEX_START,
    "top": JUSTIFY_FLEX_START,
    "end": JUSTIFY_FLEX_END,
    "trailing": JUSTIFY_FLEX_END,
    "bottom": JUSTIFY_FLEX_END,
}

# A measure callback receives ``(max_width, max_height)`` (either may be
# ``math.inf`` to indicate no constraint) and returns the leaf's natural
# ``(width, height)`` in points.
MeasureFn = Callable[[float, float], Tuple[float, float]]


# ======================================================================
# Helpers
# ======================================================================


def _is_row(direction: str) -> bool:
    """Return whether `direction` lays out children along the horizontal axis."""
    return direction in (FLEX_DIRECTION_ROW, FLEX_DIRECTION_ROW_REVERSE)


def _is_reverse(direction: str) -> bool:
    """Return whether `direction` is one of the reverse variants."""
    return direction in (FLEX_DIRECTION_ROW_REVERSE, FLEX_DIRECTION_COLUMN_REVERSE)


def _to_float(value: Any) -> Optional[float]:
    """Coerce ``value`` to ``float``; return ``None`` if not coercible."""
    if value is None or isinstance(value, bool):
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _resolve_value(value: Any, parent_size: float) -> Optional[float]:
    """Resolve a dimension value to points.

    Accepts:

    - ``None``: returns ``None``.
    - ``int`` / ``float``: returned as-is.
    - ``str`` ending in ``"%"``: percentage of ``parent_size``. If
      ``parent_size`` is not finite (e.g., inside a vertically
      unbounded ScrollView), percentages collapse to ``None`` so the
      caller can fall back to content-based sizing.
    """
    if value is None:
        return None
    if isinstance(value, bool):
        return None
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        s = value.strip()
        if s.endswith("%"):
            try:
                pct = float(s[:-1])
            except ValueError:
                return None
            if not math.isfinite(parent_size):
                return None
            return parent_size * pct / 100.0
        try:
            return float(s)
        except ValueError:
            return None
    return None


def _padding_edges(value: Any, parent_w: float, parent_h: float) -> Tuple[float, float, float, float]:
    """Resolve a padding/margin value to ``(left, top, right, bottom)``."""
    if value is None:
        return (0.0, 0.0, 0.0, 0.0)
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        v = float(value)
        return (v, v, v, v)
    if isinstance(value, dict):
        all_v = _resolve_value(value.get("all"), max(parent_w, parent_h)) or 0.0
        h_v = _resolve_value(value.get("horizontal"), parent_w)
        v_v = _resolve_value(value.get("vertical"), parent_h)
        h = h_v if h_v is not None else all_v
        v = v_v if v_v is not None else all_v
        left = _resolve_value(value.get("left"), parent_w)
        right = _resolve_value(value.get("right"), parent_w)
        top = _resolve_value(value.get("top"), parent_h)
        bottom = _resolve_value(value.get("bottom"), parent_h)
        return (
            left if left is not None else h,
            top if top is not None else v,
            right if right is not None else h,
            bottom if bottom is not None else v,
        )
    if isinstance(value, str):
        v = _resolve_value(value, max(parent_w, parent_h))
        if v is None:
            return (0.0, 0.0, 0.0, 0.0)
        return (v, v, v, v)
    return (0.0, 0.0, 0.0, 0.0)


def _resolve_padding_for(
    style: Dict[str, Any],
    parent_w: float,
    parent_h: float,
    prefix: str,
    direction: str = DIRECTION_LTR,
) -> Tuple[float, float, float, float]:
    """Resolve padding/margin from `style`, honoring per-edge overrides.

    ``{prefix}_start`` / ``{prefix}_end`` resolve to the left/right
    edge according to ``direction`` and take precedence over the
    physical ``left`` / ``right`` keys (matching React Native).
    """
    base_l, base_t, base_r, base_b = _padding_edges(style.get(prefix), parent_w, parent_h)

    h_override = _resolve_value(style.get(f"{prefix}_horizontal"), parent_w)
    v_override = _resolve_value(style.get(f"{prefix}_vertical"), parent_h)
    if h_override is not None:
        base_l = h_override
        base_r = h_override
    if v_override is not None:
        base_t = v_override
        base_b = v_override

    left = _resolve_value(style.get(f"{prefix}_left"), parent_w)
    right = _resolve_value(style.get(f"{prefix}_right"), parent_w)
    top = _resolve_value(style.get(f"{prefix}_top"), parent_h)
    bottom = _resolve_value(style.get(f"{prefix}_bottom"), parent_h)
    if left is not None:
        base_l = left
    if right is not None:
        base_r = right
    if top is not None:
        base_t = top
    if bottom is not None:
        base_b = bottom

    start = _resolve_value(style.get(f"{prefix}_start"), parent_w)
    end = _resolve_value(style.get(f"{prefix}_end"), parent_w)
    if start is not None:
        if direction == DIRECTION_RTL:
            base_r = start
        else:
            base_l = start
    if end is not None:
        if direction == DIRECTION_RTL:
            base_l = end
        else:
            base_r = end
    return (base_l, base_t, base_r, base_b)


def _clamp(
    value: Optional[float],
    minimum: Any,
    maximum: Any,
    parent_size: float,
) -> Optional[float]:
    """Clamp `value` to ``[minimum, maximum]``, resolving percentage limits."""
    if value is None:
        return None
    min_v = _resolve_value(minimum, parent_size)
    max_v = _resolve_value(maximum, parent_size)
    if min_v is not None and value < min_v:
        value = min_v
    if max_v is not None and value > max_v:
        value = max_v
    return value


def _flex_grow(style: Dict[str, Any]) -> float:
    """Return the effective `flex_grow` for a child.

    Supports the React Native ``flex`` shorthand: a positive ``flex``
    value implies ``flex_grow = flex``. ``flex_grow`` overrides the
    shorthand if both are set.
    """
    fg = style.get("flex_grow")
    if fg is not None:
        v = _to_float(fg)
        return max(v, 0.0) if v is not None else 0.0
    f = _to_float(style.get("flex"))
    if f is not None and f > 0:
        return f
    return 0.0


def _flex_shrink(style: Dict[str, Any]) -> float:
    """Return the effective `flex_shrink` for a child.

    Defaults to ``0`` for normal children, but a positive React Native
    ``flex`` shorthand implies ``flex_shrink = 1`` unless explicitly
    overridden.
    """
    fs = style.get("flex_shrink")
    if fs is not None:
        v = _to_float(fs)
        return max(v, 0.0) if v is not None else 0.0
    f = _to_float(style.get("flex"))
    if f is not None and f > 0:
        return 1.0
    return 0.0


def _flex_basis(style: Dict[str, Any], main_avail: float) -> Optional[float]:
    """Return the effective `flex_basis` for a child, or ``None`` for ``"auto"``.

    A bare numeric ``flex`` shorthand implies ``flex_basis = 0``
    (matches RN), unless ``flex_basis`` is explicitly set.
    """
    fb = style.get("flex_basis")
    if fb is not None:
        if isinstance(fb, str) and fb.strip().lower() == "auto":
            return None
        return _resolve_value(fb, main_avail)
    f = _to_float(style.get("flex"))
    if f is not None and f > 0:
        return 0.0
    return None


def _resolve_align(value: Any, default: str = ALIGN_STRETCH) -> str:
    """Normalize an `align_items` / `align_self` value, applying aliases."""
    if value is None:
        return default
    s = str(value)
    return _ALIGN_ALIASES.get(s, s)


def _resolve_justify(value: Any) -> str:
    """Normalize a `justify_content` / `align_content` value, applying aliases."""
    if value is None:
        return JUSTIFY_FLEX_START
    s = str(value)
    return _JUSTIFY_ALIASES.get(s, s)


def _resolve_gaps(style: Dict[str, Any], is_row: bool) -> Tuple[float, float]:
    """Return ``(main_gap, cross_gap)`` for a container.

    ``column_gap`` is the horizontal gap and ``row_gap`` the vertical
    gap (CSS semantics); ``spacing`` / ``gap`` set the main-axis gap
    when the specific key is absent.
    """
    generic = _to_float(style.get("spacing"))
    if generic is None:
        generic = _to_float(style.get("gap"))
    col = _to_float(style.get("column_gap"))
    row = _to_float(style.get("row_gap"))
    if is_row:
        main = col if col is not None else generic
        cross = row
    else:
        main = row if row is not None else generic
        cross = col
    return (main or 0.0, cross or 0.0)


def _wrap_mode(style: Dict[str, Any]) -> str:
    mode = style.get("flex_wrap")
    if mode in (WRAP_WRAP, WRAP_REVERSE):
        return str(mode)
    if mode == "wrap-reverse":
        return WRAP_REVERSE
    return WRAP_NOWRAP


def _resolve_direction(style: Dict[str, Any], inherited: str) -> str:
    own = style.get("direction")
    if own in (DIRECTION_LTR, DIRECTION_RTL):
        return str(own)
    return inherited


# ======================================================================
# LayoutNode
# ======================================================================


class LayoutNode:
    """A node in the layout tree.

    Holds the layout-relevant style props, child layout nodes, and the
    computed output (``x``, ``y``, ``width``, ``height`` in points
    relative to the parent's coordinate space).

    Attributes:
        style: Dict of layout-relevant style props (a subset of the
            element's full props; usually filtered through
            [`LAYOUT_STYLE_KEYS`][pythonnative.layout.LAYOUT_STYLE_KEYS]).
        children: Ordered list of child `LayoutNode`s.
        measure: Optional measure callback for leaf nodes whose natural
            size depends on their content (e.g., text). Receives
            ``(max_width, max_height)`` and returns ``(width, height)``.
            Either argument may be ``math.inf``.
        user_data: Free-form attribute the caller may use to associate
            each layout node with the corresponding native view; the
            engine itself does not inspect it.
        dirty: When ``False``, the node may serve repeat measurements
            from its memo (set by the reconciler's incremental-layout
            cache). Fresh nodes start dirty.
        x: Computed x-coordinate relative to the parent's coordinate
            space.
        y: Computed y-coordinate relative to the parent's coordinate
            space.
        width: Computed width in points.
        height: Computed height in points.
    """

    __slots__ = (
        "style",
        "children",
        "measure",
        "user_data",
        "dirty",
        "x",
        "y",
        "width",
        "height",
        "_pn_scroll_axis",
        "_measure_memo",
        "_lines",
        "_direction",
    )

    def __init__(
        self,
        style: Optional[Dict[str, Any]] = None,
        children: Optional[List["LayoutNode"]] = None,
        measure: Optional[MeasureFn] = None,
        user_data: Any = None,
    ) -> None:
        self.style = style or {}
        self.children = list(children) if children else []
        self.measure = measure
        self.user_data = user_data
        self.dirty: bool = True
        self.x: float = 0.0
        self.y: float = 0.0
        self.width: float = 0.0
        self.height: float = 0.0
        # ``"x"``/``"y"`` for scroll containers; ``None`` for everything
        # else. Consumed by ``_measure_container`` to clamp the node's
        # own main-axis size to the parent's available space while still
        # measuring children unbounded on the scroll axis (which is what
        # makes the native ``UIScrollView`` / Android ``ScrollView``
        # actually scroll). The reconciler stamps this when building the
        # layout tree for ``ScrollView`` elements.
        self._pn_scroll_axis: Optional[str] = None
        # Measurement memo: ``(avail_w, avail_h, forced_w, forced_h,
        # direction, out_w, out_h)`` from the most recent measurement.
        # Served when the node is clean (``dirty == False``) and the
        # inputs match, skipping the entire subtree's flex math.
        self._measure_memo: Optional[Tuple[float, float, Optional[float], Optional[float], str, float, float]] = None
        # Flex lines computed during measurement, consumed by the
        # positioning pass (single-line containers store one line).
        self._lines: Optional[List["_FlexLine"]] = None
        # Resolved direction ("ltr" / "rtl") inherited at measure time.
        self._direction: str = DIRECTION_LTR

    def __repr__(self) -> str:
        return (
            f"LayoutNode(x={self.x:g}, y={self.y:g}, "
            f"w={self.width:g}, h={self.height:g}, "
            f"children={len(self.children)})"
        )


class _FlexLine:
    """One row/column of children produced by line partitioning."""

    __slots__ = ("children", "main_used", "cross_size")

    def __init__(self, children: List[LayoutNode]) -> None:
        self.children = children
        self.main_used: float = 0.0
        self.cross_size: float = 0.0


# ======================================================================
# Public entry point
# ======================================================================


def calculate_layout(
    node: LayoutNode,
    available_width: float,
    available_height: float,
    direction: str = DIRECTION_LTR,
) -> None:
    """Compute layout for `node` and all descendants, in place.

    Sizes the root from its own style and content. Callers that want
    the root to fill its viewport should wrap it in a synthetic outer
    node with explicit ``width`` / ``height`` (the
    [`Reconciler`][pythonnative.reconciler.Reconciler] does this when
    running the layout pass after a commit).

    Args:
        node: Root of the layout tree.
        available_width: Available width in points. Pass
            ``math.inf`` for unbounded (e.g., horizontal scroll).
        available_height: Available height in points. Pass
            ``math.inf`` for unbounded (e.g., vertical scroll).
        direction: Base writing direction (``"ltr"`` or ``"rtl"``)
            inherited by nodes that don't set their own.
    """
    _measure_node(node, available_width, available_height, direction=direction)
    node.x = 0.0
    node.y = 0.0
    _position_children(node)


# ======================================================================
# Measurement: top-down sizing
# ======================================================================


def _measure_node(
    node: LayoutNode,
    avail_w: float,
    avail_h: float,
    forced_w: Optional[float] = None,
    forced_h: Optional[float] = None,
    direction: str = DIRECTION_LTR,
) -> None:
    """Compute ``node.width`` / ``node.height`` and recursively size children.

    Args:
        node: Node to measure.
        avail_w: Available width (constraint, never a forced value).
        avail_h: Available height (constraint, never a forced value).
        forced_w: If set, overrides any width computed from style or
            content. Used by parents to enforce flex distribution or
            cross-axis stretch.
        forced_h: As ``forced_w`` but for height.
        direction: Writing direction inherited from the parent.
    """
    style = node.style
    resolved_direction = _resolve_direction(style, direction)

    # Incremental-layout memo: a clean node measured under identical
    # inputs reuses its previous result without recursing; its whole
    # subtree keeps the sizes from the prior pass.
    memo = node._measure_memo
    if (
        memo is not None
        and not node.dirty
        and memo[0] == avail_w
        and memo[1] == avail_h
        and memo[2] == forced_w
        and memo[3] == forced_h
        and memo[4] == resolved_direction
    ):
        node.width = memo[5]
        node.height = memo[6]
        return

    node._direction = resolved_direction

    explicit_w = forced_w if forced_w is not None else _resolve_value(style.get("width"), avail_w)
    explicit_h = forced_h if forced_h is not None else _resolve_value(style.get("height"), avail_h)

    aspect = _to_float(style.get("aspect_ratio"))
    if aspect is not None and aspect > 0:
        if explicit_w is not None and explicit_h is None:
            explicit_h = explicit_w / aspect
        elif explicit_h is not None and explicit_w is None:
            explicit_w = explicit_h * aspect

    explicit_w = _clamp(explicit_w, style.get("min_width"), style.get("max_width"), avail_w)
    explicit_h = _clamp(explicit_h, style.get("min_height"), style.get("max_height"), avail_h)

    if node.children:
        width, height = _measure_container(node, avail_w, avail_h, explicit_w, explicit_h)
    elif node.measure is not None:
        width, height = _measure_leaf(node, avail_w, avail_h, explicit_w, explicit_h, aspect)
    else:
        width = explicit_w if explicit_w is not None else 0.0
        height = explicit_h if explicit_h is not None else 0.0

    if aspect is not None and aspect > 0:
        if explicit_w is None and explicit_h is not None and width  0:
        if explicit_w is None and explicit_h is None:
            height = width / aspect
    return width, height


def _measure_container(
    node: LayoutNode,
    avail_w: float,
    avail_h: float,
    explicit_w: Optional[float],
    explicit_h: Optional[float],
) -> Tuple[float, float]:
    """Layout flex children and determine the container's own size."""
    style = node.style
    direction = node._direction
    base_w = explicit_w if explicit_w is not None else avail_w
    base_h = explicit_h if explicit_h is not None else avail_h
    pad_l, pad_t, pad_r, pad_b = _resolve_padding_for(style, base_w, base_h, "padding", direction)
    pad_x = pad_l + pad_r
    pad_y = pad_t + pad_b

    content_w = (explicit_w - pad_x) if explicit_w is not None else max(0.0, avail_w - pad_x)
    content_h = (explicit_h - pad_y) if explicit_h is not None else max(0.0, avail_h - pad_y)

    is_row = _is_row(style.get("flex_direction", FLEX_DIRECTION_COLUMN))
    main_bounded = (explicit_w is not None) if is_row else (explicit_h is not None)
    cross_bounded = (explicit_h is not None) if is_row else (explicit_w is not None)

    used_main, used_cross = _layout_flex_children(
        node,
        content_w,
        content_h,
        main_bounded=main_bounded,
        cross_bounded=cross_bounded,
    )

    if is_row:
        used_w, used_h = used_main, used_cross
    else:
        used_w, used_h = used_cross, used_main

    width = explicit_w if explicit_w is not None else (used_w + pad_x)
    height = explicit_h if explicit_h is not None else (used_h + pad_y)

    # Scroll containers: clamp the container's own main-axis size to the
    # parent's available space when no explicit size was provided. The
    # children are still measured against an unbounded main-axis (handled
    # via the wrapper inserted in ``Reconciler._build_layout_tree``) so the
    # overflow becomes the scrollable region. Without this clamp, the
    # container would grow to fit its content and there would be no
    # overflow for the native ScrollView to scroll. Skipped when the
    # parent is itself unbounded, so nested scroll views still fall back
    # to natural sizing (the inner scroll is unscrollable in that case,
    # which matches the behavior in React Native).
    scroll_axis = getattr(node, "_pn_scroll_axis", None)
    if scroll_axis == "y" and explicit_h is None and math.isfinite(avail_h):
        height = avail_h
    elif scroll_axis == "x" and explicit_w is None and math.isfinite(avail_w):
        width = avail_w
    return width, height


# ======================================================================
# Flex algorithm
# ======================================================================


def _child_main_size(child: LayoutNode, is_row: bool) -> float:
    return child.width if is_row else child.height


def _child_cross_size(child: LayoutNode, is_row: bool) -> float:
    return child.height if is_row else child.width


def _child_margins(
    child: LayoutNode,
    parent_w: float,
    parent_h: float,
    direction: str,
) -> Tuple[float, float, float, float]:
    return _resolve_padding_for(child.style, parent_w, parent_h, "margin", direction)


def _child_outer_main(
    child: LayoutNode,
    is_row: bool,
    parent_w: float,
    parent_h: float,
    direction: str,
) -> float:
    """Main-axis extent including margins."""
    margins = _child_margins(child, parent_w, parent_h, direction)
    margin_main = (margins[0] + margins[2]) if is_row else (margins[1] + margins[3])
    return _child_main_size(child, is_row) + margin_main


def _child_outer_cross(
    child: LayoutNode,
    is_row: bool,
    parent_w: float,
    parent_h: float,
    direction: str,
) -> float:
    margins = _child_margins(child, parent_w, parent_h, direction)
    margin_cross = (margins[1] + margins[3]) if is_row else (margins[0] + margins[2])
    return _child_cross_size(child, is_row) + margin_cross


def _measure_child_flexed(
    child: LayoutNode,
    main_size: Optional[float],
    cross_avail: float,
    cross_force: Optional[float],
    is_row: bool,
    main_bounded: bool,
    direction: str,
) -> None:
    """Re-measure a child with optional forced main-axis size and cross hint."""
    fallback_main = math.inf if not main_bounded else cross_avail
    if is_row:
        avail_w = main_size if main_size is not None else fallback_main
        _measure_node(child, avail_w, cross_avail, forced_w=main_size, forced_h=cross_force, direction=direction)
    else:
        avail_h = main_size if main_size is not None else fallback_main
        _measure_node(child, cross_avail, avail_h, forced_w=cross_force, forced_h=main_size, direction=direction)


def _resolve_cross_force(
    child: LayoutNode,
    parent_align: str,
    cross_avail: float,
    cross_bounded: bool,
    is_row: bool,
    direction: str,
) -> Optional[float]:
    """Compute the cross-axis size to force on a child, or ``None`` to let it size naturally.

    Cross-axis stretch only applies when:

    1. The child's effective alignment is ``stretch``.
    2. The parent's cross axis is bounded (so we have a target size).
    3. The child does not have its own explicit cross-axis dimension.
    """
    if not cross_bounded or not math.isfinite(cross_avail):
        return None
    align = _resolve_align(child.style.get("align_self"), default=parent_align)
    if align != ALIGN_STRETCH:
        return None
    cross_key = "height" if is_row else "width"
    if cross_key in child.style and child.style.get(cross_key) is not None:
        return None
    margins = _child_margins(child, cross_avail, cross_avail, direction)
    margin_cross = (margins[1] + margins[3]) if is_row else (margins[0] + margins[2])
    return max(0.0, cross_avail - margin_cross)


def _layout_flex_children(
    parent: LayoutNode,
    content_w: float,
    content_h: float,
    main_bounded: bool,
    cross_bounded: bool,
) -> Tuple[float, float]:
    """Layout the in-flow children of `parent` along the flex axes.

    Children are measured, partitioned into flex lines (one line unless
    ``flex_wrap`` is enabled), grown/shrunk per line, and stretched to
    their line's cross size. The computed line structure is stored on
    ``parent._lines`` for the positioning pass.

    Returns ``(used_main, used_cross)``, the total content size used
    by the in-flow children, including inter-child gaps but excluding
    the parent's own padding. The caller adds padding back in for the
    container's outer size.
    """
    style = parent.style
    flex_direction = style.get("flex_direction", FLEX_DIRECTION_COLUMN)
    is_row = _is_row(flex_direction)
    direction = parent._direction

    main_avail = content_w if is_row else content_h
    cross_avail = content_h if is_row else content_w
    main_gap, cross_gap = _resolve_gaps(style, is_row)
    wrap = _wrap_mode(style)
    wrapping = wrap != WRAP_NOWRAP and main_bounded and math.isfinite(main_avail)

    in_flow: List[LayoutNode] = []
    absolute: List[LayoutNode] = []
    for child in parent.children:
        if child.style.get("position") == POSITION_ABSOLUTE:
            absolute.append(child)
        else:
            in_flow.append(child)

    align_items = _resolve_align(style.get("align_items"), default=ALIGN_STRETCH)

    # ------------------------------------------------------------------
    # Pass 1: initial measurement (basis for grow children, natural
    # size otherwise). Single-line containers stretch against the full
    # cross axis here (matching the nowrap fast path); wrapping
    # containers defer stretching until line cross sizes are known.
    # ------------------------------------------------------------------
    for child in in_flow:
        grow = _flex_grow(child.style)
        basis = _flex_basis(child.style, main_avail)
        cross_force = (
            None
            if wrapping
            else _resolve_cross_force(child, align_items, cross_avail, cross_bounded, is_row, direction)
        )

        if grow > 0:
            initial_main = basis if basis is not None else 0.0
            _measure_child_flexed(child, initial_main, cross_avail, cross_force, is_row, main_bounded, direction)
        elif basis is not None:
            _measure_child_flexed(child, basis, cross_avail, cross_force, is_row, main_bounded, direction)
        else:
            avail_for_child_main = math.inf if not main_bounded else main_avail
            if is_row:
                _measure_node(child, avail_for_child_main, cross_avail, forced_h=cross_force, direction=direction)
            else:
                _measure_node(child, cross_avail, avail_for_child_main, forced_w=cross_force, direction=direction)

    # ------------------------------------------------------------------
    # Pass 2: partition children into flex lines.
    # ------------------------------------------------------------------
    lines: List[_FlexLine] = []
    if wrapping:
        current: List[LayoutNode] = []
        current_main = 0.0
        for child in in_flow:
            outer = _child_outer_main(child, is_row, content_w, content_h, direction)
            extra = outer if not current else outer + main_gap
            if current and current_main + extra > main_avail + 1e-9:
                lines.append(_FlexLine(current))
                current = [child]
                current_main = outer
            else:
                current.append(child)
                current_main += extra
        if current:
            lines.append(_FlexLine(current))
    elif in_flow:
        lines.append(_FlexLine(list(in_flow)))

    # ------------------------------------------------------------------
    # Pass 3: per-line grow / shrink along the main axis.
    # ------------------------------------------------------------------
    for line in lines:
        flex_total = 0.0
        fixed_main_total = 0.0
        flex_basis_total = 0.0
        flex_entries: List[Tuple[LayoutNode, float, Optional[float]]] = []
        for child in line.children:
            grow = _flex_grow(child.style)
            if grow > 0:
                basis = _flex_basis(child.style, main_avail) or 0.0
                margins = _child_margins(child, content_w, content_h, direction)
                margin_main = (margins[0] + margins[2]) if is_row else (margins[1] + margins[3])
                flex_total += grow
                flex_basis_total += basis + margin_main
                flex_entries.append((child, grow, basis))
            else:
                fixed_main_total += _child_outer_main(child, is_row, content_w, content_h, direction)
        if len(line.children) > 1:
            fixed_main_total += main_gap * (len(line.children) - 1)

        if flex_total > 0 and main_bounded and math.isfinite(main_avail):
            remaining = max(0.0, main_avail - fixed_main_total - flex_basis_total)
            for child, grow, basis in flex_entries:
                extra = (grow / flex_total) * remaining
                child_main = (basis or 0.0) + extra
                cross_force = (
                    None
                    if wrapping
                    else _resolve_cross_force(child, align_items, cross_avail, cross_bounded, is_row, direction)
                )
                _measure_child_flexed(child, child_main, cross_avail, cross_force, is_row, main_bounded, direction)

        if main_bounded and math.isfinite(main_avail):
            total_main = sum(_child_outer_main(c, is_row, content_w, content_h, direction) for c in line.children)
            if len(line.children) > 1:
                total_main += main_gap * (len(line.children) - 1)
            overflow = total_main - main_avail
            if overflow > 0:
                shrinks = [(c, _flex_shrink(c.style)) for c in line.children]
                total_shrink = sum(s * _child_main_size(c, is_row) for c, s in shrinks)
                if total_shrink > 0:
                    for child, shrink in shrinks:
                        if shrink  1:
            line.main_used += main_gap * (len(line.children) - 1)

    # ------------------------------------------------------------------
    # Pass 4: line cross sizes (+ align_content stretch) and per-line
    # cross stretching for wrapped containers.
    # ------------------------------------------------------------------
    for line in lines:
        line.cross_size = max(
            (_child_outer_cross(c, is_row, content_w, content_h, direction) for c in line.children),
            default=0.0,
        )

    if wrapping and lines:
        align_content = _resolve_justify(style.get("align_content", "stretch"))
        if align_content == ALIGN_STRETCH and cross_bounded and math.isfinite(cross_avail):
            total_cross = sum(line.cross_size for line in lines) + cross_gap * (len(lines) - 1)
            if total_cross < cross_avail:
                extra_each = (cross_avail - total_cross) / len(lines)
                for line in lines:
                    line.cross_size += extra_each
        for line in lines:
            for child in line.children:
                cross_force = _resolve_cross_force(child, align_items, line.cross_size, True, is_row, direction)
                if cross_force is not None and abs(cross_force - _child_cross_size(child, is_row)) > 1e-9:
                    _measure_child_flexed(
                        child,
                        _child_main_size(child, is_row),
                        line.cross_size,
                        cross_force,
                        is_row,
                        main_bounded,
                        direction,
                    )

    for child in absolute:
        _measure_absolute(child, content_w, content_h, direction)

    parent._lines = lines

    if wrapping:
        used_main = max((line.main_used for line in lines), default=0.0)
        used_cross = sum(line.cross_size for line in lines)
        if len(lines) > 1:
            used_cross += cross_gap * (len(lines) - 1)
    else:
        used_main = lines[0].main_used if lines else 0.0
        used_cross = lines[0].cross_size if lines else 0.0
    return used_main, used_cross


def _measure_absolute(child: LayoutNode, parent_w: float, parent_h: float, direction: str) -> None:
    """Measure an absolutely-positioned child using `top` / `left` / `start` / etc."""
    style = child.style
    explicit_w = _resolve_value(style.get("width"), parent_w)
    explicit_h = _resolve_value(style.get("height"), parent_h)
    left, right = _absolute_horizontal_insets(style, parent_w, direction)
    top = _resolve_value(style.get("top"), parent_h)
    bottom = _resolve_value(style.get("bottom"), parent_h)

    if explicit_w is None and left is not None and right is not None:
        explicit_w = max(0.0, parent_w - left - right)
    if explicit_h is None and top is not None and bottom is not None:
        explicit_h = max(0.0, parent_h - top - bottom)

    avail_w = explicit_w if explicit_w is not None else parent_w
    avail_h = explicit_h if explicit_h is not None else parent_h
    _measure_node(child, avail_w, avail_h, forced_w=explicit_w, forced_h=explicit_h, direction=direction)


def _absolute_horizontal_insets(
    style: Dict[str, Any],
    parent_w: float,
    direction: str,
) -> Tuple[Optional[float], Optional[float]]:
    """Resolve ``left`` / ``right``, honoring ``start`` / ``end`` overrides."""
    left = _resolve_value(style.get("left"), parent_w)
    right = _resolve_value(style.get("right"), parent_w)
    start = _resolve_value(style.get("start"), parent_w)
    end = _resolve_value(style.get("end"), parent_w)
    if start is not None:
        if direction == DIRECTION_RTL:
            right = start
        else:
            left = start
    if end is not None:
        if direction == DIRECTION_RTL:
            left = end
        else:
            right = end
    return left, right


# ======================================================================
# Positioning: bottom-up placement after sizing
# ======================================================================


def _position_children(parent: LayoutNode) -> None:
    """Assign ``x`` / ``y`` to every descendant.

    Sizes are already computed by [`_measure_node`][pythonnative.layout._measure_node];
    this pass walks the same tree and applies ``justify_content`` /
    ``align_items`` / ``align_content`` / absolute positioning to
    determine concrete coordinates relative to each parent's
    coordinate space.
    """
    if not parent.children:
        return

    style = parent.style
    flex_direction = style.get("flex_direction", FLEX_DIRECTION_COLUMN)
    is_row = _is_row(flex_direction)
    direction = parent._direction
    rtl = direction == DIRECTION_RTL
    # In RTL, rows flip their visual order; ``row_reverse`` flips back.
    reverse = _is_reverse(flex_direction) != (rtl and is_row)
    pad_l, pad_t, pad_r, pad_b = _resolve_padding_for(style, parent.width, parent.height, "padding", direction)
    main_gap, cross_gap = _resolve_gaps(style, is_row)
    wrap = _wrap_mode(style)
    align_items = _resolve_align(style.get("align_items"), default=ALIGN_STRETCH)
    justify = _resolve_justify(style.get("justify_content"))

    in_flow: List[LayoutNode] = []
    absolute: List[LayoutNode] = []
    for child in parent.children:
        if child.style.get("position") == POSITION_ABSOLUTE:
            absolute.append(child)
        else:
            in_flow.append(child)

    content_w = max(0.0, parent.width - pad_l - pad_r)
    content_h = max(0.0, parent.height - pad_t - pad_b)
    main_size = content_w if is_row else content_h
    cross_size = content_h if is_row else content_w

    lines = parent._lines
    if not lines:
        lines = [_FlexLine(list(in_flow))] if in_flow else []
        for line in lines:
            line.main_used = sum(_child_outer_main(c, is_row, content_w, content_h, direction) for c in line.children)
            if len(line.children) > 1:
                line.main_used += main_gap * (len(line.children) - 1)
            line.cross_size = cross_size

    multi_line = wrap != WRAP_NOWRAP and len(lines) >= 1 and parent._lines is not None

    # Cross-axis placement of the lines themselves.
    if multi_line:
        total_lines_cross = sum(line.cross_size for line in lines) + cross_gap * max(0, len(lines) - 1)
        align_content = _resolve_justify(style.get("align_content", "stretch"))
        if align_content == ALIGN_STRETCH:
            align_content = JUSTIFY_FLEX_START
        free_cross = max(0.0, cross_size - total_lines_cross)
        cross_offset, cross_between = _justify_offsets(align_content, free_cross, len(lines))
    else:
        cross_offset, cross_between = 0.0, 0.0

    ordered_lines = list(lines)
    if wrap == WRAP_REVERSE and multi_line:
        ordered_lines.reverse()

    cross_cursor = cross_offset
    for line_index, line in enumerate(ordered_lines):
        line_cross = line.cross_size if multi_line else cross_size
        free_main = max(0.0, main_size - line.main_used)
        main_offset, between = _justify_offsets(justify, free_main, len(line.children))

        cursor = main_offset
        ordered = list(reversed(line.children)) if reverse else list(line.children)
        for i, child in enumerate(ordered):
            cm_l, cm_t, cm_r, cm_b = _child_margins(child, content_w, content_h, direction)
            margin_main_start = (cm_r if rtl else cm_l) if is_row else cm_t
            margin_cross_start = cm_t if is_row else (cm_r if rtl else cm_l)
            margin_cross_end = cm_b if is_row else (cm_l if rtl else cm_r)

            cross_pos = _align_offset(
                child,
                align_items,
                line_cross,
                is_row,
                margin_cross_start,
                margin_cross_end,
                rtl,
            )

            if is_row:
                child.x = pad_l + cursor + margin_main_start
                child.y = pad_t + cross_cursor + cross_pos
            else:
                child.x = pad_l + cross_cursor + cross_pos
                child.y = pad_t + cursor + margin_main_start

            cursor += _child_outer_main(child, is_row, content_w, content_h, direction)
            if i < len(ordered) - 1:
                cursor += main_gap + between

            _position_children(child)

        cross_cursor += line_cross
        if line_index < len(ordered_lines) - 1:
            cross_cursor += cross_gap + cross_between

    for child in absolute:
        _position_absolute(child, content_w, content_h, pad_l, pad_t, direction)
        _position_children(child)


def _justify_offsets(justify: str, free_main: float, n: int) -> Tuple[float, float]:
    """Return ``(leading_offset, between_children)`` for `justify_content`."""
    if n  1:
        return 0.0, free_main / (n - 1)
    if justify == JUSTIFY_SPACE_BETWEEN:
        return 0.0, 0.0
    if justify == JUSTIFY_SPACE_AROUND:
        each = free_main / n if n > 0 else 0.0
        return each / 2.0, each
    if justify == JUSTIFY_SPACE_EVENLY:
        each = free_main / (n + 1)
        return each, each
    return 0.0, 0.0


def _align_offset(
    child: LayoutNode,
    parent_align: str,
    cross_size: float,
    is_row: bool,
    margin_start: float,
    margin_end: float,
    rtl: bool = False,
) -> float:
    """Return the cross-axis offset for ``child`` inside its line."""
    align = _resolve_align(child.style.get("align_self"), default=parent_align)
    if align == ALIGN_AUTO:
        align = parent_align
    # A column's cross axis is horizontal, so RTL flips start/end.
    if rtl and not is_row:
        if align == ALIGN_FLEX_START:
            align = ALIGN_FLEX_END
        elif align == ALIGN_FLEX_END:
            align = ALIGN_FLEX_START

    child_cross = _child_cross_size(child, is_row)
    margin_cross = margin_start + margin_end
    if align == ALIGN_CENTER:
        return margin_start + max(0.0, (cross_size - child_cross - margin_cross) / 2.0)
    if align == ALIGN_FLEX_END:
        return max(0.0, cross_size - child_cross - margin_end)
    return margin_start


def _position_absolute(
    child: LayoutNode,
    content_w: float,
    content_h: float,
    pad_l: float,
    pad_t: float,
    direction: str,
) -> None:
    """Position an absolutely-positioned child via `top` / `left` / `start` / etc."""
    style = child.style
    left, right = _absolute_horizontal_insets(style, content_w, direction)
    top = _resolve_value(style.get("top"), content_h)
    bottom = _resolve_value(style.get("bottom"), content_h)

    if left is not None:
        child.x = pad_l + left
    elif right is not None:
        child.x = pad_l + content_w - right - child.width
    else:
        child.x = pad_l

    if top is not None:
        child.y = pad_t + top
    elif bottom is not None:
        child.y = pad_t + content_h - bottom - child.height
    else:
        child.y = pad_t


# ======================================================================
# Helpers used by the reconciler / native_views layer
# ======================================================================


def extract_layout_style(props: Dict[str, Any]) -> Dict[str, Any]:
    """Return a dict of the layout-relevant entries in ``props``.

    Used by the reconciler when building a `LayoutNode` from an element
    so the layout engine doesn't have to scan unrelated visual props.
    """
    return {k: v for k, v in props.items() if k in LAYOUT_STYLE_KEYS}

Web Proxy Viewer  |  New URL  |  Original Page