Layouts and text widgets

Row and Column arrange local animation surfaces. Label and StatusBar provide text without a custom draw callback. They compose with Panel, Scene, and Playback and use the existing Player loop. No additional dependencies or terminal application are required.

A small dashboard

from bruhanimate import Column, Fixed, Flex, Label, Row, Slot, Spacer, StatusBar, TextStyle, play

layout = Column(
    Slot(Label("MY DASHBOARD", align="center", style=TextStyle(bold=True)), size=Fixed(1)),
    Slot(Row(
        Slot(Label("Main content", align="center", vertical_align="middle"), size=Flex(2)),
        Spacer(Fixed(1)),
        Slot(Label(lambda: "State: ready", overflow="ellipsis"), size=Fixed(16)),
    )),
    Slot(StatusBar("Ready", right="q quit", style=TextStyle(background=24)), size=Fixed(1)),
    gap=1,
    padding=1,
)
play(effect_factory=layout.build, fps=60, frames=180, on_resize="resize")

Containers describe layout; they are not themselves animations. Pass layout.build as a player factory, or call layout.build(buffer) to obtain a typed ResizableAnimation for a Scene. Nest a Row/Column inside a Slot. A tree is single-use; construct a fresh tree for another run. Within a run, on_resize="resize" reflows the same children into new allocations.

Typed layout options

  • Slot(content, size=Flex()) owns one Animation or nested Row/Column.

  • Slot(effect_factory=callable, size=...) creates an Animation from an allocated local Buffer at startup. Supply exactly one content source.

  • Fixed(cells) requests a non-negative integer extent on the main axis: columns in Row, rows in Column.

  • Flex(weight=1) shares remaining cells by positive integer weight.

  • Spacer(size=Flex()) reserves space without painting or acquiring content.

  • gap=0 and padding=0 are non-negative integers. Padding is uniform on all four sides. Children fill the padded cross-axis extent.

  • background=None leaves gaps/padding transparent. A single-cell glyph, optionally SGR styled, paints the entire container before its children.

