Style
Colors, gradients, blend modes, themes and backgrounds.
Colors
CSS color strings, Color constructors, named constants, the theme's color roles and the Tailwind palette.

gaanim examples/style/colors.pyCode · 188 lines
"""Colors: CSS strings, Color constructors, named constants, theme roles and the Tailwind palette.
Every swatch is labelled with the code that makes its color. A light bar
behind the right half of a swatch shows through translucent colors, and the
numbers beside it are the color's 8-bit channels, read back with ``.r``,
``.g``, ``.b`` and ``.a``. The last segments lay out the named constants, the
semantic roles of the default theme and the bundled Tailwind CSS palette.
"""
import os
from gaanim import (
BLACK,
BLUE,
CORAL,
CYAN,
GOLD,
GRAY,
GREEN,
NAVY,
ORANGE,
PINK,
PURPLE,
RED,
TEAL,
WHITE,
YELLOW,
Color,
Scene,
Zones,
colors,
stagger,
)
GROW = 0.5
SPREAD = 0.5
HOLD = 1.0
scene = Scene()
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
def label(code):
"""A variant label: the code, in the code role, which reads literally."""
return scene.text(code, role="code")
def swatch(zone, paint, outline=None):
"""A rounded swatch filling ``zone``; ``outline`` strokes it, for colors close to the background."""
shape = scene.geometry.rounded_rect(zone.width, zone.height, 0.06).fill(paint).no_stroke()
if outline is not None:
shape.stroke(outline, 0.02, align="center")
return shape.place(zone)
def enter(shapes):
"""Grow ``shapes`` in one after another, then hold them."""
scene.play(stagger(*[shape.animate.grow_from_center().duration(GROW) for shape in shapes], total=SPREAD))
scene.wait(HOLD)
def channels(variants):
"""One row per (label, color): the label, a swatch over a light bar, and the color's channels."""
grid = scene.layout.zones(
Zones.grid(
rows=len(variants) + 1, columns=[7.7, "1fr", 3.4], column_gap=0.3, row_gap="16px",
padding="page_padding",
)
)
label(".r .g .b .a").fill(muted).place(grid[2], anchor="right")
shapes = []
for index, (code, color) in enumerate(variants, start=1):
label(code).place(grid[3 * index], anchor="right")
stage = grid[3 * index + 1]
# A light bar behind the swatch's right half shows through translucent colors.
inner = stage.inset((stage.height * 0.1, stage.width * 0.04))
scene.geometry.rect(inner.width * 0.45, inner.height * 0.85).fill(foreground).place(inner, anchor="right")
shapes.append(swatch(stage, color))
value = Color(color)
readout = f"{value.r:>3} {value.g:>3} {value.b:>3} {value.a:>3}"
label(readout).fill(muted).place(grid[3 * index + 2], anchor="right")
enter(shapes)
# A string is any CSS Color 4 color: hex with an optional alpha digit pair,
# a named color, or an rgb(), hsl(), hwb(), lab(), lch(), oklab() or oklch()
# function. Color(...) parses it once; fill() and stroke() take the string too.
scene.segment("css")
channels([
('Color("#2563eb")', Color("#2563eb")),
('Color("#2563eb80")', Color("#2563eb80")),
('Color("tomato")', Color("tomato")),
('Color("rgb(37 99 235 / 65%)")', Color("rgb(37 99 235 / 65%)")),
('Color("hsl(160 84% 39%)")', Color("hsl(160 84% 39%)")),
('Color("hwb(200 10% 20%)")', Color("hwb(200 10% 20%)")),
('Color("lch(60% 50 30)")', Color("lch(60% 50 30)")),
('Color("oklch(70% 0.19 48)")', Color("oklch(70% 0.19 48)")),
])
# Channels as 0-255 integers, HSL with 0-1 saturation and lightness, or
# perceptual OKLCH, whose lightness steps look even. Wherever a color is
# expected, an (r, g, b) or (r, g, b, a) tuple works as well.
scene.segment("constructors")
channels([
("Color(128, 51, 204)", Color(128, 51, 204)),
("Color(128, 51, 204, 128)", Color(128, 51, 204, 128)),
("(20, 184, 166)", (20, 184, 166)),
('Color.from_hex("#ff8800")', Color.from_hex("#ff8800")),
("Color.from_rgb(20, 184, 166)", Color.from_rgb(20, 184, 166)),
("Color.from_rgba(255, 255, 255, 64)", Color.from_rgba(255, 255, 255, 64)),
("Color.from_hsl(215, 0.9, 0.55, 0.8)", Color.from_hsl(215, 0.9, 0.55, 0.8)),
("Color.from_oklch(0.68, 0.17, 240)", Color.from_oklch(0.68, 0.17, 240)),
])
# The fifteen named constants. BLACK and NAVY nearly vanish on the dark
# theme, so every swatch here carries a thin muted outline.
scene.segment("constants")
CONSTANTS = [
("WHITE", WHITE), ("GRAY", GRAY), ("BLACK", BLACK), ("NAVY", NAVY), ("BLUE", BLUE),
("TEAL", TEAL), ("CYAN", CYAN), ("GREEN", GREEN), ("YELLOW", YELLOW), ("GOLD", GOLD),
("ORANGE", ORANGE), ("CORAL", CORAL), ("RED", RED), ("PINK", PINK), ("PURPLE", PURPLE),
] # fmt: skip
grid = scene.layout.zones(Zones.grid(rows=3, columns=5, column_gap="48px", row_gap="36px", padding="page_padding"))
shapes = []
for cell, (name, color) in zip(grid, CONSTANTS):
art, caption = cell.split(Zones.rows(["1fr", "48px"], gap="16px"))
label(name).place(caption)
shapes.append(swatch(art, color, outline=muted))
enter(shapes)
# Theme roles: scene.canvas.color(role) returns the active theme's color, so
# a scene that switches themes keeps its meaning. primary and body are
# aliases of foreground, secondary of muted and border of rule. Each label
# is shortened from scene.canvas.color(...).
scene.segment("theme_roles")
ROLES = [
"background", "foreground", "primary", "body", "muted", "secondary", "title", "accent",
"chart", "panel", "header", "rule", "border", "success", "warning", "danger",
] # fmt: skip
grid = scene.layout.zones(
Zones.grid(
rows=8, columns=["5fr", "3fr", "5fr", "3fr"], column_gap="48px", row_gap="12px", padding="page_padding"
)
)
shapes = []
for index, role in enumerate(ROLES):
row, column = index % 8, index // 8
label(f'color("{role}")').place(grid[4 * row + 2 * column], anchor="right")
shapes.append(swatch(grid[4 * row + 2 * column + 1], scene.canvas.color(role), outline=muted))
enter(shapes)
def tailwind(families):
"""One row of swatches per Tailwind family, from shade 50 to 950, under a header of shades."""
shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
grid = scene.layout.zones(
Zones.grid(
rows=len(families) + 1, columns=["4fr"] + ["1fr"] * len(shades), column_gap="8px", row_gap="6px",
padding="page_padding",
)
)
for index, shade in enumerate(shades, start=1):
label(str(shade)).fill(muted).place(grid[index])
shapes = []
for row, family in enumerate(families, start=1):
first = row * (len(shades) + 1)
label(f"tailwind.{family}").place(grid[first].inset((0, 0.25, 0, 0)), anchor="right")
for index, shade in enumerate(shades, start=1):
shapes.append(swatch(grid[first + index], colors.tailwind[family][shade]))
enter(shapes)
# colors.tailwind holds the 26 families of Tailwind CSS, each a scale from 50
# to 950: colors.tailwind.blue[500] or colors.tailwind["blue"][500].
FAMILIES = colors.tailwind.families()
scene.segment("tailwind_hues")
tailwind(FAMILIES[:13])
scene.segment("tailwind_neutrals")
tailwind(FAMILIES[13:])
STEP = GROW + SPREAD + HOLD
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Growing in and settled, in each of the six segments.
scene.snapshots(snapshots, [STEP * index + t for index in range(6) for t in (0.6, 1.6)])
else:
scene.render()Brushes
Solid, linear, radial and sweep brushes, their extend modes, gradient text and strokes, and gradients that animate.

