Boxes

The box model: padding in every CSS shorthand, backgrounds, borders, radii, shadows, clipping and size rules.

Boxes
View on GitHubgaanim examples/layout/boxes.py
Code · 153 lines
"""The box model: padding, decoration and size rules of a layout box.

A box keeps its padding between its edge and its content, draws its own
background, border, rounded corners and shadow, and takes its size from its
content, a length, a share of its parent or the free space. Each segment
covers one part of the model, and each label is the code that differs
between the boxes.
"""

import os

from gaanim import BoxStyle, Scene, Zones, stagger

scene = Scene()
L = scene.layout
accent = scene.canvas.color("accent")
header = scene.canvas.color("header")
panel = scene.canvas.color("panel")
rule = scene.canvas.color("rule")
dark = scene.canvas.color("background")
seeks = []


def columns(count, split):
    """The frame cut into ``count`` rows of (label zone, example zone)."""
    grid = L.zones(Zones.grid(rows=count, columns=list(split), column_gap="48px", padding="page_padding"))
    return [(grid[2 * index], grid[2 * index + 1]) for index in range(count)]


def example_width(split):
    """The width of the example column of a table with ``split``, in scene units."""
    return columns(1, split)[0][1].width


def table(variants, split):
    """One row per (label, box): the code on the left, the box it builds on the right."""
    entrances = []
    for (label_zone, zone), (label, box) in zip(columns(len(variants), split), variants):
        code = scene.text(label, role="code").place(label_zone, anchor="right")
        box.place(zone, anchor="left")
        entrances += [code.animate.fade_in().duration(0.4), box.animate.fade_in().duration(0.4)]
    start = scene.cursor
    scene.play(stagger(*entrances, each=0.04))
    scene.wait(1.0)
    # Mid-entrance and settled.
    seeks.extend([start + 0.3, scene.cursor - 0.1])


# Padding is the space between a box's edge and its content, written like
# CSS: one value, (vertical, horizontal), (top, horizontal, bottom) or all
# four sides. Lengths are scene units, "Npx" or theme tokens.
scene.segment("padding")
SPLIT = ("3fr", "2fr")
frame = BoxStyle(background=header, border=rule, width=example_width(SPLIT))
content = BoxStyle(width="fill", padding=("16px", 0), align="center", background=accent, color=dark,
                   font_size="72px")


def padded(padding):
    """A content block that fills a framed box with ``padding``."""
    return L.box(L.box("content", style=content), style=frame, padding=padding)


table(
    [
        ("padding=0", padded(0)),
        ('padding="24px"', padded("24px")),
        ('padding="space_lg"', padded("space_lg")),
    ],
    SPLIT,
)

scene.segment("padding_sides")
table(
    [
        ('padding=("12px", "96px")', padded(("12px", "96px"))),
        ('padding=("8px", "32px", "64px")', padded(("8px", "32px", "64px"))),
        ('padding=("8px", "32px", "64px", "128px")', padded(("8px", "32px", "64px", "128px"))),
    ],
    SPLIT,
)

# A box draws its own background, border and rounded corners.
scene.segment("background")
SPLIT = ("8fr", "7fr")
tile = BoxStyle(width=example_width(SPLIT), height="120px", align="center", justify="center", font_size="56px")
table(
    [
        ("background=header", L.box("Box", style=tile, background=header)),
        ("border=rule", L.box("Box", style=tile, border=rule)),
        ('border=accent, border_width="6px"', L.box("Box", style=tile, border=accent, border_width="6px")),
        ('background=header, radius="24px"', L.box("Box", style=tile, background=header, radius="24px")),
        ('background=header, radius="full"', L.box("Box", style=tile, background=header, radius="full")),
    ],
    SPLIT,
)

# A shadow is a color blurred and offset under the box (shadow=True takes
# the theme's shadow color); clip=True cuts the content at the box's rounded
# edge.
scene.segment("shadow_and_clip")
SPLIT = ("3fr", "2fr")
tile = tile.but(width=example_width(SPLIT), height="160px")
table(
    [
        ("shadow=True", L.box("Box", style=tile, background=header, shadow=True)),
        ('shadow={"color": accent, "blur": "32px"}',
         L.box("Box", style=tile, background=header, shadow={"color": accent, "blur": "32px"})),
        ('shadow={"color": rule, "blur": 0}', L.box("Box", style=tile, background=header,
                                                     shadow={"color": rule, "blur": 0})),
        ("clip=True", L.stack(scene.geometry.circle(1.1).item(anchor="top"), style=tile, background=header,
                              radius="24px", clip=True)),
    ],
    SPLIT,
)

# Sizes: "hug" the content (the default), a length, a share of the parent
# or "fill" the free space.
scene.segment("width")
SPLIT = ("5fr", "11fr")
lane = BoxStyle(width=example_width(SPLIT), padding="8px", background=panel, border=rule)
chip = BoxStyle(padding=("8px", "24px"), background=accent, color=dark, font_size="56px")
table(
    [
        ('width="hug"', L.row(L.box("hug", style=chip, width="hug"), style=lane)),
        ('width="360px"', L.row(L.box("360px", style=chip, width="360px"), style=lane)),
        ('width="50%"', L.row(L.box("50%", style=chip, width="50%"), style=lane)),
        ('width="fill"', L.row(L.box("fill", style=chip, width="fill"), style=lane)),
    ],
    SPLIT,
)

# min and max bounds, a fixed height and an aspect ratio.
scene.segment("bounds")
SPLIT = ("1fr", "1fr")
lane = lane.but(width=example_width(SPLIT))
table(
    [
        ('min_width="560px"', L.row(L.box("min", style=chip, min_width="560px"), style=lane)),
        ('max_width="480px"', L.row(L.box("max_width wraps a long line", style=chip, max_width="480px"),
                                    style=lane)),
        ('height="160px"', L.row(L.box("160px", style=chip, height="160px"), style=lane)),
        ('width="480px", aspect_ratio=4', L.row(L.box("4:1", style=chip, width="480px", aspect_ratio=4),
                                                style=lane)),
    ],
    SPLIT,
)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Box styles

Reusable styles with BoxStyle, .but() and classes, typography inherited by box text, text_box and components.

Box styles
View on GitHubgaanim examples/layout/box_styles.py
Code · 154 lines
"""Reusable box styles: BoxStyle, classes, inherited typography and components.

A BoxStyle groups box properties like a CSS rule; classes name them for the
whole scene, and a box's typography reaches every string it holds. A
component wraps a whole box tree in a function with checked arguments. Each
label is the code that builds the box beside it.
"""

import os

from gaanim import BoxStyle, Scene, Zones, component, stagger

scene = Scene()
L = scene.layout
accent = scene.canvas.color("accent")
header = scene.canvas.color("header")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
dark = scene.canvas.color("background")
success = scene.canvas.color("success")
warning = scene.canvas.color("warning")
danger = scene.canvas.color("danger")
seeks = []


def columns(count, split):
    """The frame cut into ``count`` rows of (label zone, example zone)."""
    grid = L.zones(Zones.grid(rows=count, columns=list(split), column_gap="48px", padding="page_padding"))
    return [(grid[2 * index], grid[2 * index + 1]) for index in range(count)]


def example_width(split):
    """The width of the example column of a table with ``split``, in scene units."""
    return columns(1, split)[0][1].width


def table(variants, split):
    """One row per (label, box): the code on the left, the box it builds on the right."""
    entrances = []
    for (label_zone, zone), (label, box) in zip(columns(len(variants), split), variants):
        code = scene.text(label, role="code").place(label_zone, anchor="right")
        box.place(zone, anchor="left")
        entrances += [code.animate.fade_in().duration(0.4), box.animate.fade_in().duration(0.4)]
    start = scene.cursor
    scene.play(stagger(*entrances, each=0.04))
    scene.wait(1.0)
    # Mid-entrance and settled.
    seeks.extend([start + 0.3, scene.cursor - 0.1])


# style= applies a BoxStyle, and .but() derives a copy with some properties
# changed.
scene.segment("style")
tag = BoxStyle(padding=("12px", "48px"), radius="12px", background=header, font_size="72px")
table(
    [
        ("style=tag", L.box("Draft", style=tag)),
        ("style=tag.but(border=accent)", L.box("Draft", style=tag.but(border=accent))),
        ('style=tag.but(radius="full")', L.box("Draft", style=tag.but(radius="full"))),
    ],
    ("1fr", "1fr"),
)