All constructor options are named and typed. Use Row(, Slot(, Fixed(, and Flex( for editor completion. layout.regions((width, height)) returns a tuple of local (x, y, width, height) rectangles without building or acquiring content. It includes Spacer and empty-slot rectangles.

Allocation is deterministic. Padding and gaps reserve space first, then Fixed requests consume it in declaration order. If fixed requests exceed the available space, later requests shrink, possibly to zero. Flex slots share the remainder using integer largest-remainder allocation; ties go to earlier children. If there are no Flex slots, unused space stays at the end. Oversized gaps/padding can leave zero-area slots whose origins lie outside the buffer.

Zero-area slots do not start children, invoke factories, update, draw, or participate in finite completion. Supplied children in those slots are still owned and closed during cleanup. This allows tiny terminals without constructing zero-sized built-in simulations. Factories may assume positive local dimensions.

Effects and panels in slots

from bruhanimate import Column, Fixed, Label, Panel, Slot, SnowEffect, SnowSettings, play

def snow_panel(local):
    border = local.width() >= 3 and local.height() >= 3
    return Panel(
        effect_factory=lambda content: SnowEffect(content, " ", SnowSettings(seed=42)),
        size=(local.width() - 2 * border, local.height() - 2 * border),
        border=border,
        title="Snow" if border else "",
        updates_per_second=15,
    )

layout = Column(
    Slot(Label("Local animation surface"), size=Fixed(1)),
    Slot(effect_factory=snow_panel),
    gap=1,
)
play(effect_factory=layout.build, fps=60, updates_per_second=60, frames=180)

Each slot’s child draws in local coordinates. Built-in buffers must match the allocated size, so prefer factories to preallocated effects. A Panel inside a slot uses that slot as its parent: its outer size includes its border, while its size is content-only. The example subtracts the border from the slot size and uses an unframed panel when the allocation is too small for a border.

Slots clip content to their rectangles. A layout adds no update clock: every parent update is forwarded in declaration order. Use Panel or Layer to pace an effect independently; see Movable panels and Panels, layer clocks, and temporary overlays.

Labels and dynamic text

from bruhanimate import Label, TextStyle, play

label = Label(
    "Aligned text\nwith a second line",
    align="center",
    vertical_align="middle",
    overflow="ellipsis",
    style=TextStyle(foreground=117, background=235, bold=True),
)
play(label, frames=60)

Label accepts a string or a zero-argument callback returning a string. Callbacks are not called during construction or drawing. They are evaluated once at start and once per update; draw uses cached text, available through the read-only text property. Callbacks should be fast, read-only state getters. Place state-producing content before dependent labels so the labels observe the new state in the same update. Invalid callback results raise when refreshed.

Editor completion exposes:

  • align="left" | "center" | "right"

  • vertical_align="top" | "middle" | "bottom"

  • overflow="clip" | "ellipsis" | "wrap"

  • style=TextStyle(foreground=None, background=None, bold=False)

  • fill=True (paint the entire local area with styled spaces).

Colors are ANSI palette indices 0..255. Newlines are supported; wide characters, combining marks, tabs, other controls, and inline escape sequences are rejected. Use TextStyle for per-cell ANSI styling. wrap breaks at cell boundaries, not words. ellipsis shortens each long line using ASCII dots. Excess rows clip; vertical alignment applies to the visible rows. fill=False leaves unpainted cells untouched, but spaces in the text still paint.

TextSource, TextAlign, VerticalAlign, and TextOverflow are exported type aliases for applications that expose these same options.

Status bars

StatusBar(left="", center="", right="", style=None) paints one local row. Each section is a single-line string or callback, refreshed only at start and on updates. Use Slot(status, size=Fixed(1)) in a Column. The right section has first claim on space, then left, then center; sections clip from their ends and keep a one-cell gap when space permits. They never overwrite one another. Extra local rows remain untouched. Style applies to the full bar, including spaces.

Ownership, completion, and limitations

Building reserves the whole nested layout tree and transfers supplied children to the returned animation. Duplicate supplied animation/container instances are rejected across the tree. Factories must return fresh instances. Constructors and build do not open resources or call factories; startup creates only visible slot content. Closing before startup still closes supplied children without calling factories. Cleanup covers partial startup failures and continues if a child’s close raises. Close is idempotent; built layouts cannot restart afterward.

Never share children with another owner or separately build an already-owned nested layout. Factory code remains responsible for resources acquired before it successfully returns a child. Dynamic labels own only their cached strings; they do not own objects captured by callbacks. They refresh on a later start if used independently again.

Started finite children (including subsequently hidden ones) participate in Scene’s usual completion rules. Labels, status bars, and spacers are endless. on_complete="hold" keeps backgrounds and text updating after a solver finishes. Capture defaults to exit on finite completion. Updates use the parent’s simulation seconds, with no extra loops, threads, input polling, or sleeping.

Keyboard polling stays on Player. Use Keyboard bindings and focus to route bindings to focused panels; layouts exclude zero-area slots from focus automatically. Use Scrollable views and activity logs for scrollable animation canvases and bounded activity logs. Mouse handling is not provided. With on_resize="resize", built layouts accept new viewport sizes between frames. Existing children keep running when a slot becomes empty; initially empty slots start only when they first become visible. Factories are never recalled for existing content. Use Panel(fit=True, ...) in slot factories to fit a bordered panel to its changing allocation. See Terminal resize. The Dashboard demo example combines a public beam fixture, an animated background, a statistics sidebar, and explicit solver controls in your native terminal.

API

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

class bruhanimate.layout.Fixed(cells: int)[source]

Request a non-negative number of cells along the container’s main axis.

cells: int
__init__(cells: int) → None
class bruhanimate.layout.Flex(weight: int = 1)[source]

Share remaining cells in proportion to a positive integer weight.

weight: int = 1
__init__(weight: int = 1) → None
class bruhanimate.layout.Slot(content: Animation | Row | Column | None = None, *, size: Fixed | Flex = Flex(weight=1), effect_factory: Callable[[Buffer], Animation] | None = None)[source]

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 = Flex(weight=1)
effect_factory: Callable[[Buffer], Animation] | None = None
__init__(content: Animation | Row | Column | None = None, *, size: Fixed | Flex = Flex(weight=1), effect_factory: Callable[[Buffer], Animation] | None = None) → None
class bruhanimate.layout.Spacer(size: Fixed | Flex = Flex(weight=1))[source]

An unpainted layout slot; defaults to Flex(1), or use Fixed(cells).

size: Fixed | Flex = Flex(weight=1)
__init__(size: Fixed | Flex = Flex(weight=1)) → None
class bruhanimate.layout.Row(*children: Slot | Spacer, gap: int = 0, padding: int = 0, background: str | None = None)[source]

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.

__init__(*children: Slot | Spacer, gap: int = 0, padding: int = 0, background: str | None = None) → None
build(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.

regions(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.

class bruhanimate.layout.Column(*children: Slot | Spacer, gap: int = 0, padding: int = 0, background: str | None = None)[source]

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.

__init__(*children: Slot | Spacer, gap: int = 0, padding: int = 0, background: str | None = None) → None
build(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.

regions(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.

Single-cell text widgets that snapshot dynamic text during simulation updates.

class bruhanimate.widgets.Label(text: str | Callable[[], str], *, align: Literal['left', 'center', 'right'] = 'left', vertical_align: Literal['top', 'middle', 'bottom'] = 'top', overflow: Literal['clip', 'ellipsis', 'wrap'] = 'clip', style: TextStyle | None = None, fill: bool = True)[source]

Aligned plain text, optionally supplied by a live zero-argument callback.

Text is validated/cached on start and once per update, never during draw. Static text is available immediately; dynamic text starts empty. Newlines are allowed, but controls, wide/combining glyphs and inline ANSI are not. wrap breaks at cell boundaries, not words. ellipsis shortens each long line with ASCII dots. Extra rows clip. fill=True paints the full local buffer with styled spaces; False leaves unpainted cells untouched. Text spaces paint. Widgets own no input or external resources. Callbacks should only read state.

__init__(text: str | Callable[[], str], *, align: Literal['left', 'center', 'right'] = 'left', vertical_align: Literal['top', 'middle', 'bottom'] = 'top', overflow: Literal['clip', 'ellipsis', 'wrap'] = 'clip', style: TextStyle | None = None, fill: bool = True) → None[source]
property text: str

Most recently cached text; reading this never calls the source.

start() → None[source]
update(dt: float) → None[source]
draw(buffer: Buffer) → None[source]
close() → None[source]
class bruhanimate.widgets.StatusBar(left: str | Callable[[], str] = '', *, center: str | Callable[[], str] = '', right: str | Callable[[], str] = '', style: TextStyle | None = None)[source]

One row of left/center/right text, refreshed at start and on each update.

Sections are single-line TextSources, styled per cell. Draw never evaluates callbacks. The right section has first claim on space, then left, then center. Sections clip from their ends and leave a one-cell gap when space permits; they never overwrite one another. Only the first local row is painted. Use Slot(status, size=Fixed(1)) in a Column. No input routing is installed.

__init__(left: str | Callable[[], str] = '', *, center: str | Callable[[], str] = '', right: str | Callable[[], str] = '', style: TextStyle | None = None) → None[source]
start() → None[source]
update(dt: float) → None[source]
draw(buffer: Buffer) → None[source]
close() → None[source]

Single-cell styling shared by widgets and animation layers.

class bruhanimate.styling.TextStyle(foreground: int | None = None, background: int | None = None, bold: bool = False)[source]

Per-cell ANSI styling; palette colors are 0..255, or None for defaults.

On a Layer, None colors and bold=False leave child attributes unchanged; explicit colors and bold=True override them. Widget behavior is unchanged.

foreground: int | None = None
background: int | None = None
bold: bool = False
__init__(foreground: int | None = None, background: int | None = None, bold: bool = False) → None
bruhanimate.styling._background(prefix: str) → str[source]

Resolve background at the glyph, including resets and RGB/palette SGR.