gaanim examples/style/brushes.pyCode · 218 lines
"""Brushes: solid, linear, radial and sweep paints, their extend modes, and gradients that animate.
``fill()`` and ``stroke()`` take a ``Brush`` wherever they take a color. A
gradient's coordinates are local to the drawable it paints, so the paint
moves, turns and stretches with it. Each label is the code that builds the
variant; names in capitals are colors and brushes defined in the script,
such as ``RAMP``, ``[success, accent, danger]`` from the theme.
"""
import math
import os
from gaanim import Brush, Scene, Zones
GROW = 0.6
MOVE = 1.6
HOLD = 1.2
scene = Scene()
background = scene.canvas.color("background")
foreground = scene.canvas.color("foreground")
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
chart = scene.canvas.color("chart")
RAMP = [success, accent, danger]
TWO = [accent, danger]
ORB = [foreground, accent, background]
# The accent with no opacity, to fade a gradient out.
CLEAR = (accent.r, accent.g, accent.b, 0)
def label(code):
"""A variant label: the code, in the code role."""
return scene.text(code, role="code")
def card():
"""The rounded rectangle most variants paint, 4.4 x 1.6 around its own origin."""
return scene.geometry.rounded_rect(4.4, 1.6, 0.18).no_stroke()
def disc():
"""The circle the radial and sweep variants paint, of radius 0.8 around its own origin."""
return scene.geometry.circle(0.8).no_stroke()
def gallery(variants):
"""Place each (label, drawable) pair in a 3 x 2 grid, the drawable above its label."""
grid = scene.layout.zones(Zones.grid(rows=3, columns=2, column_gap="96px", row_gap="36px", padding="page_padding"))
for cell, (code, drawable) in zip(grid, variants):
art, caption = cell.split(Zones.rows(["1fr", "88px"], gap="12px"))
label(code).place(caption, anchor="top")
drawable.place(art)
return [drawable for _, drawable in variants]
def reveal(drawables):
"""Grow ``drawables`` in together, then hold them."""
scene.play([drawable.animate.grow_from_center() for drawable in drawables], duration=GROW)
scene.wait(HOLD)
# Linear gradients run from start to end, two points in the drawable's own
# coordinates (a card spans x from -2.2 to 2.2), with the colors spread
# evenly between them. A solid brush is the same as a plain color.
scene.segment("linear")
reveal(gallery([
("Brush.solid(accent)", card().fill(Brush.solid(accent))),
(
"Brush.linear(TWO,\n start=(-2.2, 0), end=(2.2, 0))",
card().fill(Brush.linear(TWO, start=(-2.2, 0), end=(2.2, 0))),
),
(
"Brush.linear(RAMP,\n start=(-2.2, 0), end=(2.2, 0))",
card().fill(Brush.linear(RAMP, start=(-2.2, 0), end=(2.2, 0))),
),
(
"Brush.linear(RAMP,\n start=(-2.2, -0.8), end=(2.2, 0.8))",
card().fill(Brush.linear(RAMP, start=(-2.2, -0.8), end=(2.2, 0.8))),
),
(
"Brush.linear(RAMP,\n start=(0, -0.8), end=(0, 0.8))",
card().fill(Brush.linear(RAMP, start=(0, -0.8), end=(0, 0.8))),
),
(
"Brush.linear([accent, CLEAR],\n start=(-2.2, 0), end=(2.2, 0))",
card().fill(Brush.linear([accent, CLEAR], start=(-2.2, 0), end=(2.2, 0))),
),
]))
# Radial gradients spread from a center out to a radius; sweep gradients
# turn counterclockwise around a center, from start_angle to end_angle in
# degrees.
scene.segment("radial_sweep")
reveal(gallery([
("Brush.radial(RAMP, radius=0.8)", disc().fill(Brush.radial(RAMP, radius=0.8))),
("Brush.sweep(RAMP + [success])", disc().fill(Brush.sweep(RAMP + [success]))),
(
"Brush.radial(ORB, radius=1.2,\n center=(-0.3, 0.3))",
disc().fill(Brush.radial(ORB, radius=1.2, center=(-0.3, 0.3))),
),
(
"Brush.sweep(RAMP,\n start_angle=90, end_angle=270)",
disc().fill(Brush.sweep(RAMP, start_angle=90, end_angle=270)),
),
("Brush.radial([accent, CLEAR],\n radius=0.8)", disc().fill(Brush.radial([accent, CLEAR], radius=0.8))),
(
"Brush.sweep(RAMP + [success],\n center=(0.4, 0.3))",
disc().fill(Brush.sweep(RAMP + [success], center=(0.4, 0.3))),
),
]))
# extend decides what lies past the ends of the ramp: "pad" holds the end
# colors, "repeat" starts the ramp over and "reflect" mirrors it. Every ramp
# here is short: one unit long, half a unit of radius or a quarter turn.
scene.segment("extend")
grid = scene.layout.zones(
Zones.grid(
rows=["48px", "1fr", "1fr", "1fr"], columns=["11fr", "8fr", "8fr", "8fr"], column_gap="48px", row_gap="36px",
padding="page_padding",
)
)
MODES = ["pad", "repeat", "reflect"]
for index, mode in enumerate(MODES, start=1):
label(f'extend="{mode}"').place(grid[index])
KINDS = [
(
"Brush.linear(TWO,\n start=(-0.5, 0),\n end=(0.5, 0))",
lambda mode: scene.geometry.rounded_rect(3.4, 1.7, 0.16).no_stroke().fill(
Brush.linear(TWO, start=(-0.5, 0), end=(0.5, 0), extend=mode)
),
),
(
"Brush.radial(TWO,\n radius=0.5)",
lambda mode: scene.geometry.circle(0.85).no_stroke().fill(Brush.radial(TWO, radius=0.5, extend=mode)),
),
(
"Brush.sweep(TWO,\n end_angle=90)",
lambda mode: scene.geometry.circle(0.85).no_stroke().fill(Brush.sweep(TWO, end_angle=90, extend=mode)),
),
]
shapes = []
for row, (code, build) in enumerate(KINDS, start=1):
label(code).place(grid[4 * row], anchor="left")
for column, mode in enumerate(MODES, start=1):
shapes.append(build(mode).place(grid[4 * row + column]))
reveal(shapes)
# A brush paints strokes and text as well as fills. Its coordinates belong
# to the drawable, so the gradient turns and stretches along with it. A
# text's own coordinates start at its left end, so its ramp runs from x = 0.
scene.segment("paint_follows")
CARD = Brush.linear(RAMP, start=(-2.2, 0), end=(2.2, 0))
DISC = Brush.linear(RAMP, start=(-0.8, 0), end=(0.8, 0))
TEXT = Brush.linear(RAMP, start=(0, 0), end=(4.4, 0))
SWEEP = Brush.sweep(RAMP + [success])
turning = disc().fill(DISC)
stretching = disc().fill(DISC)
spinning = disc().no_fill().stroke(SWEEP, 0.2)
drawables = gallery([
('scene.text("Brush", size=1.5)\n .fill(TEXT)', scene.text("Brush", size=1.5).fill(TEXT)),
(".no_fill().stroke(CARD, 0.2)", card().no_fill().stroke(CARD, 0.2)),
(".fill(DISC)\n.animate.rotate_by(math.pi / 2)", turning),
(".fill(DISC)\n.animate.scale_to_3d(2.5, 1, 1)", stretching),
(".no_fill().stroke(SWEEP, 0.2)\n.animate.rotate_by(math.pi)", spinning),
(".fill(DISC)", disc().fill(DISC)),
])
scene.play([drawable.animate.grow_from_center() for drawable in drawables], duration=GROW)
scene.play(
[
turning.animate.rotate_by(math.pi / 2),
stretching.animate.scale_to_3d(2.5, 1, 1),
spinning.animate.rotate_by(math.pi),
],
duration=MOVE,
)
scene.wait(HOLD)
# animate.fill and animate.stroke blend one paint into another: gradients of
# one kind blend their geometry and colors (stops are matched by position,
# so two can become three), and a solid color turns into a gradient or back.
# Blending different kinds, such as linear into radial, raises ValueError.
scene.segment("animated")
WARM = Brush.linear([accent, danger], start=(-2.2, 0), end=(2.2, 0))
COOL = Brush.linear([success, chart], start=(-2.2, 0), end=(2.2, 0))
RISING = Brush.linear([success, chart], start=(0, -0.8), end=(0, 0.8))
GLOW = Brush.radial(ORB, center=(-0.3, 0.3), radius=1.2)
SHIFTED = Brush.radial([foreground, danger, background], center=(0.35, -0.3), radius=1.6)
VARIANTS = [
(".fill(WARM)\n.animate.fill(COOL)", card().fill(WARM), lambda shape: shape.animate.fill(COOL)),
(".fill(WARM)\n.animate.fill(RISING)", card().fill(WARM), lambda shape: shape.animate.fill(RISING)),
(".fill(WARM)\n.animate.fill(CARD)", card().fill(WARM), lambda shape: shape.animate.fill(CARD)),
(".fill(accent)\n.animate.fill(COOL)", card().fill(accent), lambda shape: shape.animate.fill(COOL)),
(".fill(GLOW)\n.animate.fill(SHIFTED)", disc().fill(GLOW), lambda shape: shape.animate.fill(SHIFTED)),
(
".no_fill().stroke(accent, 0.06)\n.animate.stroke(CARD, 0.24)",
card().no_fill().stroke(accent, 0.06),
lambda shape: shape.animate.stroke(CARD, 0.24),
),
]
shapes = gallery([(code, shape) for code, shape, _ in VARIANTS])
scene.play([shape.animate.grow_from_center() for shape in shapes], duration=GROW)
scene.play([change(shape) for shape, (_, _, change) in zip(shapes, VARIANTS)], duration=MOVE)
scene.wait(HOLD)
STILL = GROW + HOLD
MOVING = GROW + MOVE + HOLD
SEEKS = [STILL * index + 1.2 for index in range(3)] + [
3 * STILL + MOVING * index + t for index in range(2) for t in (0.6, 1.4, 3.0)
]
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The three still segments settled; the two animated ones before,
# during and after their changes.
scene.snapshots(snapshots, SEEKS)
else:
scene.render()Color maps
Built-in continuous and categorical color maps, sampling, custom, reversed and translucent maps, and data colored by value.