# classes() names styles for class_=. Classes apply first (the last name
# wins), then style=, then the properties written in the call.
scene.segment("classes")
L.classes(
    pill=BoxStyle(padding=("12px", "48px"), radius="full", background=header, font_size="72px"),
    ok=BoxStyle(background=success, color=dark),
)
warn = BoxStyle(background=warning, color=dark)
table(
    [
        ('class_="pill"', L.box("Draft", class_="pill")),
        ('class_="pill ok"', L.box("Ready", class_="pill ok")),
        ('class_="pill ok", style=warn', L.box("Review", class_="pill ok", style=warn)),
        ('class_="pill", style=warn, background=danger', L.box("Blocked", class_="pill", style=warn,
                                                                background=danger)),
    ],
    ("2fr", "1fr"),
)

# Typography set on a box reaches the strings it holds, which become text.
scene.segment("typography")
SPLIT = ("1fr", "1fr")
SENTENCE = "Boxes set type"
body = BoxStyle(font_size="64px")
table(
    [
        ('font_size="96px"', L.box(SENTENCE, style=body, font_size="96px")),
        ("weight=700, color=accent", L.box(SENTENCE, style=body, weight=700, color=accent)),
        ('italic=True, letter_spacing="8px"', L.box(SENTENCE, style=body, italic=True, letter_spacing="8px")),
        ('role="code"', L.box(SENTENCE, style=body, role="code")),
        ("markup=True", L.box("A *strong* word and $x^2$", style=body, markup=True)),
    ],
    SPLIT,
)

# A box narrower than its text wraps it; text_align and line_spacing shape
# the lines.
scene.segment("wrapping")
para = BoxStyle(width=example_width(SPLIT), padding=("8px", "16px"), border=rule, font_size="56px")
PARAGRAPH = "A box wraps the text it holds to its own width, line by line"
table(
    [
        ('text_align="left"', L.box(PARAGRAPH, style=para, text_align="left")),
        ('text_align="center"', L.box(PARAGRAPH, style=para, text_align="center")),
        ('text_align="right"', L.box(PARAGRAPH, style=para, text_align="right")),
        ('text_align="justify"', L.box(PARAGRAPH, style=para, text_align="justify")),
        ("line_spacing=1.5", L.box(PARAGRAPH, style=para, line_spacing=1.5)),
    ],
    SPLIT,
)

# text_box chooses the box a text is measured and placed by: whole lines,
# from the capital height to the baseline, or the ink of its glyphs.
scene.segment("text_box")
sample = BoxStyle(background=header, font_size="176px")
table(
    [
        ('text_box="line"', L.box("Typography", style=sample, text_box="line")),
        ('text_box="cap"', L.box("Typography", style=sample, text_box="cap")),
        ('text_box="ink"', L.box("Typography", style=sample, text_box="ink")),
    ],
    ("2fr", "3fr"),
)


# A component is a function that returns a box; @component checks its
# keyword slots and every call builds new objects.
@component
def kpi(scene, *, label: str, value: str, up: bool = True):
    return scene.layout.column(
        scene.layout.box(label, color=muted, font_size="48px"),
        scene.layout.box(value, font_size="104px", weight=700, color=success if up else danger),
        padding=("16px", "40px"), radius="20px", background=header, width="480px",
    )


scene.segment("components")
table(
    [
        ('kpi(scene, label="Visits", value="48.2k")', kpi(scene, label="Visits", value="48.2k")),
        ('kpi(scene, label="Sign-ups", value="1 284")', kpi(scene, label="Sign-ups", value="1 284")),
        ('kpi(scene, label="Bounce", value="31%", up=False)', kpi(scene, label="Bounce", value="31%", up=False)),
    ],
    ("5fr", "2fr"),
)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Flex

Rows and columns with every justify and align value, gaps, wrapping, grow, shrink, basis, margins and absolute children.

Flex
View on GitHubgaanim examples/layout/flex.py
Code · 148 lines
"""Rows and columns: how a box distributes and aligns the children of one line.

``L.row`` and ``L.column`` flow their children along a main axis. ``justify``
spreads them along it, ``align`` lines them up across it, ``gap`` and
``wrap`` set the spacing and the line breaks, and each child can grow,
shrink, align itself or take margins with ``item()``. Every lane is the same
box with one rule changed, and the label is that rule.
"""

import os

from gaanim import BoxStyle, Scene, Zones, stagger

scene = Scene()
L = scene.layout
accent = scene.canvas.color("accent")
panel = scene.canvas.color("panel")
rule = scene.canvas.color("rule")
dark = scene.canvas.color("background")
seeks = []

lane_style = BoxStyle(padding="8px", gap="8px", background=panel, border=rule)
chip = BoxStyle(padding=("4px", "32px"), min_width="160px", background=accent, color=dark, align="center",
                font_size="72px")


def chips(*names):
    """One chip per name."""
    return [L.box(name, style=chip) for name in names]


def marked(name, **rules):
    """A chip with the item rules ``rules``: the child a lane is about."""
    return L.box(name, style=chip).item(**rules)


def columns(count, split):
    """The frame cut into ``count`` rows of (label zone, example zone)."""
    grid = L.zones(Zones.grid(rows=count, columns=list(split), column_gap="48px", padding="page_padding"))
    return [(grid[2 * index], grid[2 * index + 1]) for index in range(count)]


def lane_for(split):
    """The lane style, as wide as the example column of a table with ``split``."""
    return lane_style.but(width=columns(1, split)[0][1].width)


def table(variants, split):
    """One row per (label, box): the code on the left, the box it builds on the right."""
    entrances = []
    for (label_zone, zone), (label, box) in zip(columns(len(variants), split), variants):
        code = scene.text(label, role="code").place(label_zone, anchor="right")
        box.place(zone, anchor="left")
        entrances += [code.animate.fade_in().duration(0.4), box.animate.fade_in().duration(0.4)]
    start = scene.cursor
    scene.play(stagger(*entrances, each=0.04))
    scene.wait(1.0)
    # Mid-entrance and settled.
    seeks.extend([start + 0.3, scene.cursor - 0.1])


# justify spreads the children along the main axis: horizontal in a row.
scene.segment("justify")
SPLIT = ("2fr", "5fr")
lane = lane_for(SPLIT)
table([
    (f'justify="{justify}"', L.row(*chips("1", "2", "3"), style=lane, justify=justify))
    for justify in ("start", "center", "end", "between", "around", "evenly")
], SPLIT)

# align lines them up across it. Chips of three text sizes show "baseline",
# and "stretch" makes each one as tall as the line.
scene.segment("align")
table([
    (f'align="{align}"', L.row(
        L.box("Ag", style=chip, font_size="48px"),
        L.box("Ag", style=chip, font_size="80px"),
        L.box("Ag", style=chip, font_size="120px"),
        style=lane, height="176px", align=align,
    ))
    for align in ("start", "center", "end", "stretch", "baseline")
], SPLIT)

# gap separates the children; a column flows top to bottom.
scene.segment("gap")
SPLIT = ("2fr", "3fr")
lane = lane_for(SPLIT)
table([
    ('L.row(..., gap="8px")', L.row(*chips("1", "2", "3"), style=lane, gap="8px")),
    ('L.row(..., gap="64px")', L.row(*chips("1", "2", "3"), style=lane, gap="64px")),
    ('L.column(..., gap="8px")', L.column(*chips("1", "2", "3"), style=lane, gap="8px", align="start")),
], SPLIT)

# wrap=True breaks a row that does not fit into more lines; row_gap spaces
# the lines and justify places each line.
scene.segment("wrap")
WORDS = ["layout", "boxes", "rows", "grids", "zones", "gap", "wrap", "align", "justify", "grow"]
small = chip.but(font_size="64px", padding=("4px", "16px"), min_width=0)
table([
    ("wrap=True", L.row(*[L.box(word, style=small) for word in WORDS], style=lane, wrap=True)),
    ('wrap=True, row_gap="32px"', L.row(*[L.box(word, style=small) for word in WORDS], style=lane, wrap=True,
                                        row_gap="32px")),
    ('wrap=True, justify="center"', L.row(*[L.box(word, style=small) for word in WORDS], style=lane, wrap=True,
                                          justify="center")),
], SPLIT)

