Source code for bruhanimate.layout

"""Declarative row/column allocation with explicit sizing and child ownership."""

from __future__ import annotations

from collections.abc import Callable
from contextlib import ExitStack
from dataclasses import dataclass, field
from typing import Literal

from .bruhutil.bruhffer import Buffer
from .grid import _glyph
from .keyboard import _set_visible
from .panel import Panel
from .player import Animation, CallbackEffect, ResizableAnimation
from .resize import _resize, _size
from .scene import Scene


def _integer(value: int, name: str, minimum: int = 0) -> None:
    if type(value) is not int:
        raise TypeError(f"{name} must be an integer")
    if value < minimum:
        raise ValueError(f"{name} must be at least {minimum}")


[docs] @dataclass(frozen=True) class Fixed: """Request a non-negative number of cells along the container's main axis.""" cells: int def __post_init__(self) -> None: _integer(self.cells, "cells")
[docs] @dataclass(frozen=True) class Flex: """Share remaining cells in proportion to a positive integer weight.""" weight: int = 1 def __post_init__(self) -> None: _integer(self.weight, "weight", 1)
[docs] @dataclass(frozen=True) class Slot: """One owned animation or nested layout, with explicit main-axis sizing. Supply exactly one of content or effect_factory. Factories run at startup with the allocated local Buffer, only for nonempty slots. Size defaults to Flex(1). Factories must return fresh Animation instances, not shared children. """ content: Animation | Row | Column | None = None size: Fixed | Flex = field(default=Flex(), kw_only=True) effect_factory: Callable[[Buffer], Animation] | None = field( default=None, kw_only=True ) def __post_init__(self) -> None: if (self.content is None) == (self.effect_factory is None): raise ValueError("Slot requires exactly one of content or effect_factory") if self.content is not None and not isinstance( self.content, (Animation, Row, Column) ): raise TypeError("Slot content must be an Animation, Row, or Column") if self.effect_factory is not None and not callable(self.effect_factory): raise TypeError("effect_factory must be callable") if not isinstance(self.size, (Fixed, Flex)): raise TypeError("size must be Fixed or Flex")
[docs] @dataclass(frozen=True) class Spacer: """An unpainted layout slot; defaults to Flex(1), or use Fixed(cells).""" size: Fixed | Flex = Flex() def __post_init__(self) -> None: if not isinstance(self.size, (Fixed, Flex)): raise TypeError("size must be Fixed or Flex")
class _Layout: _axis: Literal["row", "column"] = "row" def __init__( self, *children: Slot | Spacer, gap: int = 0, padding: int = 0, background: str | None = None, ) -> None: if any(not isinstance(child, (Slot, Spacer)) for child in children): raise TypeError("Layout children must be Slot or Spacer instances") _integer(gap, "gap") _integer(padding, "padding") if background is not None: _glyph(background) self._children, self._gap, self._padding = children, gap, padding self._background = background self._used = False def regions(self, size: tuple[int, int]) -> tuple[tuple[int, int, int, int], ...]: """Inspect (x, y, width, height) for each child without acquiring anything. Size is the available width/height. Uniform padding and gaps reserve space first. Fixed requests then consume space in declaration order; Flex children share the remainder using largest remainders, with ties going to earlier children. The cross axis fills the padded extent. Zero-area regions may have origins outside size when gaps cannot fit. """ if not isinstance(size, tuple) or len(size) != 2: raise TypeError("size must be a (width, height) tuple") for value in size: _integer(value, "dimension") horizontal = self._axis == "row" main, cross = size if horizontal else (size[1], size[0]) remaining = max( 0, main - 2 * self._padding - self._gap * max(0, len(self._children) - 1) ) extents = [0] * len(self._children) flexible = [] for index, child in enumerate(self._children): if isinstance(child.size, Fixed): extents[index] = min(remaining, child.size.cells) remaining -= extents[index] else: flexible.append((index, child.size.weight)) total = sum(weight for _, weight in flexible) if total: fractions = [] for index, weight in flexible: extents[index], remainder = divmod(remaining * weight, total) fractions.append((-remainder, index)) extra = remaining - sum(extents[index] for index, _ in flexible) for _, index in sorted(fractions)[:extra]: extents[index] += 1 regions = [] cursor = self._padding cross = max(0, cross - 2 * self._padding) for extent in extents: regions.append( (cursor, self._padding, extent, cross) if horizontal else (self._padding, cursor, cross, extent) ) cursor += extent + self._gap return tuple(regions) def build(self, buffer: Buffer) -> ResizableAnimation: """Bind this single-use tree to buffer dimensions without calling factories. Pass this method as play(effect_factory=layout.build), or compose the returned animation in Scene. Nested layouts bind automatically. The built tree owns all supplied children, including zero-area slots; content that never becomes visible closes without starting or calling factories. Resize notifications reallocate existing children without restarting them. Once started, hidden children keep updating; initially hidden factories wait until first visible allocation. Closing releases every owned child. """ layouts: list[_Layout] = [] owners: set[int] = set() def visit(layout: _Layout) -> None: if layout._used: raise RuntimeError("Layout trees are single-use; construct a new tree") if id(layout) in owners: raise ValueError("A layout cannot own the same child twice") owners.add(id(layout)) layouts.append(layout) for child in layout._children: if isinstance(child, Spacer) or child.content is None: continue if isinstance(child.content, _Layout): visit(child.content) elif id(child.content) in owners: raise ValueError("A layout cannot own the same animation twice") else: owners.add(id(child.content)) visit(self) size = (buffer.width(), buffer.height()) self.regions(size) for layout in layouts: layout._used = True return self._instantiate(buffer) def _instantiate(self, buffer: Buffer) -> _LayoutAnimation: return _LayoutAnimation(self, (buffer.width(), buffer.height()))
[docs] class Row(_Layout): """Arrange Slots/Spacers horizontally; Fixed/Flex sizes allocate columns. Cross-axis height fills the padded area. Use build as a player factory; nest Row/Column inside a Slot. See regions for shortage and rounding rules. """
[docs] class Column(_Layout): """Arrange Slots/Spacers vertically; Fixed/Flex sizes allocate rows. Cross-axis width fills the padded area. Use build as a player factory; nest Row/Column inside a Slot. See regions for shortage and rounding rules. """ _axis: Literal["row", "column"] = "column"
def _discard(content: Animation | Row | Column | None) -> None: if isinstance(content, _Layout): with ExitStack() as cleanup: for child in content._children: if isinstance(child, Slot): cleanup.callback(_discard, child.content) elif content is not None: content.close() class _Cell: def __init__(self, child: Slot | Spacer, region: tuple[int, int, int, int]) -> None: self._child, self._region = child, region self._visible = True self._panel: Panel | None = None self._closed = False @property def finished(self) -> bool | None: return self._panel.finished if self._panel is not None else None def set_visible(self, visible: bool) -> None: self._visible = visible if self._panel is not None: self._panel.set_visible( visible and bool(self._region[2] and self._region[3]) ) def reflow(self, region: tuple[int, int, int, int], *, started: bool) -> None: self._region = region x, y, width, height = region if self._panel is not None: self._panel.move_to((x, y)) if width and height: self._panel.resize((width, height)) elif started: self.start() self.set_visible(self._visible) def start(self) -> None: if self._panel is not None: return x, y, width, height = self._region child = self._child if isinstance(child, Spacer) or not width or not height: return factory = child.effect_factory content = child.content if isinstance(content, _Layout): factory = content._instantiate content = None elif content is not None: # Supplied content may first become visible after a resize, or be a # fitting panel whose initial size differs from this allocation. _resize(content, (width, height)) self._panel = Panel( content, effect_factory=factory, size=(width, height), position=(x, y), border=False, fit=True, background=None, ) self._panel.set_visible(self._visible) self._panel.start() def update(self, dt: float) -> None: if self._panel is not None: self._panel.update(dt) def draw(self, buffer: Buffer) -> None: if self._panel is not None and self._region[2] and self._region[3]: # A fitted bordered child can exceed a one-cell slot. Drawing through # the outer borderless panel keeps every pixel within its allocation. self._panel.draw(buffer) def close(self) -> None: if self._closed: return self._closed = True if self._panel is not None: self._panel.close() elif isinstance(self._child, Slot): _discard(self._child.content) class _LayoutAnimation: def __init__(self, layout: _Layout, size: tuple[int, int]) -> None: self._layout = layout self._size = size self._cells = [ _Cell(child, region) for child, region in zip(layout._children, layout.regions(size)) ] def background(buffer: Buffer) -> None: buffer.clear_buffer(val=layout._background) self._scene = Scene(CallbackEffect(background), *self._cells) self._started = self._closed = False @property def finished(self) -> bool | None: return self._scene.finished def start(self) -> None: if self._closed: raise RuntimeError("Layout animations are single-use") if self._started: return self._started = True try: self._scene.start() except BaseException: self.close() raise def update(self, dt: float) -> None: if not self._started or self._closed: raise RuntimeError("Layout must be started and not closed before update") self._scene.update(dt) def set_visible(self, visible: bool) -> None: """Propagate ancestor visibility to focus targets, preserving updates.""" if type(visible) is not bool: raise TypeError("visible must be a bool") for cell in self._cells: _set_visible(cell, visible) def resize(self, size: tuple[int, int]) -> None: """Reflow without replacing live children or resetting clocks.""" _size(size) if self._closed: raise RuntimeError("Cannot resize a closed layout") for cell, region in zip(self._cells, self._layout.regions(size)): cell.reflow(region, started=self._started) self._size = size def draw(self, buffer: Buffer) -> None: if not self._started or self._closed: raise RuntimeError("Layout must be started and not closed before draw") if (buffer.width(), buffer.height()) != self._size: raise ValueError("Layout drawing buffer must match its bound dimensions") self._scene.draw(buffer) def close(self) -> None: if self._closed: return self._closed = True with ExitStack() as cleanup: for cell in self._cells: cleanup.callback(cell.close) cleanup.callback(self._scene.close)