gaanim examples/style/color_maps.pyCode · 212 lines
"""Color maps: built-in continuous and categorical maps, sampling, custom maps and data colored by value.
A ``ColorMap`` turns a position from 0 to 1 into a color. Each strip samples
its map at 64 evenly spaced positions with ``sample(t)``, and each row of
swatches is the list ``colors(n)`` returns. Labels are the code that builds
the map; the column on the right reads a property of it back.
"""
import math
import os
from gaanim import ColorMap, Direction, Scene, Zones
GROW = 0.8
HOLD = 1.2
STEPS = 64
scene = Scene()
background = scene.canvas.color("background")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
warning = scene.canvas.color("warning")
danger = scene.canvas.color("danger")
def label(code):
"""A variant label: the code, in the code role."""
return scene.text(code, role="code")
def row_of(zone, colors, width):
"""One rectangle per color across ``zone``, left to right, each ``width`` times its share of it."""
share = zone.width / len(colors)
y = zone.center[1]
return scene.geometry.group([
scene.geometry.rect(share * width, zone.height).fill(color).move_to(zone.left + share * (index + 0.5), y)
for index, color in enumerate(colors)
])
def strip(zone, colors, overlap=True):
"""A continuous bar of slim cells; they overlap a little so no seam shows.
Translucent cells must not overlap, or the overlaps would show darker.
"""
return row_of(zone, colors, 1.02 if overlap else 1.0)
def swatches(zone, colors):
"""One swatch per color, with a gap between swatches."""
return row_of(zone, colors, 0.86)
def sampled(colormap):
"""``colormap`` sampled at STEPS evenly spaced positions from 0 to 1."""
return [colormap.sample(index / (STEPS - 1)) for index in range(STEPS)]
def table(rows, header=None):
"""One row per (label, build, readout): build(zone) draws the row's colors next to its label.
``header`` labels the readout column. The rows grow in from the left together.
"""
grid = scene.layout.zones(
Zones.grid(
rows=len(rows) + 1, columns=[7.4, "1fr", 2.6], column_gap=0.4, row_gap="20px", padding="page_padding"
)
)
if header:
label(header).fill(muted).place(grid[2], anchor="left")
bars = []
for index, (code, build, readout) in enumerate(rows, start=1):
label(code).place(grid[3 * index], anchor="right")
bars.append(build(grid[3 * index + 1]))
if readout is not None:
label(readout).fill(muted).place(grid[3 * index + 2], anchor="left")
scene.play([bar.animate.grow_from_edge(Direction.LEFT) for bar in bars], duration=GROW)
scene.wait(HOLD)
def continuous(name, factory="ColorMap"):
"""A row for the built-in map ``name``, built by ``factory``: its sampled strip and its catalog."""
colormap = ColorMap.named(name) if factory == "ColorMap.named" else ColorMap(name)
return (f'{factory}("{name}")', lambda zone: strip(zone, sampled(colormap)), colormap.category)
# Built-in maps come from two catalogs: Matplotlib's and the Scientific Colour
# Maps. Each is sequential, diverging (two hues meeting at a light or dark
# middle) or cyclic (both ends match). ColorMap.names(category) lists them
# and ColorMap.named(name) is the same as ColorMap(name).
scene.segment("built_in")
table(
[
continuous("viridis"),
continuous("magma"),
continuous("coolwarm"),
continuous("twilight"),
continuous("batlow"),
continuous("vik"),
continuous("romaO"),
continuous("lajolla", factory="ColorMap.named"),
],
header=".category",
)
# Categorical maps hold distinct steps: sample(t) jumps from one to the next
# and colors(n) returns the first n in order. colors(n) on a continuous map
# spreads n colors evenly from end to end.
scene.segment("categorical")
tab10 = ColorMap("tab10")
viridis = ColorMap("viridis")
table(
[
('ColorMap("tab10").colors(10)', lambda zone: swatches(zone, tab10.colors(10)), "True"),
('ColorMap("tab20").colors(20)', lambda zone: swatches(zone, ColorMap("tab20").colors(20)), "True"),
('ColorMap("tab20b").colors(20)', lambda zone: swatches(zone, ColorMap("tab20b").colors(20)), "True"),
('ColorMap("tab20c").colors(20)', lambda zone: swatches(zone, ColorMap("tab20c").colors(20)), "True"),
('ColorMap("tab10").sample(t)', lambda zone: strip(zone, sampled(tab10)), "True"),
('ColorMap("viridis").colors(5)', lambda zone: swatches(zone, viridis.colors(5)), "False"),
('ColorMap("viridis").colors(12)', lambda zone: swatches(zone, viridis.colors(12)), "False"),
],
header=".categorical",
)
def translucent(zone):
"""The half-transparent viridis strip over a light bar along its middle."""
scene.geometry.rect(zone.width, zone.height * 0.4).fill(foreground).move_to(*zone.center)
return strip(zone, sampled(viridis.with_alpha(0.5)), overlap=False)
# Maps of your own: from_colors spreads its colors evenly, or at positions
# from 0 to 1, and has no name or category; reversed() flips any map and
# with_alpha() makes it translucent, over a light bar here so the bar shows
# through. The long labels sit above their strips.
scene.segment("custom")
WARM = [background, accent, foreground]
CUSTOM = [
(
"ColorMap.from_colors([background, accent, foreground])",
lambda zone: strip(zone, sampled(ColorMap.from_colors(WARM))),
),
(
"ColorMap.from_colors([...], positions=[0, 0.8, 1])",
lambda zone: strip(zone, sampled(ColorMap.from_colors(WARM, positions=[0, 0.8, 1]))),
),
(
"ColorMap.from_colors([success, warning, danger])",
lambda zone: strip(zone, sampled(ColorMap.from_colors([success, warning, danger]))),
),
('ColorMap("viridis").reversed()', lambda zone: strip(zone, sampled(viridis.reversed()))),
('ColorMap("viridis").with_alpha(0.5)', translucent),
('ColorMap("vik").reversed()', lambda zone: strip(zone, sampled(ColorMap("vik").reversed()))),
]
grid = scene.layout.zones(Zones.rows(len(CUSTOM), gap="24px", padding="page_padding"))
bars = []
for cell, (code, build) in zip(grid, CUSTOM):
caption, stage = cell.split(Zones.rows(["48px", "1fr"], gap="8px"))
label(code).place(caption, anchor="left")
bars.append(build(stage))
scene.play([bar.animate.grow_from_edge(Direction.LEFT) for bar in bars], duration=GROW)
scene.wait(HOLD)
# Data: a value scaled to 0..1 picks its color. The bars take their color
# from their height, with viridis = ColorMap("viridis"), and the cells from
# a wave over the grid, with magma = ColorMap("magma").
scene.segment("data")
left, right = scene.layout.zones(Zones.columns(2, gap="96px", padding="page_padding"))
plot_left, caption_left = left.split(Zones.rows(["1fr", "60px"], gap="24px"))
plot_right, caption_right = right.split(Zones.rows(["1fr", "60px"], gap="24px"))
label("viridis.sample(height)").place(caption_left)
label("magma.sample(wave)").place(caption_right)
COUNT = 16
heights = [0.5 + 0.5 * math.sin(index / COUNT * math.tau * 1.2 - 0.6) * math.cos(index / 7) for index in range(COUNT)]
low, high = min(heights), max(heights)
width = plot_left.width / COUNT
bars = []
for index, height in enumerate(heights):
value = (height - low) / (high - low)
tall = 0.3 + value * (plot_left.height - 0.3)
bar = scene.geometry.rect(width * 0.8, tall).fill(viridis.sample(value))
bars.append(bar.move_to(plot_left.left + width * (index + 0.5), plot_left.bottom + tall / 2))
magma = ColorMap("magma")
COLUMNS, ROWS = 14, 9
size = min(plot_right.width / COLUMNS, plot_right.height / ROWS)
x0 = plot_right.center[0] - size * COLUMNS / 2
y0 = plot_right.center[1] - size * ROWS / 2
cells = []
for row in range(ROWS):
for column in range(COLUMNS):
wave = math.sin(column / 2.2) * math.cos(row / 2.6) * 0.5 + 0.5
cell = scene.geometry.square(size * 0.92).fill(magma.sample(wave))
cells.append(cell.move_to(x0 + size * (column + 0.5), y0 + size * (row + 0.5)))
scene.play(
[bar.animate.grow_from_edge(Direction.DOWN) for bar in bars] + [cell.animate.fade_in() for cell in cells],
duration=GROW,
)
scene.wait(HOLD)
STEP = GROW + HOLD
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Growing in and settled, in each of the four segments.
scene.snapshots(snapshots, [STEP * index + t for index in range(4) for t in (0.4, 1.6)])
else:
scene.render()Blend modes
Every blend mode of a drawable over the same backdrop, and blended groups, member overrides and text.