# grow shares the free space by weight; width="fill" is grow=1.
scene.segment("grow")
SPLIT = ("4fr", "5fr")
lane = lane_for(SPLIT)
table([
    ("item(grow=1)", L.row(*chips("hug"), marked("grow=1", grow=1), *chips("hug"), style=lane)),
    ("item(grow=1), item(grow=2)", L.row(marked("grow=1", grow=1), marked("grow=2", grow=2), style=lane)),
    ('width="fill"', L.row(*chips("hug"), L.box("fill", style=chip, width="fill"), style=lane)),
], SPLIT)

# basis is the size before growing or shrinking: a length or a share of
# the line. When the line is too full, children shrink, except those with
# shrink=0.
scene.segment("basis")
table([
    ('item(basis="320px")', L.row(marked("320px", basis="320px"), *chips("hug", "hug"), style=lane)),
    ('item(basis="40%")', L.row(marked("40%", basis="40%"), *chips("hug", "hug"), style=lane)),
    ('item(basis="400px")', L.row(*[marked("400px", basis="400px") for _ in range(3)], style=lane)),
    ('item(basis="400px", shrink=0)', L.row(marked("shrink=0", basis="400px", shrink=0),
                                            *[marked("400px", basis="400px") for _ in range(2)], style=lane)),
], SPLIT)

# A child can align itself, take margins ("auto" takes the free space), or
# leave the flow with absolute=True and sit by an anchor.
scene.segment("items")
SPLIT = ("9fr", "7fr")
lane = lane_for(SPLIT)
table([
    ('item(align_self="end")', L.row(*chips("1"), marked("end", align_self="end"), *chips("3"),
                                     style=lane, height="184px", align="start")),
    ('item(margin="32px")', L.row(*chips("1"), marked("32px", margin="32px"), *chips("3"), style=lane)),
    ('item(margin=(0, 0, 0, "auto"))', L.row(*chips("1", "2"), marked("auto", margin=(0, 0, 0, "auto")), style=lane)),
    ('item(margin=(0, "auto"))', L.row(*chips("1"), marked("auto", margin=(0, "auto")), *chips("3"), style=lane)),
    ('item(absolute=True, anchor="right")', L.row(*chips("1", "2", "3"), marked("badge", absolute=True, anchor="right"),
                                                  style=lane)),
], SPLIT)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Grids

Grid tracks in px, %, fr, auto and scene units, gaps, spans, explicit cells, column flow and alignment in cells.

Grids
View on GitHubgaanim examples/layout/grids.py
Code · 125 lines
"""Grids: track sizes, gaps, spans, placement, flow and alignment in cells.

``L.grid`` places its children in cells. ``columns`` and ``rows`` take a
count or a list of tracks (lengths, percentages, ``"auto"`` and ``"Nfr"``
shares of the free space); each child fills the next cell, or the cells it
asks for with ``item()``. Every grid below is the same box with one rule
changed, and the label is that rule.
"""

import os

from gaanim import BoxStyle, Scene, Zones, stagger

scene = Scene()
L = scene.layout
accent = scene.canvas.color("accent")
header = scene.canvas.color("header")
panel = scene.canvas.color("panel")
rule = scene.canvas.color("rule")
dark = scene.canvas.color("background")
seeks = []

cell = BoxStyle(padding=("4px", "16px"), background=header, border=rule, align="center", justify="center",
                font_size="56px")
hot = cell.but(background=accent, color=dark)


def cells(*names, style=cell):
    """One cell per name."""
    return [L.box(name, style=style) for name in names]


def numbered(count, start=1, style=cell):
    """``count`` cells numbered from ``start``, so the fill order shows."""
    return cells(*[str(number) for number in range(start, start + count)], style=style)


def columns(count, split):
    """The frame cut into ``count`` rows of (label zone, example zone)."""
    grid = L.zones(Zones.grid(rows=count, columns=list(split), column_gap="48px", padding="page_padding"))
    return [(grid[2 * index], grid[2 * index + 1]) for index in range(count)]


def board_for(split):
    """The grid style, as wide as the example column of a table with ``split``."""
    width = columns(1, split)[0][1].width
    return BoxStyle(width=width, padding="8px", gap="8px", background=panel, border=rule)


def table(variants, split):
    """One row per (label, box): the code on the left, the box it builds on the right."""
    entrances = []
    for (label_zone, zone), (label, box) in zip(columns(len(variants), split), variants):
        code = scene.text(label, role="code").place(label_zone, anchor="right")
        box.place(zone, anchor="left")
        entrances += [code.animate.fade_in().duration(0.4), box.animate.fade_in().duration(0.4)]
    start = scene.cursor
    scene.play(stagger(*entrances, each=0.04))
    scene.wait(1.0)
    # Mid-entrance and settled.
    seeks.extend([start + 0.3, scene.cursor - 0.1])


# Tracks: a count of equal columns, fixed lengths, shares of the free space
# ("1fr", "2fr"), percentages of the grid, "auto" (the content) or numbers
# in scene units. Each cell shows its own track.
scene.segment("tracks")
SPLIT = ("1fr", "1fr")
board = board_for(SPLIT).but(height="144px")
table([
    ("columns=3", L.grid(*cells("1fr", "1fr", "1fr"), style=board, columns=3)),
    ('columns=["240px", "1fr", "2fr"]', L.grid(*cells("240px", "1fr", "2fr"), style=board,
                                               columns=["240px", "1fr", "2fr"])),
    ('columns=["25%", "1fr", "25%"]', L.grid(*cells("25%", "1fr", "25%"), style=board,
                                             columns=["25%", "1fr", "25%"])),
    ('columns=["auto", "1fr", "auto"]', L.grid(*cells("auto", "1fr", "wider auto"), style=board,
                                               columns=["auto", "1fr", "auto"])),
    ('columns=[2.5, "1fr", "1fr"]', L.grid(*cells("2.5", "1fr", "1fr"), style=board, columns=[2.5, "1fr", "1fr"])),
], SPLIT)

# Rows take the same tracks; gap separates both directions, or row_gap and
# column_gap one each.
scene.segment("rows_and_gaps")
board = board_for(SPLIT)
table([
    ('rows=["80px", "128px"]', L.grid(*numbered(6), style=board, columns=3, rows=["80px", "128px"])),
    ('gap="2px"', L.grid(*numbered(6), style=board, columns=3, gap="2px")),
    ('gap="32px"', L.grid(*numbered(6), style=board, columns=3, gap="32px")),
    ('row_gap="32px", column_gap="2px"', L.grid(*numbered(6), style=board, columns=3, row_gap="32px",
                                                column_gap="2px")),
], SPLIT)

# A child can span several tracks or ask for a cell (counted from 0); the
# rest flow around it, by rows or, with auto_flow="column", by columns.
scene.segment("placement")
table([
    ("item(column_span=2)", L.grid(L.box("1", style=hot).item(column_span=2), *numbered(4, start=2),
                                   style=board, columns=3)),
    ("item(row_span=2)", L.grid(L.box("1", style=hot).item(row_span=2), *numbered(4, start=2),
                                style=board, columns=3)),
    ("item(row=1, column=2)", L.grid(L.box("1", style=hot).item(row=1, column=2), *numbered(4, start=2),
                                     style=board, columns=3)),
    ('auto_flow="column"', L.grid(*numbered(6), style=board, columns=3, rows=2, auto_flow="column")),
], SPLIT)

# Cells stretch their children by default. align places them vertically in
# their cells, and a child's own align_self and anchor override it.
scene.segment("cells")
SPLIT = ("9fr", "7fr")
board = board_for(SPLIT)
wide, wide_hot = cell.but(min_width="144px"), hot.but(min_width="144px")
table([
    (f'align="{align}"', L.grid(*numbered(4, style=wide), style=board, columns=4, rows=["160px"], align=align))
    for align in ("stretch", "start", "center", "end")
] + [
    ('item(align_self="end", anchor="right")',
     L.grid(*numbered(3, style=wide), L.box("4", style=wide_hot).item(align_self="end", anchor="right"),
            style=board, columns=4, rows=["160px"])),
], SPLIT)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Stacks

Stacked layers placed by the nine anchors and offsets, every fit mode, and draw order with z_index.

Stacks
View on GitHubgaanim examples/layout/stacks.py
Code · 123 lines
"""Stacks: layers that share one area, placed by anchor and offset or fitted to it.

``L.stack`` overlaps its children in the same box. Each child sits by its
``anchor`` (the center by default), moved by an ``offset``, or scales to the
whole area with ``fit``; later children draw on top unless ``z_index`` says
otherwise. Each label is the code that places the layer beside it.
"""

import os

from gaanim import BoxStyle, Scene, stagger

scene = Scene()
L = scene.layout
accent = scene.canvas.color("accent")
panel = scene.canvas.color("panel")
rule = scene.canvas.color("rule")
dark = scene.canvas.color("background")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
seeks = []

badge = BoxStyle(padding=("4px", "16px"), background=accent, color=dark)
stage = BoxStyle(width="480px", height="150px", background=panel, border=rule)


def code(label):
    return scene.text(label, role="code")


def show(*objects):
    """Fade the objects in one after another, hold, and note the seeks."""
    start = scene.cursor
    scene.play(stagger(*[item.animate.fade_in().duration(0.4) for item in objects], each=0.04))
    scene.wait(1.0)
    # Mid-entrance and settled.
    seeks.extend([start + 0.3, scene.cursor - 0.1])


def table(rows, header=None):
    """Lay out rows of (label, *boxes) as one grid: the code on the left, the boxes to its right.

    ``header`` labels the box columns. The grid hugs its content and sits in
    the middle of the frame.
    """
    cells = []
    if header:
        cells += [L.box()] + [code(label).item(anchor="left") for label in header]
    for label, *boxes in rows:
        cells += [code(label).item(anchor="right")] + [box.item(anchor="left") for box in boxes]
    L.grid(*cells, columns=["auto"] * len(rows[0]), row_gap="24px", column_gap="48px", align="center")
    show(*cells)


# A layer sits by its anchor, one of nine points of the stack; each chip
# here is labelled with its own anchor.
scene.segment("anchors")
ANCHORS = ["top_left", "top", "top_right", "left", "center", "right", "bottom_left", "bottom", "bottom_right"]
layers = L.stack(
    *[L.box(f'anchor="{anchor}"', style=badge, role="code").item(anchor=anchor) for anchor in ANCHORS],
    background=panel, border=rule, width="fill", height="fill", padding="24px",
)
L.box(layers, within="frame", width="fill", height="fill", padding="page_padding")
show(layers)

# offset moves a layer from its anchor, x to the right and y up.
scene.segment("offsets")
table([
    ('item(anchor="top_right")', L.stack(L.box("badge", style=badge).item(anchor="top_right"), style=stage)),
    ('item(anchor="top_right", offset=(-0.2, -0.2))',
     L.stack(L.box("badge", style=badge).item(anchor="top_right", offset=(-0.2, -0.2)), style=stage)),
    ('item(anchor="bottom_left", offset=(0.2, 0.2))',
     L.stack(L.box("badge", style=badge).item(anchor="bottom_left", offset=(0.2, 0.2)), style=stage)),
    ('item(offset=("160px", 0))', L.stack(L.box("badge", style=badge).item(offset=("160px", 0)), style=stage)),
])

# fit scales a layer to the stack: "contain" fits it whole, "cover" fills
# the area and crops the rest, "stretch" fills it out of proportion and
# "scale_down" only ever shrinks. "none" keeps the layer's own size, and
# clip=True cuts what leaves the stack.
scene.segment("fit")
frame = BoxStyle(width="420px", height="150px", border=rule, clip=True)
table(
    [
        (f'item(fit="{fit}")', *[L.stack(scene.media.svg("assets/character.svg").scale_to(scale).item(fit=fit),
                                         style=frame) for scale in (1.0, 0.25)])
        for fit in ("none", "contain", "cover", "stretch", "scale_down")
    ],
    header=["svg", "svg.scale_to(0.25)"],
)

# Later layers draw on top; z_index lifts one above the others. A cover
# image, a caption and a badge make a card.
scene.segment("layers")
SQUARES = [(accent, (-0.25, 0.12)), (success, (0, 0)), (danger, (0.25, -0.12))]


def squares(lift=None):
    """Three overlapping squares, the one at index ``lift`` given z_index(1)."""
    layers = []
    for index, (color, offset) in enumerate(SQUARES):
        square = scene.geometry.square(0.8).fill(color).item(offset=offset)
        layers.append(square.z_index(1) if index == lift else square)
    return L.stack(*layers, style=stage)


table([
    ("L.stack(a, b, c)", squares()),
    ("L.stack(a.z_index(1), b, c)", squares(lift=0)),
    ('L.stack(svg.item(fit="cover"), caption, badge)', L.stack(
        scene.media.svg("assets/character.svg").item(fit="cover"),
        L.box("Character", background=dark, padding=("2px", "12px")).item(anchor="bottom_left",
                                                                          offset=("12px", "12px")),
        L.box("New", style=badge, radius="full").item(anchor="top_right", offset=("-12px", "-12px")),
        style=stage, height="300px", radius="16px", clip=True,
    )),
])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Zones

Zones templates, nesting and inset, place() with anchor, padding, offset and fit, arrange(), fill() and animated moves between zones.

Zones
View on GitHubgaanim examples/layout/zones.py
Code · 209 lines
"""Zones: templates that divide a region into rectangles to place free objects.

A ``Zones`` template cuts a region into rows, columns or a grid, with gaps,
padding and names. Applied to the frame, a zone or an object's box it gives
a ``ZoneSet``; objects placed in its zones stay free, so they can move to
other zones later. Each label is the code that builds what it describes.
"""

import os

from gaanim import Scene, Zones, stagger

scene = Scene()
L = scene.layout
accent = scene.canvas.color("accent")
header = scene.canvas.color("header")
rule = scene.canvas.color("rule")
seeks = []


def code(label):
    return scene.text(label, role="code", text_align="left")


def outline(zone):
    """A rule-colored frame drawn around ``zone``, to make it visible."""
    return scene.geometry.rect(zone.width, zone.height).no_fill().stroke(rule, 0.02).place(zone)


def tile(zone):
    """A filled rectangle that covers ``zone`` exactly."""
    return scene.geometry.rect(zone.width, zone.height).fill(header).stroke(rule, 0.02).place(zone)


def show(*objects):
    """Fade the objects in one after another, hold, and note the seeks."""
    start = scene.cursor
    scene.play(stagger(*[item.animate.fade_in().duration(0.4) for item in objects], each=0.03))
    scene.wait(1.0)
    # Mid-entrance and settled.
    seeks.extend([start + 0.3, scene.cursor - 0.1])


def rows(count, split=("1fr", "1fr")):
    """The frame cut into ``count`` rows of (label zone, example zone)."""
    grid = L.zones(Zones.grid(rows=count, columns=list(split), column_gap="48px", padding="page_padding"))
    return [(grid[2 * index], grid[2 * index + 1].inset(("8px", 0))) for index in range(count)]


# Templates cut a region into rows, columns or a grid of tracks (lengths,
# percentages and "fr" shares), with gaps, padding and names. L.zones()
# applies one to the safe area, or within="frame", a zone or an object's
# box; each preview here is L.zones(template, within=zone).
def previews(templates):
    """One row per (label, template): the code on the left, its zones on the right."""
    shown = []
    for (label_zone, region), (label, template) in zip(rows(len(templates)), templates):
        region = region.inset(("16px", 0))
        shown += [code(label).place(label_zone, anchor="right"), outline(region)]
        zones = L.zones(template, within=region)
        shown += [tile(zone) for zone in zones]
        # A named template's zones are found by name too: zones["main"].
        shown += [code(name).place(zones[name]) for name in zones.names]
    show(*shown)


scene.segment("templates")
previews([
    ("Zones.rows(3)", Zones.rows(3)),
    ('Zones.columns(\n    ["1fr", "2fr", "1fr"])', Zones.columns(["1fr", "2fr", "1fr"])),
    ('Zones.columns(4, gap="24px")', Zones.columns(4, gap="24px")),
    ('Zones.columns(4, padding="16px")', Zones.columns(4, padding="16px")),
])