gaanim examples/style/blend_modes.pyCode · 136 lines
"""Blend modes: how a drawable mixes with what is drawn beneath it, in each of the seventeen modes.
In every cell a disc crosses the same base, a band of the theme's hues over a
band from dark to light, so only the mode changes. The disc starts half off
the base, where it blends with the plain background. The last segment blends
whole groups, gives one member of a group its own mode, and blends text. Each
label is the call that sets the mode.
"""
import os
from gaanim import Brush, Scene, Zones
GROW = 0.6
CROSS = 2.0
HOLD = 1.0
scene = Scene()
background = scene.canvas.color("background")
foreground = scene.canvas.color("foreground")
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
BAND = 0.7
DISC = Brush.radial([foreground, accent, danger], radius=0.62)
def base(zone, width):
"""The base of a cell, ``width`` wide: the hue band over the gray band, centered in ``zone``."""
x, y = zone.center
hues = Brush.linear([success, accent, danger], start=(-width / 2, 0), end=(width / 2, 0))
grays = Brush.linear([background, foreground], start=(-width / 2, 0), end=(width / 2, 0))
return [
scene.geometry.rect(width, BAND).fill(hues).move_to(x, y + BAND / 2),
scene.geometry.rect(width, BAND).fill(grays).move_to(x, y - BAND / 2),
]
def cells(variants, columns=3, width=3.6, caption="48px"):
"""One cell per (label, build): a base ``width`` wide, then what build(x, y) returns at its left end.
The movers grow in, cross their bases together, and hold.
"""
rows = -(-len(variants) // columns)
grid = scene.layout.zones(
Zones.grid(rows=rows, columns=columns, column_gap="72px", row_gap="36px", padding="page_padding")
)
bases, movers = [], []
for cell, (code, build) in zip(grid, variants):
art, text = cell.split(Zones.rows(["1fr", caption], gap="12px"))
scene.text(code, role="code").place(text)
bases += base(art, width)
x, y = art.center
movers.append(build(x - width / 2, y))
scene.play(
[shape.animate.fade_in() for shape in bases] + [mover.animate.grow_from_center() for mover in movers],
duration=GROW,
)
scene.play([mover.animate.shift_by(width, 0) for mover in movers], duration=CROSS)
scene.wait(HOLD)
def disc(mode):
"""A builder of the disc blended with ``mode``."""
return lambda x, y: scene.geometry.circle(0.62).fill(DISC).no_stroke().move_to(x, y).blend(mode)
def trio(x, y):
"""Three overlapping discs, a, b and c, in success, accent and danger, grouped around ``(x, y)``."""
discs = [
scene.geometry.circle(0.42).fill(paint).no_stroke().move_to(x + dx, y + dy)
for paint, (dx, dy) in zip([success, accent, danger], [(-0.26, -0.15), (0, 0.3), (0.26, -0.15)])
]
return scene.geometry.group(discs), discs
def group_blend(mode, override=None):
"""A builder of the trio blended with ``mode``; ``override`` is (member index, its own mode)."""
def build(x, y):
group, discs = trio(x, y)
group.blend(mode)
if override is not None:
# Set after the group's mode, the member's own mode wins.
index, own = override
discs[index].blend(own)
return group
return build
def word(mode, paint):
"""A builder of a bold word in ``paint`` blended with ``mode``."""
return lambda x, y: scene.text("BLEND", size=0.6, weight=800, color=paint).move_to(x, y).blend(mode)
# Separable modes mix each color channel on its own: multiply and the
# darkening modes only darken, screen and the lightening modes only lighten,
# and overlay, hard_light and soft_light do one or the other by brightness.
scene.segment("separable")
SEPARABLE = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color_dodge", "color_burn", "hard_light"]
cells([(f'.blend("{mode}")', disc(mode)) for mode in SEPARABLE])
# The non-separable modes take hue, saturation or luminosity from the disc
# and the rest from the base; "add" sums light. The last cell is the same
# disc in "normal" again, for comparison.
scene.segment("non_separable")
NON_SEPARABLE = ["soft_light", "difference", "exclusion", "hue", "saturation", "color", "luminosity", "add", "normal"]
cells([(f'.blend("{mode}")', disc(mode)) for mode in NON_SEPARABLE])
# On a group the mode reaches every member: trio = group([a, b, c]) blends
# its three discs with the base and with each other. A member that sets its
# own mode afterwards keeps it, and on a text every glyph blends.
scene.segment("groups_and_text")
cells(
[
('trio.blend("screen")', group_blend("screen")),
('trio.blend("multiply")', group_blend("multiply")),
('trio.blend("screen")\nb.blend("normal")', group_blend("screen", (1, "normal"))),
('trio.blend("multiply")\nc.blend("difference")', group_blend("multiply", (2, "difference"))),
('scene.text("BLEND")\n .blend("difference")', word("difference", foreground)),
('scene.text("BLEND", color=accent)\n .blend("multiply")', word("multiply", accent)),
],
columns=2,
width=4.2,
caption="88px",
)
STEP = GROW + CROSS + HOLD
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment with its movers half off the base, halfway across and at the far end.
scene.snapshots(snapshots, [STEP * index + t for index in range(3) for t in (0.7, 1.6, 3.4)])
else:
scene.render()Themes
The palette of every built-in theme and its aliases, side by side on the same small composition.

gaanim examples/style/themes.pyCode · 139 lines
"""Built-in themes: the palette of every theme Gaanim ships, side by side on the same small composition.
A theme is scene-wide: ``Scene(theme=...)`` or ``scene.canvas.set_theme(...)``
restyles every object of the scene at once. To compare the themes in one
scene, each card here paints its parts with ``Theme(name).color(role)``, the
colors that theme gives them: its background, title, foreground and muted
text, panel and header, rule, the accent that fills unstyled shapes and the
chart color that strokes plots. The four bars are the theme's series
palette, [chart, accent, title, muted]. Each label is the code that selects
the theme.
"""
import math
import os
from gaanim import Scene, Theme, Zones
GROW = 0.8
HOLD = 1.4
scene = Scene()
muted = scene.canvas.color("muted")
ALIASES = [
("scientific", "technical"),
("deck", "presentation"),
("light", "paper"),
("gruvbox", "gruvbox-dark"),
("tokyo", "tokyo-night"),
("catppuccin", "catppuccin-mocha"),
("mocha", "catppuccin-mocha"),
("latte", "catppuccin-latte"),
]
def label(code):
"""A variant label: the code, in the code role."""
return scene.text(code, role="code")
def text_column(zone, theme):
"""The text half of a card: a heading, body and caption text, and a panel with a header."""
color = theme.color
heading, body, caption, panel = zone.split(Zones.rows(["5fr", "3fr", "3fr", "8fr"], gap="10px"))
parts = [
scene.text("Heading", size=0.56, color=color("title")).place(heading, anchor="bottom_left"),
scene.text("Body text", size=0.34, color=color("foreground")).place(body, anchor="left"),
scene.text("A muted caption", size=0.28, color=color("muted")).place(caption, anchor="left"),
]
box = scene.geometry.rounded_rect(panel.width, panel.height, 0.1).fill(color("panel")).stroke(color("rule"), 0.02)
parts.append(box.place(panel))
strip, inside = panel.split(Zones.rows(["2fr", "3fr"]))
parts.append(scene.geometry.rect(strip.width - 0.04, strip.height - 0.02).fill(color("header")).place(strip))
parts.append(scene.text("header", size=0.26, color=color("foreground")).place(strip.inset((0, 0.2)), anchor="left"))
parts.append(scene.text("panel", size=0.26, color=color("muted")).place(inside.inset((0, 0.2)), anchor="left"))
return parts
def chart_column(zone, theme):
"""The chart half of a card: a grid, axes, four bars in the series colors and a plotted wave."""
color = theme.color
plot = zone.inset((0.1, 0.1, 0.1, 0.2))
left, bottom, width, height = plot.left, plot.bottom, plot.width, plot.height
parts = [
scene.geometry.line((left, bottom + height * k / 4), (left + width, bottom + height * k / 4)).stroke(
color("rule"), 0.015
)
for k in range(1, 5)
]
series = [color("chart"), color("accent"), color("title"), color("muted")]
for index, (paint, value) in enumerate(zip(series, [0.55, 0.85, 0.4, 0.7])):
x = left + width * (index + 0.75) / 4.5
parts.append(scene.geometry.rect(width / 7, height * value).fill(paint).move_to(x, bottom + height * value / 2))
wave = [
(left + width * k / 40, bottom + height * (0.5 + 0.32 * math.sin(k / 40 * math.tau * 1.2)))
for k in range(41)
]
parts.append(scene.geometry.polyline(wave).no_fill().stroke(color("chart"), 0.05))
parts.append(scene.geometry.line((left, bottom), (left + width, bottom)).stroke(color("foreground"), 0.03))
parts.append(scene.geometry.line((left, bottom), (left, bottom + height)).stroke(color("foreground"), 0.03))
parts.append(scene.geometry.circle(0.22).fill(color("accent")).move_to(left + width - 0.3, bottom + height - 0.3))
return parts
def card(zone, name):
"""One theme's card in ``zone``, under its label; returns the card's parts."""
theme = Theme(name)
caption, body = zone.split(Zones.rows(["44px", "1fr"], gap="12px"))
label(f'Scene(theme="{name}")').place(caption, anchor="left")
back = scene.geometry.rounded_rect(body.width, body.height, 0.16).fill(theme.color("background"))
parts = [back.stroke(theme.color("rule"), 0.03, align="center").place(body)]
words, chart = body.inset((0.3, 0.35)).split(Zones.columns(["11fr", "9fr"], gap="36px"))
return parts + text_column(words, theme) + chart_column(chart, theme)
def aliases(zone):
"""The aliases each built-in theme answers to, as a two-column list under a label."""
caption, body = zone.split(Zones.rows(["44px", "1fr"], gap="12px"))
label("Scene(theme=alias)").place(caption, anchor="left")
grid = body.split(Zones.grid(rows=len(ALIASES), columns=["2fr", "3fr"], column_gap="24px"))
parts = []
for index, (alias, name) in enumerate(ALIASES):
parts.append(label(f'"{alias}"').place(grid[2 * index], anchor="right"))
parts.append(label(f'→ "{name}"').fill(muted).place(grid[2 * index + 1], anchor="left"))
return parts
def page(names, extra=None, size=2):
"""Lay out cards in a ``size`` x ``size`` grid, ``extra(zone)`` filling a free cell, and fade them in."""
grid = scene.layout.zones(
Zones.grid(rows=size, columns=size, column_gap="72px", row_gap="36px", padding="page_padding")
)
parts = []
for zone, name in zip(grid, names):
parts += card(zone, name)
if extra is not None:
parts += extra(grid[len(names)])
scene.play([part.animate.fade_in() for part in parts], duration=GROW)
scene.wait(HOLD)
# Theme.schemes() lists the twelve built-in themes, without aliases.
SCHEMES = Theme.schemes()
scene.segment("neutral_dark")
page(SCHEMES[:2] + ["dracula", "nord"])
scene.segment("editor_dark")
page(["solarized-dark", "gruvbox-dark", "tokyo-night", "catppuccin-mocha"])
scene.segment("light")
page(["paper", "solarized-light", "catppuccin-latte"], extra=aliases)
scene.segment("brand")
page(["gaanim"], size=1)
STEP = GROW + HOLD
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Fading in and settled, in each of the four segments.
scene.snapshots(snapshots, [STEP * index + t for index in range(4) for t in (0.4, 1.8)])
else:
scene.render()Custom themes
A Theme derived from a built-in one with its own colors, fonts, text roles, style classes, spacing tokens and validation.

gaanim examples/style/custom_themes.pyCode · 269 lines
"""Custom themes: a Theme derived from a built-in one, with its own colors, fonts, sizes, rules and tokens.
``FIELD_NOTES`` below derives the ``nord`` theme and replaces only what it
names: color roles and tokens of its own, font families, text sizes and
styles, rules for families, factories, chart parts and classes of objects,
chart palettes and layout tokens. The scene is built with it, so everything
here, labels included, is drawn in its look. Each segment shows one part of
it, labelled with the code that uses it.
"""
import math
import os
from gaanim import AxesStyle, Axis, ChartSpec, Scene, StrokeStyle, Style, TextStyle, Theme, Zones, colors
GROW = 0.8
HOLD = 1.4
palette = colors.tailwind
FIELD_NOTES = Theme(
"nord",
name="field-notes",
# Roles replace the base's colors; any other name is a token for rules.
colors={
"title": palette.amber[300],
"accent": palette.orange[400],
"brand": palette.sky[400],
"ink": palette.slate[200],
"calm": palette.emerald[400],
},
# Families by role: "text" names every prose role. Both families here
# ship with Gaanim; font_dir or font_files embed font files of your own.
fonts={"text": "Libertinus Serif", "code": "DejaVu Sans Mono"},
sizes={"title": 0.8, "heading": 0.6, "body": 0.44},
text={
"label": TextStyle(weight=700, letter_spacing=0.04),
"caption": TextStyle(italic=True),
},
# Selectors: a family ("shape", "line"), a factory ("circle", and
# "expression_plot" for function plots), a part ("axes" and its axis,
# grid, ticks, numbers and labels) or a class.
styles={
"shape": Style(fill="brand"),
"circle": Style(fill="accent", stroke=StrokeStyle("ink", 0.05)),
"line": Style(stroke=StrokeStyle("ink", 0.06, cap="butt", dashes=[0.24, 0.12])),
"expression_plot": Style(stroke=StrokeStyle("accent", 0.07)),
"axes": AxesStyle(
axis=StrokeStyle("ink", 0.04),
grid=StrokeStyle("rule", 0.012),
ticks=StrokeStyle("ink", 0.03),
numbers=TextStyle(size=0.26, color=palette.slate[400]),
labels=TextStyle(size=0.32, weight=700, color=palette.amber[300]),
),
".warning": Style(fill="warning"),
".calm": Style(fill="calm"),
".ghost": Style(opacity=0.35),
},
# The first series color paints chart marks; heatmap cells pick theirs
# along the heatmap ramp.
series=[palette.sky[400], palette.amber[300], palette.rose[400]],
heatmap=[palette.slate[800], palette.sky[700], palette.sky[300], palette.amber[200]],
# Tokens are scene units, used wherever a length is: gap="gutter".
layout={"page_padding": 0.6, "gutter": 0.5},
# With markup off, * and _ are literal unless a text asks for markup.
text_markup=False,
)
scene = Scene(theme=FIELD_NOTES)
muted = scene.canvas.color("muted")
success = scene.canvas.color("success")
def label(code):
"""A variant label: the code, in the code role."""
return scene.text(code, role="code")
def readout(text):
"""A value read back from the theme, in the muted color."""
return scene.text(text, role="code").fill(muted)
def table(rows, columns=("10fr", "4fr", "3fr"), anchor="center"):
"""One row per (label, drawable, readout): the label, the drawable at ``anchor`` and an optional readout.
Without a third column the readouts are left out. The drawables fade in
together, then hold.
"""
grid = scene.layout.zones(
Zones.grid(rows=len(rows), columns=list(columns), column_gap="48px", row_gap="24px", padding="page_padding")
)
width = len(columns)
drawables = []
for index, (code, drawable, value) in enumerate(rows):
label(code).place(grid[width * index], anchor="right")
drawables.append(drawable.place(grid[width * index + 1], anchor=anchor))
if value is not None and width > 2:
readout(value).place(grid[width * index + 2], anchor="left")
scene.play([drawable.animate.fade_in() for drawable in drawables], duration=GROW)
scene.wait(HOLD)
# Text roles take the theme's fonts, sizes and styles: Libertinus Serif for
# prose and DejaVu Sans Mono for code, the labels included.
scene.segment("typography")
SAMPLES = [
("title", "Tide tables"),
("subtitle", "Low water at dawn"),
("heading", "Field notes"),
("body", "The bay empties twice a day."),
("caption", "Measured from the north pier"),
("label", "HIGH WATER"),
("code", "tide.level(t=6.2)"),
("math", "$h(t) = A cos(omega t)$"),
]
table(
[(f'role="{role}"', scene.text(sample, role=role), None) for role, sample in SAMPLES],
columns=("2fr", "3fr"),
anchor="left",
)
# A family rule styles every drawable of the family, a factory rule only that
# factory's drawables, and a class rule the drawables that ask for it. Each
# readout is the rule that decides the look; labels omit scene.geometry.
scene.segment("selectors")
table([
("square(1.1)", scene.geometry.square(1.1), '"shape"'),
("rounded_rect(2.4, 1.1, 0.2)", scene.geometry.rounded_rect(2.4, 1.1, 0.2), '"shape"'),
("circle(0.55)", scene.geometry.circle(0.55), '"circle"'),
("line((0, 0), (3, 0))", scene.geometry.line((0, 0), (3, 0)), '"line"'),
('square(1.1).style_class("warning")', scene.geometry.square(1.1).style_class("warning"), '".warning"'),
])
# The cascade: the base theme, then the family, the factory or part, the
# classes in the order they were added, and last the drawable's own setters.
scene.segment("cascade")
table([
('circle(0.55).style_class("warning")', scene.geometry.circle(0.55).style_class("warning"), '".warning"'),
(
'square(1.1).style_class("warning")\n .style_class("calm")',
scene.geometry.square(1.1).style_class("warning").style_class("calm"),
'".calm"',
),
(
'square(1.1).style_class("calm")\n .style_class("warning")',
scene.geometry.square(1.1).style_class("calm").style_class("warning"),
'".warning"',
),
(
'square(1.1).style_class("warning")\n .style_class("ghost")',
scene.geometry.square(1.1).style_class("warning").style_class("ghost"),
'".ghost"',
),
(
'square(1.1).style_class("warning")\n .fill(success)',
scene.geometry.square(1.1).style_class("warning").fill(success),
"fill()",
),
])
# Charts: the axes rule styles every part of a plane, the expression_plot
# rule its curves, the first series color the chart's marks and the heatmap
# ramp the cells of a heatmap.
scene.segment("charts")
left, right = scene.layout.zones(Zones.columns(["4fr", "3fr"], gap="gutter", padding="page_padding"))
plane_caption, plane_zone = left.split(Zones.rows(["48px", "1fr"], gap="24px"))
bars_caption, bars_zone, heat_caption, heat_zone = right.split(
Zones.rows(["48px", "1fr", "48px", "1fr"], gap="16px")
)
label("cartesian_2d(x, y).plot(math.sin)").place(plane_caption, anchor="left")
label('ChartSpec(...).mark("bar")').place(bars_caption, anchor="left")
label('ChartSpec(...).mark("heatmap")').place(heat_caption, anchor="left")
plane = scene.viz.cartesian_2d(
Axis.linear(0, 6).ticks(1).label("t"),
Axis.linear(-1.2, 1.2).ticks(0.6).label("h"),
width=plane_zone.width * 0.85,
height=plane_zone.height * 0.8,
)
plane.move_to(*plane_zone.center)
wave = plane.plot(math.sin)
LEVELS = {"day": [1, 2, 3, 4, 5, 6, 7], "level": [2.1, 3.4, 2.8, 4.6, 3.9, 5.2, 4.4]}
bars = scene.viz.chart(ChartSpec(LEVELS).mark("bar", width=0.6).encode(x="day", y="level"))
GRID = [(x, y, math.sin(x / 2) * math.cos(y / 2)) for y in range(5) for x in range(10)]
CELLS = {"x": [x for x, _, _ in GRID], "y": [y for _, y, _ in GRID], "value": [v for _, _, v in GRID]}
heat = scene.viz.chart(ChartSpec(CELLS).mark("heatmap").encode(x="x", y="y", color="value"))
for chart, zone in [(bars, bars_zone), (heat, heat_zone)]:
chart.drawable().place(zone, fit="contain")
scene.play(
[plane.animate.create(), bars.layer("axes").animate.create(), heat.layer("axes").animate.create()],
duration=GROW,
)
scene.play(
[wave.animate.create(), bars.layer("marks").animate.fade_in(), heat.layer("marks").animate.fade_in()],
duration=GROW,
)
scene.wait(HOLD)
def spaced(gap):
"""Four small squares in a row, ``gap`` apart: a layout token or a length."""
return scene.layout.row(*[scene.geometry.square(0.5) for _ in range(4)], gap=gap)
# Layout tokens: the built-in spacing scale, the theme's page_padding (0.6
# here, so every grid of this example sits further in) and gutter, a token
# of its own. Each readout is scene.canvas.layout_token(name).
scene.segment("layout_tokens")
TOKENS = ["space_xs", "space_sm", "space_md", "space_lg", "column_gap", "gutter", "page_padding"]
table(
[(f'row(..., gap="{name}")', spaced(name), f"{scene.canvas.layout_token(name):g}") for name in TOKENS],
columns=("8fr", "6fr", "2fr"),
)
def stacked(rows):
"""One row per (label, drawable): the label with the drawable under it, both left-aligned.
The drawables fade in together, then hold.
"""
grid = scene.layout.zones(Zones.rows(len(rows), gap="24px", padding="page_padding"))
shown = []
for zone, (code, drawable) in zip(grid, rows):
top, bottom = zone.split(Zones.rows(2, gap="8px"))
label(code).place(top, anchor="bottom_left")
shown.append(drawable.place(bottom, anchor="top_left"))
scene.play([drawable.animate.fade_in() for drawable in shown], duration=GROW)
scene.wait(HOLD)
# Names and the markup default: with text_markup=False, * and _ stay as
# typed, and a text that passes markup=True styles them again.
scene.segment("names_and_markup")
table(
[
("FIELD_NOTES.name", readout(repr(FIELD_NOTES.name)), None),
("scene.canvas.theme", readout(repr(scene.canvas.theme)), None),
("FIELD_NOTES.text_markup", readout(repr(FIELD_NOTES.text_markup)), None),
('scene.text("tide_level, *stars*")', scene.text("tide_level, *stars*"), None),
('scene.text("*bold*, _italic_", markup=True)', scene.text("*bold*, _italic_", markup=True), None),
],
columns=("11fr", "5fr"),
anchor="left",
)
# validate() lists the colors that do not stand out enough from the
# background, for the active theme or any other; it never rejects a theme.
# DIM derives FIELD_NOTES with darker title and muted colors; every
# built-in theme passes.
scene.segment("validate")
DIM = Theme(FIELD_NOTES, name="field-notes-dim", colors={"muted": palette.slate[600], "title": palette.slate[500]})
stacked([
("scene.canvas.validate_theme()", readout(repr(scene.canvas.validate_theme()))),
("DIM.validate()", readout("\n".join(DIM.validate()))),
(
"[name for name in Theme.schemes() if Theme(name).validate()]",
readout(repr([name for name in Theme.schemes() if Theme(name).validate()])),
),
])
STEP = GROW + HOLD
# The charts segment draws its axes, then its marks.
STARTS = [0, STEP, 2 * STEP, 3 * STEP, 4 * STEP + GROW, 5 * STEP + GROW, 6 * STEP + GROW]
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment settled, and the charts while their marks draw.
scene.snapshots(snapshots, sorted([start + 1.8 for start in STARTS] + [3 * STEP + 1.1]))
else:
scene.render()Backgrounds
The scene background, solid and gradient segment backgrounds, dot grids, mesh gradients, auroras and WGSL shader backgrounds.

gaanim examples/style/backgrounds.pyCode · 138 lines
"""Backgrounds: the scene's background, solid and gradient segment backgrounds, living backgrounds and WGSL shaders.
``scene.canvas.background`` paints behind every segment; a segment's own
``background=`` replaces it while that segment plays, and a segment without
one goes back to it. A background is a color, a ``Brush`` or a
``Background``: the living ones drift with the timeline, and a shader is
drawn by your own WGSL. Each panel holds the code that sets the background
around it.
"""
import os
from pathlib import Path
from gaanim import Background, Brush, ColorMap, Scene, Zones
SHOW = 0.4
HOLD = 1.8
scene = Scene()
background = scene.canvas.color("background")
panel = scene.canvas.color("panel")
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
# Palettes for the backgrounds, from two color maps: four blues from dark
# to light, and a sunset from dark red to yellow.
NIGHT = ColorMap("lapaz").colors(4)
DUSK = ColorMap("lajolla").reversed().colors(4)[1:]
# The frame is 16 x 9 scene units around the origin; brushes on a background
# use scene coordinates.
HALF_WIDTH = scene.canvas.frame_width / 2
def show(code, moves=()):
"""Fade in a panel at the bottom of the frame holding ``code``, then hold it while ``moves`` play."""
_, strip = scene.layout.zones(Zones.rows(["4fr", "1fr"], padding="page_padding"))
text = scene.text(code, role="code")
box = scene.layout.box(text, padding=("16px", "28px"), background=panel, radius="12px")
box.place(strip, anchor="bottom")
scene.play([box.animate.fade_in()], duration=SHOW)
if moves:
scene.play(list(moves), duration=HOLD)
else:
scene.wait(HOLD)
# The scene background shows in every segment without a background of its
# own; without one, it is the theme's.
scene.canvas.background = Background.dot_grid(0.5, radius=0.035, background=background)
scene.segment("scene")
show("scene.canvas.background = Background.dot_grid(\n 0.5, radius=0.035, background=background)")
# A color, or a gradient brush laid out in scene coordinates: x runs from
# -8 to 8 across the frame and y from -4.5 to 4.5. Background(paint) wraps
# either one the same way.
scene.segment("color", background=NIGHT[0])
show('scene.segment("color", background=NIGHT[0])')
scene.segment("linear", background=Brush.linear(NIGHT, start=(-HALF_WIDTH, 0), end=(HALF_WIDTH, 0)))
show('scene.segment("linear",\n background=Brush.linear(NIGHT, start=(-8, 0), end=(8, 0)))')
scene.segment("radial", background=Brush.radial(DUSK + [background], center=(0, -4.5), radius=9))
show('scene.segment("radial", background=Brush.radial(\n DUSK + [background], center=(0, -4.5), radius=9))')
# Living backgrounds move with the timeline, so every seek of a frame looks
# the same. speed scales the motion (0 holds it) and seed picks another layout.
scene.segment("mesh_gradient", background=Background.mesh_gradient(NIGHT[:2] + DUSK, speed=0.4, seed=4))
show("Background.mesh_gradient(NIGHT[:2] + DUSK, speed=0.4, seed=4)")
scene.segment("noise_gradient", background=Background.noise_gradient(DUSK, scale=1.6, speed=0.15, seed=1))
show("Background.noise_gradient(DUSK, scale=1.6, speed=0.15, seed=1)")
scene.segment("noise_scale", background=Background.noise_gradient(NIGHT, scale=4, speed=0.15, seed=2))
show("Background.noise_gradient(NIGHT, scale=4, speed=0.15, seed=2)")
# One curtain of light per color, over a night sky that sky= can change.
scene.segment("aurora", background=Background.aurora([success, NIGHT[3], accent], speed=0.4, seed=2))
show("Background.aurora([success, NIGHT[3], accent], speed=0.4, seed=2)")
scene.segment("aurora_sky", background=Background.aurora([success, NIGHT[3]], sky=DUSK[0], speed=0.4, seed=5))
show("Background.aurora([success, NIGHT[3]], sky=DUSK[0], speed=0.4, seed=5)")
# Dots every spacing units; the color keeps its alpha (DOTS is the accent
# at 160 of 255) and drift scrolls the grid in units per second.
DOTS = (accent.r, accent.g, accent.b, 160)
scene.segment(
"dot_grid", background=Background.dot_grid(0.6, radius=0.08, color=DOTS, background=NIGHT[0], drift=(0.4, 0.2))
)
show("Background.dot_grid(0.6, radius=0.08, color=DOTS,\n background=NIGHT[0], drift=(0.4, 0.2))")
# A shader defines gaanim_background(uv, resolution, time): uv runs from the
# top left (0, 0) to the bottom right (1, 1) and time is the timeline's, so
# seeks and exports agree. fallback paints outside the frame.
RIBBONS = """
fn gaanim_background(uv: vec2<f32>, resolution: vec2<f32>, time: f32) -> vec4<f32> {
let q = (uv - vec2<f32>(0.5)) * vec2<f32>(resolution.x / resolution.y, 1.0);
let warped = q + 0.08 * vec2<f32>(sin(q.y * 4.2 + time * 0.6), sin(q.x * 3.6 - time * 0.5));
let folds = 0.5 + 0.5 * sin(warped.y * 9.0 + 1.5 * sin(warped.x * 3.3 - time * 0.4));
let ribbon = smoothstep(0.55, 0.95, folds) * smoothstep(0.1, 0.8, length(q));
let color = vec3<f32>(0.03, 0.04, 0.07) + vec3<f32>(0.2, 0.35, 0.55) * ribbon;
return vec4<f32>(color, 1.0);
}
"""
scene.segment("shader", background=Background.shader(RIBBONS, fallback=NIGHT[0]))
show("Background.shader(RIBBONS, fallback=NIGHT[0])")
# uniforms hand the shader values it reads from gaanim_uniforms, evaluated
# every frame: here a parameter that brightens the rings as it animates.
RINGS = """
fn gaanim_background(uv: vec2<f32>, resolution: vec2<f32>, time: f32) -> vec4<f32> {
let p = (uv - vec2<f32>(0.5)) * gaanim_frame_size(resolution);
let d = length(p);
let rings = 0.5 + 0.5 * cos(d * 3.0 - time * 2.0);
let glow = exp(-d * 0.3) * gaanim_uniforms.level;
let color = vec3<f32>(0.07) + vec3<f32>(0.95, 0.65, 0.25) * rings * glow;
return vec4<f32>(color, 1.0);
}
"""
level = scene.viz.parameter(0.0)
scene.segment("uniforms", background=Background.shader(RINGS, uniforms={"level": level}, fallback=background))
show('Background.shader(RINGS, uniforms={"level": level})\nlevel.animate.set(1.0)', [level.animate.set(1.0)])
# A Path loads the WGSL from a file.
scene.segment("shader_file", background=Background.shader(Path("assets/graph_paper.wgsl"), fallback=background))
show('Background.shader(Path("assets/graph_paper.wgsl"))')
# A segment without background= shows the scene background again.
scene.segment("scene_again")
show('scene.segment("scene_again")')
STEP = SHOW + HOLD
# Every segment settled; the ones that move also early on, to show them change.
MOVING = (4, 7, 9, 10, 11, 12)
SEEKS = [STEP * index + t for index in range(14) for t in ((0.5, 2.0) if index in MOVING else (2.0,))]
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
scene.snapshots(snapshots, SEEKS)
else:
scene.render()