scene.segment("grid_templates")
previews([
    ("Zones.grid(rows=2, columns=6)", Zones.grid(rows=2, columns=6)),
    ('Zones.grid(rows=2, columns=6,\n           row_gap="16px")', Zones.grid(rows=2, columns=6, row_gap="16px")),
    ('Zones.columns(2,\n    names=["side", "main"])', Zones.columns(2, names=["side", "main"])),
])

# A ZoneSet is indexed by name or position, and every zone splits again
# with another template or shrinks with inset(). Each zone below is filled
# and labelled with the code that finds it.
scene.segment("nesting")
page = L.zones(Zones.rows(["120px", "1fr", "120px"], names=["header", "body", "footer"], gap="16px",
                          padding="page_padding"))
body = page["body"].split(Zones.columns(["1fr", "3fr"], names=["side", "main"], gap="16px"))
cells = body["main"].split(Zones.grid(rows=2, columns=2, gap="16px"))
labelled = [(page["header"], 'page["header"]'), (page["footer"], 'page["footer"]'),
            (body["side"], 'body["side"]'), *[(cell, f"cells[{index}]") for index, cell in enumerate(cells[:3])]]
shown = []
for zone, label in labelled:
    shown += [tile(zone), code(label).place(zone)]
inner = cells[3].inset("32px")
shown += [outline(cells[3]), tile(inner), code('cells[3].inset("32px")').place(inner)]
show(*shown)

# place() puts an object in a zone, or on another object's box, by its
# anchor; padding insets the target and offset moves the object.
def placed(variants):
    """One row per (label, place): the code on the left; ``place(zone)`` draws in the outlined zone."""
    shown = []
    for (label_zone, zone), (label, place) in zip(rows(len(variants), split=("2fr", "1fr")), variants):
        zone = zone.inset(("16px", 0))
        shown += [code(label).place(label_zone, anchor="right"), outline(zone), *place(zone)]
    show(*shown)


def disc():
    return scene.geometry.circle(0.4)


scene.segment("place")
placed([
    ("place(zone)", lambda zone: [disc().place(zone)]),
    ('place(zone, anchor="top_left")', lambda zone: [disc().place(zone, anchor="top_left")]),
    ('place(zone, anchor="right", padding="24px")', lambda zone: [disc().place(zone, anchor="right", padding="24px")]),
    ('place(zone, anchor="left", offset=("160px", 0))',
     lambda zone: [disc().place(zone, anchor="left", offset=("160px", 0))]),
])


def on_card(zone):
    card = scene.geometry.rounded_rect(zone.width * 0.6, zone.height * 0.6, 0.12).fill(header).stroke(rule, 0.02)
    card.place(zone)
    return [card, scene.geometry.circle(0.25).place(card, anchor="top_right")]


# fit scales the object to the target; point() gives a zone's points in
# scene units.
scene.segment("place_fit")
placed([
    ('place(zone, fit="contain")', lambda zone: [disc().place(zone, fit="contain")]),
    ('place(zone, fit="stretch")', lambda zone: [disc().place(zone, fit="stretch")]),
    ('move_to(*zone.point("bottom_right"))', lambda zone: [disc().move_to(*zone.point("bottom_right"))]),
    ('place(card, anchor="top_right")', on_card),
])


# arrange() lines free objects up in a zone once, without owning them;
# fill() puts one object in each zone of a set.
def arranged(variants):
    """One row per (label, options): four dots arranged in the outlined zone with ``options``."""
    shown = []
    for (label_zone, zone), (label, options) in zip(rows(len(variants), split=("5fr", "2fr")), variants):
        zone = zone.inset(("24px", 0))
        if options is None:
            items = [scene.geometry.square(0.6) for _ in range(4)]
            quarters = zone.split(Zones.columns(4))
            quarters.fill(*items)
            frames = [outline(quarter) for quarter in quarters]
        else:
            items = [scene.geometry.circle(0.2) for _ in range(4)]
            zone.arrange(*items, **options)
            frames = [outline(zone)]
        shown += [code(label).place(label_zone, anchor="right"), *frames, *items]
    show(*shown)


scene.segment("arrange")
arranged([
    ("zone.arrange(*dots)", {}),
    ('zone.arrange(*dots, gap="48px")', {"gap": "48px"}),
    ('zone.arrange(*dots, justify="between")', {"justify": "between"}),
])

scene.segment("arrange_more")
arranged([
    ('zone.arrange(*dots, justify="end", align="end")', {"justify": "end", "align": "end"}),
    ('zone.arrange(*dots, direction="column")', {"direction": "column"}),
    ("zone.split(Zones.columns(4)).fill(*squares)", None),
])

# Placed objects stay free: animate.place() moves one to another zone, with
# the same anchor, padding, offset and fit options.
scene.segment("moves")
page = L.zones(Zones.rows(["1fr", "96px"], names=["stage", "caption"], gap="24px", padding="page_padding"))
grid = page["stage"].split(Zones.grid(rows=2, columns=3, gap="24px"))
frames = [outline(zone) for zone in grid]
names = [code(f"grid[{index}]").place(zone, anchor="top_left", padding="12px") for index, zone in enumerate(grid)]
ball = scene.geometry.circle(0.5).place(grid[3])
scene.play(stagger(*[item.animate.fade_in().duration(0.4) for item in [*frames, *names, ball]], each=0.03))
MOVES = [
    ('place(grid[0], anchor="top_right")', grid[0], {"anchor": "top_right"}),
    ('place(grid[1], fit="contain", padding="32px")', grid[1], {"fit": "contain", "padding": "32px"}),
    ('place(grid[2], anchor="bottom_left", padding="32px")', grid[2], {"anchor": "bottom_left", "padding": "32px"}),
    ('place(grid[5], fit="contain", padding="96px")', grid[5], {"fit": "contain", "padding": "96px"}),
    ('place(grid[4], fit="stretch", padding="32px")', grid[4], {"fit": "stretch", "padding": "32px"}),
]
caption = None
for label, zone, options in MOVES:
    # Each move's code replaces the previous one under the grid.
    line = code(f"ball.animate.{label}").place(page["caption"]).hidden()
    moves = [ball.animate.place(zone, **options).duration(0.8), line.animate.fade_in().duration(0.3)]
    if caption is not None:
        moves.append(caption.animate.fade_out().duration(0.3))
    scene.play(moves)
    scene.wait(0.3)
    # Every move settled, and the first one midway.
    seeks.extend([scene.cursor - 0.1] if caption else [scene.cursor - 0.7, scene.cursor - 0.1])
    caption = line
scene.wait(0.5)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Positioning

Free objects placed with to_edge, to_corner, next_to, align_to, move_to and linear layout constraints with priorities.

Positioning
View on GitHubgaanim examples/layout/positioning.py
Code · 212 lines
"""Relative positioning: frame edges, next_to, align_to, move_to and constraints.

Free objects (outside any box) are placed by relations: against an edge or a
corner of the frame, beside another object, aligned with one of its anchors,
with one of their own anchors on a point, or by linear constraints between
edges, centers and sizes. In the first two segments each label is placed by
the code it shows; in the others it is the code that places the accent shape
beside it.
"""

import os

from gaanim import Anchor, Direction, Scene, Zones, stagger

scene = Scene()
L = scene.layout
header = scene.canvas.color("header")
rule = scene.canvas.color("rule")
foreground = scene.canvas.color("foreground")
seeks = []


def code(label):
    return scene.text(label, role="code", text_align="left")


def show(*objects):
    """Fade the objects in one after another, hold, and note the seeks."""
    start = scene.cursor
    scene.play(stagger(*[item.animate.fade_in().duration(0.4) for item in objects], each=0.04))
    scene.wait(1.0)
    # Mid-entrance and settled.
    seeks.extend([start + 0.3, scene.cursor - 0.1])


def rows(count, split=("1fr", "1fr")):
    """The frame cut into ``count`` rows of (label zone, example zone)."""
    grid = L.zones(Zones.grid(rows=count, columns=list(split), column_gap="48px", padding="page_padding"))
    return [(grid[2 * index], grid[2 * index + 1]) for index in range(count)]


def reference(zone, width=4.4, height=0.8, anchor="center"):
    """The object the others are placed against, in ``zone`` by ``anchor``."""
    return scene.geometry.rect(width, height).fill(header).stroke(rule, 0.02).place(zone, anchor=anchor)


def chip(label):
    """A box holding ``label``: the object that the label's code moves."""
    return L.box(label, role="code", font_size="44px", padding=("12px", "20px"), background=header, border=rule)


def table(variants, place):
    """One row per (label, args): the code on the left, ``place(zone, args)``'s objects on the right."""
    shown = []
    for (label_zone, zone), (label, args) in zip(rows(len(variants)), variants):
        shown += [code(label).place(label_zone, anchor="right"), *place(zone, args)]
    show(*shown)


# to_edge() and to_corner() push an object against the frame, buff away
# from it (0.24 by default). Each box here is moved by the code it holds.
scene.segment("edges")
show(
    chip("to_edge(Direction.UP)").to_edge(Direction.UP),
    chip("to_edge(Direction.LEFT)").to_edge(Direction.LEFT),
    chip("move_to(0, 0)").move_to(0, 0),
    chip("to_edge(Direction.RIGHT)").to_edge(Direction.RIGHT),
    chip("to_edge(Direction.DOWN, buff=1.2)").to_edge(Direction.DOWN, buff=1.2),
)

scene.segment("corners")
show(
    chip("to_corner(Anchor.TOP_LEFT)").to_corner(Anchor.TOP_LEFT),
    chip("to_corner(Anchor.TOP_RIGHT)").to_corner(Anchor.TOP_RIGHT),
    chip("to_corner(Anchor.TOP_LEFT, buff=1.2)").to_corner(Anchor.TOP_LEFT, buff=1.2),
    chip("to_corner(Anchor.BOTTOM_LEFT)").to_corner(Anchor.BOTTOM_LEFT),
    chip("to_corner(Anchor.BOTTOM_RIGHT)").to_corner(Anchor.BOTTOM_RIGHT),
)


def beside(zone, args):
    box = reference(zone)
    return [box, scene.geometry.square(0.6).next_to(box, *args)]


# next_to() puts an object beside another, spacing away (0.24 by default)
# in a direction; aligned_edge lines up one of their edges across it.
scene.segment("next_to")
table([
    ("next_to(box, Direction.RIGHT)", (Direction.RIGHT,)),
    ("next_to(box, Direction.LEFT, 0.8)", (Direction.LEFT, 0.8)),
    ("next_to(box, Direction.UP)", (Direction.UP,)),
], beside)

scene.segment("next_to_aligned")
table([
    ("next_to(box, Direction.DOWN,\n        0.24, Anchor.LEFT)", (Direction.DOWN, 0.24, Anchor.LEFT)),
    ("next_to(box, Direction.DOWN,\n        0.24, Anchor.RIGHT)", (Direction.DOWN, 0.24, Anchor.RIGHT)),
    ("next_to(box, Direction.UP_RIGHT)", (Direction.UP_RIGHT,)),
], beside)


def aligned(zone, anchors):
    box = reference(zone)
    return [box, scene.geometry.square(0.6).align_to(box, *anchors)]


# align_to() moves an object so that one of its anchors meets an anchor of
# another: the same one, or the second anchor given.
scene.segment("align_to")
table([
    ("align_to(box, Anchor.LEFT)", (Anchor.LEFT,)),
    ("align_to(box, Anchor.TOP_RIGHT)", (Anchor.TOP_RIGHT,)),
    ("align_to(box, Anchor.CENTER)", (Anchor.CENTER,)),
], aligned)

scene.segment("align_to_anchor")
table([
    ("align_to(box, Anchor.BOTTOM,\n         Anchor.TOP)", (Anchor.BOTTOM, Anchor.TOP)),
    ("align_to(box, Anchor.RIGHT,\n         Anchor.LEFT)", (Anchor.RIGHT, Anchor.LEFT)),
    ("align_to(box, Anchor.TOP_LEFT,\n         Anchor.BOTTOM_RIGHT)", (Anchor.TOP_LEFT, Anchor.BOTTOM_RIGHT)),
], aligned)


def on_point(zone, anchor):
    # The dot marks the point (x, y): the middle of the zone.
    x, y = zone.center
    square = scene.geometry.square(0.6)
    square = square.move_to(x, y) if anchor is None else square.move_to(x, y, anchor)
    return [square, scene.geometry.circle(0.08).fill(foreground).move_to(x, y)]


# move_to() puts an anchor of the object (its center by default) on a
# point, on another object's center or on a point that follows another
# object. bounds() measures an object where it ends up.
scene.segment("move_to")
table([
    ("move_to(x, y)", None),
    ("move_to(x, y, Anchor.TOP_LEFT)", Anchor.TOP_LEFT),
    ("move_to(x, y,\n        Anchor.BOTTOM_RIGHT)", Anchor.BOTTOM_RIGHT),
], on_point)

scene.segment("move_to_objects")
lanes = rows(3)
box = reference(lanes[0][1])
shown = [code("move_to(box)").place(lanes[0][0], anchor="right"), box, scene.geometry.square(0.6).move_to(box)]
box = reference(lanes[1][1])
shown += [code("move_to(box.anchor_point(\n    Anchor.TOP_RIGHT))").place(lanes[1][0], anchor="right"), box,
          scene.geometry.square(0.6).move_to(box.anchor_point(Anchor.TOP_RIGHT))]
word = scene.text("measured", size=1.0).place(lanes[2][1])
b = word.bounds()
shown += [code("rect(b.width, b.height)\n    .move_to(*b.center)").place(lanes[2][0], anchor="right"), word,
          scene.geometry.rect(b.width, b.height).no_fill().stroke(rule, 0.03).move_to(*b.center)]
show(*shown)

# constrain() solves linear relations between the left, right, top, bottom,
# center_x, center_y, width and height of objects, even objects in
# different boxes. Relations are required unless strong(), medium() or
# weak(); a relaxed weak one is reported by check_layout() under the
# label that named() gives it.
scene.segment("constrain")
SPLIT = ("5fr", "2fr")
lanes, shown = iter(rows(3, SPLIT)), []


def lane(label):
    """Place ``label`` in the next row and return the zone beside it."""
    label_zone, zone = next(lanes)
    shown.append(code(label).place(label_zone, anchor="right"))
    return zone


zone = lane("L.constrain(sq.left == box.right + 0.2,\n            sq.center_y == box.center_y)")
box, sq = reference(zone, 2.4), scene.geometry.square(0.6)
L.constrain(sq.left == box.right + 0.2, sq.center_y == box.center_y)
shown += [box, sq]

zone = lane("L.constrain(sq.center_x == box.center_x,\n            sq.bottom == box.top)")
box, sq = reference(zone, 2.4), scene.geometry.square(0.6)
L.constrain(sq.center_x == box.center_x, sq.bottom == box.top)
shown += [box, sq]

zone = lane("L.constrain(\n    sq.center_x == (a.right + b.left) / 2,\n    sq.center_y == a.center_y)")
a, b = reference(zone, 1.4, anchor="left"), reference(zone, 1.4, anchor="right")
sq = scene.geometry.square(0.6)
L.constrain(sq.center_x == (a.right + b.left) / 2, sq.center_y == a.center_y)
shown += [a, b, sq]
show(*shown)

# Sizes are expressions too, and when relations conflict the stronger one
# wins. Only strong and required relations resize an object.
scene.segment("constrain_sizes")
lanes, shown = iter(rows(2, SPLIT)), []

zone = lane("L.constrain(bar.left == box.left,\n            bar.center_y == box.center_y,\n"
            "            bar.width == box.width * 0.6)")
box, bar = reference(zone, 3.0), scene.geometry.rect(1, 0.4)
L.constrain(bar.left == box.left, bar.center_y == box.center_y, bar.width == box.width * 0.6)
shown += [box, bar]

zone = lane("L.constrain(sq.bottom == box.top,\n    (sq.center_x == box.left).weak(),\n"
            "    (sq.center_x == box.right).strong())")
box, sq = reference(zone, 2.0), scene.geometry.square(0.6)
L.constrain(sq.bottom == box.top, (sq.center_x == box.left).weak(), (sq.center_x == box.right).strong())
shown += [box, sq]
show(*shown)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Reflow

Animated structure changes: add, remove, replace, set, item, move_child, swap, reverse, detach, moves between boxes and reflow after a scale.

Reflow
View on GitHubgaanim examples/layout/reflow.py
Code · 132 lines
"""Animated structure: boxes that gain, lose, reorder and resize their children.

Every structural change accepts ``duration=``: the children slide to their
new places, what enters fades in and what leaves fades out, and the change
advances the cursor like ``scene.play``. Each step shows its code under the
box, then makes the change.
"""

import os

from gaanim import BoxStyle, Scene, Zones

scene = Scene()
L = scene.layout
accent = scene.canvas.color("accent")
header = scene.canvas.color("header")
panel = scene.canvas.color("panel")
rule = scene.canvas.color("rule")
dark = scene.canvas.color("background")
seeks = []

# The boxes sit in the middle of the frame; the code of each step goes in a
# band along the bottom.
caption_zone = L.zones(Zones.rows(["1fr", "120px"], padding="page_padding"))[1]
card_style = BoxStyle(padding=("20px", "40px"), background=header, border=rule, radius="16px", min_width="280px",
                      align="center", justify="center", font_size="80px")
new_style = card_style.but(background=accent, color=dark)


def card(name, new=False):
    return L.box(name, style=new_style if new else card_style)


def steps(box, changes):
    """Fade ``box`` in, then for each (label, change) show the label under it and run the change.

    A label of two lines is two statements that ``change`` runs in order.
    """
    scene.play([box.animate.fade_in().duration(0.5)])
    caption = None
    for index, (label, change) in enumerate(changes):
        line = scene.text(label, role="code").place(caption_zone).hidden()
        fades = [line.animate.fade_in().duration(0.3)]
        if caption is not None:
            fades.append(caption.animate.fade_out().duration(0.3))
        scene.play(fades)
        start = scene.cursor
        change()
        # The first change midway, then every change settled.
        if index == 0:
            seeks.append((start + scene.cursor) / 2)
        scene.wait(0.5)
        seeks.append(scene.cursor - 0.1)
        caption = line


# add() inserts a child (at the end or at an index), remove() takes one out
# and replace() puts another in its place.
scene.segment("children")
board = L.row(card("One"), card("Two"), card("Three"), gap="32px")
steps(board, [
    ('board.add(card("Four", new=True), duration=0.6)', lambda: board.add(card("Four", new=True), duration=0.6)),
    ('board.add(card("Zero", new=True), at=0, duration=0.6)',
     lambda: board.add(card("Zero", new=True), at=0, duration=0.6)),
    ("board.remove(board[2], duration=0.6)", lambda: board.remove(board[2], duration=0.6)),
    ('board.replace(board[0], card("New", new=True), duration=0.6)',
     lambda: board.replace(board[0], card("New", new=True), duration=0.6)),
])

# set() changes any box property and item() any rule of a child; with
# advance=False the next change starts at the same time.
scene.segment("properties")
grid = L.grid(*[card(name) for name in "ABCDEF"], columns=3, gap="24px", padding="16px", background=panel,
              border=rule)
steps(grid, [
    ("grid.set(columns=2, duration=0.8)", lambda: grid.set(columns=2, duration=0.8)),
    ('grid.set(gap="32px", duration=0.8)', lambda: grid.set(gap="32px", duration=0.8)),
    ("grid[0].item(column_span=2, duration=0.8)", lambda: grid[0].item(column_span=2, duration=0.8)),
    ('grid.set(columns=3, gap="24px", duration=0.8, advance=False)\ngrid[0].item(row_span=2, duration=0.8)',
     lambda: (grid.set(columns=3, gap="24px", duration=0.8, advance=False), grid[0].item(row_span=2, duration=0.8))),
])

# move_child(), swap() and reverse() reorder the children, which slide to
# their new places.
scene.segment("order")
row = L.row(*[card(str(number)) for number in range(1, 6)], gap="32px")
steps(row, [
    ("row.swap(row[0], row[4], duration=0.6)", lambda: row.swap(row[0], row[4], duration=0.6)),
    ("row.move_child(row[0], 2, duration=0.6)", lambda: row.move_child(row[0], 2, duration=0.6)),
    ("row.reverse(duration=0.6)", lambda: row.reverse(duration=0.6)),
])

# A child still animates like any object: it starts where the box put it,
# does not push its siblings and keeps its offset when the box reflows.
# detach() lets a child go without hiding it, free to move on its own.
scene.segment("free")
three = card("Three")
row = L.row(card("One"), card("Two"), three, gap="32px")
steps(row, [
    ("scene.play([row[1].animate.shift_by(0, 1.0)])", lambda: scene.play([row[1].animate.shift_by(0, 1.0)])),
    ('row.add(card("Four", new=True), at=0, duration=0.6)',
     lambda: row.add(card("Four", new=True), at=0, duration=0.6)),
    ("row.detach(three, duration=0.6)", lambda: row.detach(three, duration=0.6)),
    ("scene.play([three.animate.shift_by(0, -1.8)])", lambda: scene.play([three.animate.shift_by(0, -1.8)])),
])

# A child moves to another box: detach() takes it out where it stands, and
# the other box's add() slides it into its new place while the first box
# closes the gap.
scene.segment("between")
moving = card("Two")
top = L.row(card("One"), moving, card("Three"), gap="32px")
bottom = L.row(card("Four"), gap="32px")
steps(L.column(top, bottom, gap="96px", align="center"), [
    ("top.detach(moving, duration=0.6, advance=False)\nbottom.add(moving, duration=0.6)",
     lambda: (top.detach(moving, duration=0.6, advance=False), bottom.add(moving, duration=0.6))),
])

# A scaled child keeps its slot too, until reflow() measures it at its new
# size and the others make room.
scene.segment("scale")
row = L.row(card("One"), card("Two"), card("Three"), gap="32px")
steps(row, [
    ("scene.play([row[1].animate.scale_to(1.4)])", lambda: scene.play([row[1].animate.scale_to(1.4)])),
    ("row.reflow(duration=0.6)", lambda: row.reflow(duration=0.6)),
])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Box reveal

Revealing a box piece by piece with reveal, cascade and stagger, and changing pieces with find, find_all and each.

Box reveal
View on GitHubgaanim examples/layout/box_reveal.py
Code · 108 lines
"""Box pieces: reveal a box piece by piece, and find, restyle or animate its pieces.

A box's pieces are its backgrounds, texts, rules and other objects at any
depth, in draw order. ``reveal`` brings them in with the usual slide policy,
``cascade`` and ``stagger`` with any animation, and ``find``, ``find_all``
and ``each`` pick pieces to change. Every card is the same box, and each
label is the call that animates it.
"""

import os

from gaanim import Box, BoxStyle, Direction, Scene, Text, Zones, stagger

scene = Scene()
L = scene.layout
accent = scene.canvas.color("accent")
header = scene.canvas.color("header")
rule = scene.canvas.color("rule")
seeks = []

chip = BoxStyle(padding=("2px", "24px"), border=rule, radius="full", font_size="48px")


def columns(count, split):
    """The frame cut into ``count`` rows of (label zone, example zone)."""
    grid = L.zones(Zones.grid(rows=count, columns=list(split), column_gap="48px", padding="page_padding"))
    return [(grid[2 * index], grid[2 * index + 1]) for index in range(count)]


def card(width):
    """A title, an accent rule and a row of chips, on a rounded panel ``width`` wide."""
    return L.column(
        L.box("Title", font_size="64px", weight=700),
        L.box(height="6px", width="fill", background=accent),
        L.row(*[L.box(name, style=chip) for name in ("Boxes", "Rules", "Text")], gap="12px"),
        padding=("12px", "24px"), gap="12px", background=header, border=rule, radius="16px", width=width,
    )


def table(variants, split, visible=False):
    """Lay out one card per (label, animate) row and run every ``animate(card)`` at once.

    Each card is as wide as the example column. The labels fade in first;
    with ``visible`` the cards do too, so the animations start from a
    finished card.
    """
    labels, cards = [], []
    for (label_zone, zone), (label, _) in zip(columns(len(variants), split), variants):
        code = scene.text(label, role="code", text_align="left")
        labels.append(code.place(label_zone, anchor="right"))
        cards.append(card(zone.width).place(zone, anchor="left"))
    shown = labels + (cards if visible else [])
    scene.play(stagger(*[item.animate.fade_in().duration(0.4) for item in shown], each=0.05))
    start = scene.cursor
    animations = [animate(box) for box, (_, animate) in zip(cards, variants)]
    # each() changes the card at once and returns it: there is nothing to play.
    scene.play([item for item in animations if not isinstance(item, Box)])
    scene.wait(0.6)
    # Early, midway and settled.
    seeks.extend([start + 0.3, start + 0.7, scene.cursor - 0.1])


# reveal() fades backgrounds in, slides texts in from direction and grows
# rules (boxes with no children that draw) from rules_from.
scene.segment("reveal")
table([
    ("card.reveal()", lambda card: card.reveal()),
    ("card.reveal(direction=Direction.LEFT)", lambda card: card.reveal(direction=Direction.LEFT)),
    ('card.reveal(rules="fade", each=0.2)', lambda card: card.reveal(rules="fade", each=0.2)),
    ("card.reveal(rules_from=Direction.RIGHT)", lambda card: card.reveal(rules_from=Direction.RIGHT)),
], ("3fr", "2fr"))

# cascade() runs one animation of the .animate proxy on every piece, with
# the timing options of stagger; walk() lists the pieces for a stagger of
# your own.
scene.segment("cascade")
table([
    ("card.cascade().fade_in()", lambda card: card.cascade().fade_in()),
    ("card.cascade(each=0.2).grow_from_center()", lambda card: card.cascade(each=0.2).grow_from_center()),
    ("card.cascade().fade_in_from(Direction.UP)", lambda card: card.cascade().fade_in_from(Direction.UP)),
], ("5fr", "3fr"))

scene.segment("cascade_more")
table([
    ('card.cascade(origin="end").fade_in()', lambda card: card.cascade(origin="end").fade_in()),
    ("stagger(*[p.animate.fade_in()\n          for p in card.walk()])",
     lambda card: stagger(*[piece.animate.fade_in() for piece in card.walk()])),
], ("5fr", "3fr"))

# find() and find_all() pick pieces by type, text or a where= test, each()
# applies an immediate setter to the pieces that match, and stagger() makes
# one animation per piece (None skips it).
scene.segment("pieces")
table([
    ("card.each(lambda p: p.fill(accent), type=Text)",
     lambda card: card.each(lambda piece: piece.fill(accent), type=Text)),
    ('card.find(text="Title").animate.scale_by(1.3)', lambda card: card.find(text="Title").animate.scale_by(1.3)),
    ("card.stagger(lambda p: p.animate.indicate())", lambda card: card.stagger(lambda piece: piece.animate.indicate())),
    ("card.stagger(\n    lambda p: p.animate.indicate()\n    if isinstance(p, Box) else None,\n    boxes=True)",
     lambda card: card.stagger(lambda piece: piece.animate.indicate() if isinstance(piece, Box) else None,
                               boxes=True)),
], ("2fr", "1fr"), visible=True)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Scatter

Seeded scatter of formulas around a character's parts, with seeds, gaps, avoided zones and object regions.

Scatter
View on GitHubgaanim examples/layout/scatter.py
Code · 104 lines
"""Scatter: seeded, non-overlapping placement of free objects in a region.

``scene.layout.scatter`` (``L.scatter`` below) moves objects to random but repeatable places
inside a region (the safe area, the frame, a zone or an object's box),
at least ``gap`` apart and clear of every shape in ``avoid``. Larger objects
go first, each where it has the most room. Each label is the call that
scattered the objects under it.
"""

import os

from gaanim import Scene, Zones, stagger

scene = Scene()
L = scene.layout
header = scene.canvas.color("header")
rule = scene.canvas.color("rule")
danger = scene.canvas.color("danger")
seeks = []


def code(label):
    return scene.text(label, role="code")


def outline(zone, color=rule):
    """A frame drawn around ``zone``, to make it visible."""
    return scene.geometry.rect(zone.width, zone.height).no_fill().stroke(color, 0.02).place(zone)


# Formulas scattered around a character: avoiding its parts, rather than
# its whole box, lets them come close to its outline.
scene.segment("avoid")
page = L.zones(Zones.rows(["1fr", "96px"], names=["stage", "caption"], gap="16px", padding="page_padding"))
character = scene.media.svg("assets/character.svg").scale_to(2.2).place(page["stage"])
parts = [character.part(name) for name in ("head", "torso", "legs", "left-arm", "right-arm")]
FORMULAS = [
    "E = m c^2", "a^2 + b^2 = c^2", "e^(i pi) + 1 = 0", "F = m a", "pi r^2", "sqrt(2)",
    "integral_0^1 x dif x", "sum_(n=1)^oo 1/n^2", "d/(d x) sin x", "lim_(x -> 0) (sin x)/x",
    "nabla dot E", "p V = n R T", "Delta x", "x!", "log_2 8 = 3", "(a + b)^2", "v = d / t",
    "phi", "f'(x)", "binom(n, k)", "cos^2 + sin^2 = 1", "lambda",
]
formulas = [scene.text.equation(formula) for formula in FORMULAS]
L.scatter(formulas, page["stage"], avoid=parts, gap=0.2, seed=1)
caption = code('L.scatter(formulas, page["stage"], avoid=parts, gap=0.2, seed=1)').place(page["caption"])
start = scene.cursor
scene.play([character.animate.fade_in().duration(0.5), caption.animate.fade_in().duration(0.5)])
scene.play(stagger(*[formula.animate.write().duration(0.6) for formula in formulas], each=0.05))
scene.wait(0.6)
seeks.extend([start + 0.6, start + 1.2, scene.cursor - 0.1])

# The same objects and region give the same layout for the same seed; gap
# sets the clearance, avoid takes zones too, and the region can be any
# object's box.
scene.segment("options")
cells = L.zones(Zones.grid(rows=3, columns=2, gap="32px", padding="page_padding"))
SIZES = [0.26, 0.24, 0.22, 0.2, 0.18, 0.16, 0.15, 0.14, 0.12, 0.12, 0.1, 0.1, 0.08, 0.08]


def dots():
    return [scene.geometry.circle(size) for size in SIZES]


def grains():
    """Many small dots, to fill a region up to the gap."""
    return [scene.geometry.circle(0.07) for _ in range(90)]


shown, scattered = [], []
for cell, label, scatter in [
    (cells[0], "scatter(dots, zone, seed=1)", lambda items, zone: L.scatter(items, zone, seed=1)),
    (cells[1], "scatter(dots, zone, seed=2)", lambda items, zone: L.scatter(items, zone, seed=2)),
    (cells[2], "scatter(dots, zone, gap=0.4)", lambda items, zone: L.scatter(items, zone, gap=0.4)),
    (cells[3], 'scatter(grains, zone, gap="6px")', lambda items, zone: L.scatter(items, zone, gap="6px")),
]:
    title, zone = cell.split(Zones.rows(["56px", "1fr"], gap="8px"))
    items = grains() if "grains" in label else dots()
    scatter(items, zone)
    shown += [code(label).place(title, anchor="left"), outline(zone)]
    scattered += items
# A zone to keep clear, and an object whose box is the region.
title, zone = cells[4].split(Zones.rows(["56px", "1fr"], gap="8px"))
hole = zone.inset(("32px", "300px"))
items = dots()
L.scatter(items, zone, avoid=[hole])
shown += [code("scatter(dots, zone, avoid=[hole])").place(title, anchor="left"), outline(zone), outline(hole, danger)]
scattered += items
title, zone = cells[5].split(Zones.rows(["56px", "1fr"], gap="8px"))
card = scene.geometry.rounded_rect(zone.width * 0.7, zone.height, 0.2).fill(header).stroke(rule, 0.02).place(zone)
items = dots()
L.scatter(items, card)
shown += [code("scatter(dots, card)").place(title, anchor="left"), card]
scattered += items
start = scene.cursor
scene.play(stagger(*[item.animate.fade_in().duration(0.4) for item in shown], each=0.05))
scene.play(stagger(*[item.animate.grow_from_center().duration(0.4) for item in scattered], each=0.01))
scene.wait(0.6)
seeks.extend([start + 0.6, start + 1.2, scene.cursor - 0.1])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()