Skip to content

Text

Text

Text is a multi-line text node. The constructor takes an optional string, split on \n into lines:

with Scene():
    Text("Hello, FairyFlow!").font(size=28).fill("steelblue")

Output image

fairyflow frame 0
with Scene():
    Text("First line\nSecond line\nThird line").font(size=22).fill("steelblue")

Output image

fairyflow frame 0

Use .line(text="") to start additional lines and style them individually, and .span(text) to add another inline run to the current (last) line:

with Scene():
    t = Text("First line").font(size=22).fill("darkslateblue")
    t.line("Second line").fill("steelblue")
    t.line("Third line").fill("cornflowerblue")

Output image

fairyflow frame 0
with Scene():
    t = Text("INFO ").font("monospace", 22)
    t.span("server started").fill("gray")

Output image

fairyflow frame 0

Inline groups

Calling .span(text) more than once without an intervening .line() joins the runs onto the same line — .group() is only needed when you want a handle to a nested sub-group of runs (e.g. to name or style them together):

with Scene():
    t = Text()
    g = t.group()
    g.span("Bold").font(size=26, bold=True).fill("darkred")
    g.span("  normal  ").font(size=26).fill("gray")
    g.span("Italic").font(size=26, italic=True).fill("darkblue")

Output image

fairyflow frame 0

Font styling

All text nodes share one structured style setter, .font(family=None, size=None, *, weight=, italic=, bold=, mono=, dur=, ease=), plus .fill(c) for the text fill color:

Parameter Effect
family (positional) Font family name, e.g. "serif", "monospace", or font name
size (positional) Font size in pixels
weight= Numeric weight (100–900)
bold=True Shortcut for weight=800 (bold=False resets to weight=400)
mono=True Shortcut for family="monospace" (mono=False resets to "sans-serif")
italic= Enable/disable italic

bold=/weight= are mutually exclusive (as are mono=/family) — passing both raises TypeError.

with Scene():
    t = Text()
    t.span("Small").font(size=14).fill("gray")
    t.span("Medium").font(size=22).fill("steelblue")
    t.span("Large").font(size=36, bold=True).fill("darkslateblue")

Output image

fairyflow frame 0

To use your own font files, add a font_directories key to fairyflow.toml. FairyFlow scans each listed directory recursively and loads all fonts it finds (.ttf, .otf, .ttc, .otc, .woff, .woff2). Paths are relative to the project root.

font_directories = ["fonts"]

After adding fonts, refer to them by family name in .font():

t.span("Custom text").font("MyFont", 24)

Font aliases

The [font-aliases] table in fairyflow.toml maps CSS generic family names to specific fonts. This lets you pin which font backs "sans-serif", "monospace", etc., so renders are consistent across machines regardless of system fonts.

[font-aliases]
sans-serif = "DejaVu Sans"
monospace  = "DejaVu Sans Mono"
serif      = "DejaVu Serif"

Any CSS generic family name is accepted as a key: serif, sans-serif, monospace, cursive, fantasy, system-ui, ui-serif, ui-sans-serif, ui-monospace, ui-rounded, emoji, math, fangsong. Generic families not listed here keep their system defaults.

The target font must be loaded — either from font_directories or from system fonts — before the alias takes effect.

Project-wide defaults

A deck is usually one font, at one size, in one color. Rather than repeating .font("Inter", 28).fill("white") on every Text node, call set_default_font() once in prologue.py:

# prologue.py
from fairyflow import *

set_default_font("Inter", 28, fill="white")
set_default_font("monospace", 22, bold=True, fill="steelblue")
with Scene(280, 40):
    Text("Styled by default")

Output image

fairyflow frame 0

set_default_font(family=None, size=None, *, weight=, italic=, bold=, mono=, fill=None) takes the same style parameters as .font(), plus fill= for the default text color (accepts anything .fill() does — a color string or a gradient()). Passing nothing for a parameter leaves that default alone, so calls compose: set_default_font("Inter") followed later by set_default_font(size=28) is the same as one call with both.

A few things worth knowing:

  • Text only. fill= only changes the default for Text nodes — a bare Rect()/Ellipse()/Path() is unaffected.
  • A later .font()/.fill() call on a node always wins — the configured default only fills in whatever the node doesn't set itself.
  • Like set_default_scene(), this is evaluated once when the prologue runs, before any scene exists, so it is not animatable and belongs in prologue.py, not inside a scene file.

Text decorations

.underline() and .strike() are animatable 0→1 progress attributes, not booleans — a presentation tool underlines a word at a moment, so the line sweeps in rather than blinking on:

with Scene():
    t = Text().font(size=28).fill("black")
    w = t.span("prime").fill("darkred")
    w.underline()
    t.span(" numbers")

Output image

fairyflow frame 0
w.underline(dur=0.4)  # sweeps left-to-right over 0.4s
w.underline(0)  # sweeps back out
Text("old value").strike(dur=0.3)  # cross out a superseded number
underline(value=1, *, color=None, width=None, offset=None, dur=None, ease=None)
strike(value=1, *, color=None, width=None, offset=None, dur=None, ease=None)
  • value is a fraction in [0, 1] (True/False are accepted as 1/0), measured along the decoration's total length — a run wrapped across several visual rows draws in continuously across all of them:
with Scene():
    t = Text().font(size=20).wrap(180).fill("black")
    t.span("one two three four five six seven eight nine").underline()

Output image

fairyflow frame 0
  • color= defaults to the run's own fill; width=/offset= default to the font's own underline/strikeout metrics at the run's resolved size, so decorations scale with font(size=) for free — the three knobs are rarely touched:
w.underline(color="darkorange", width=3, offset=6)
  • Available on Text, TextGroup and TextSpan, and inherited like italic — a run with no .underline()/.strike() of its own picks up its enclosing block's value; .underline(0) on a run opts it out of an inherited decoration. color=/width=/offset= are not inherited — each defaults independently per run, which is what makes a strike() over a .sh()-highlighted line take each token's own color rather than one flat color:
with Scene(background="#2b303b"):
    t = code('x = "hello world"', "python", theme="base16-ocean.dark")
    t.strike()

Output image

fairyflow frame 0
  • dur=/ease= behave like every other animatable setter — per-call, .anim() proxy, or an enclosing anim() block default. No dur= means an instant change, matching the rest of the API.
  • strike() and underline() are independent — combine them freely, or in a Par() if they should animate together.

Sizing

By default a Text node's box is the measured extent of the laid-out text — whatever width/height the current font size and line breaks produce. Call .size(w=, h=) to scale the finished block to fit an explicit box; this does not re-layout the text or move any line breaks, it magnifies the whole block as a unit — the same fit model Image uses.

with Scene():
    Text("Hi").font(size=16).size(w=100)

Output image

fairyflow frame 0

When only one axis is given, the other is derived automatically to preserve the aspect ratio:

with Scene():
    Text("Hi").font(size=16).size(h=80)

Output image

fairyflow frame 0

When both axes are given, the block is fit into the box scaled uniformly and centered (letterboxed) by default (keep_aspect=True):

with Scene():
    Text("Hi").font(size=16).size(100, 60)

Output image

fairyflow frame 0

Pass .keep_aspect(False) to stretch the block to exactly fill the box instead:

with Scene():
    Text("Hi").font(size=16).size(100, 60).keep_aspect(False)

Output image

fairyflow frame 0

.expand() fills the parent completely — handy for poster-style text:

with Scene():
    Text("Hi").font(size=16).expand()

Output image

fairyflow frame 0

.font(size=) and .size() are not the same thing: .font(size=) changes the layout — metrics and where line breaks fall. .size() magnifies the already laid-out result; line breaks never move.


Wrapping and alignment

By default a line never breaks on its own — it's exactly as wide as its text. Call .wrap(width) to break a line automatically at word boundaries once it would exceed width (measured before any .size() scaling, in the same unscaled layout units as .font(size=)):

with Scene():
    Text("The quick brown fox jumps over the lazy dog").font(size=16).wrap(160)

Output image

fairyflow frame 0

.text_align(mode) sets how the block's lines are positioned relative to each other — "left" (default), "center", "right", or "justify":

with Scene():
    Text("Hi\nA longer second line").font(size=16).wrap(220).text_align("center")

Output image

fairyflow frame 0
with Scene():
    Text("Hi\nA longer second line").font(size=16).wrap(220).text_align("right")

Output image

fairyflow frame 0

"justify" stretches inter-word spacing so every line except the last fills the wrap width exactly:

with Scene():
    Text("The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.").font(size=16).wrap(
        220
    ).text_align("justify")

Output image

fairyflow frame 0

Call .text_align() on an individual TextGroup line to override the block's default for just that line. .line() with no text starts an empty group — keep a reference to it (rather than to .span()'s return value) to call .text_align() on it:

with Scene():
    t = Text().font(size=16).wrap(220).text_align("left")
    t.line("This is just a very long line")
    right_line = t.line()
    right_line.span("Right aligned")
    right_line.text_align("right")
    right_line = t.line()
    right_line.span("Center aligned")
    right_line.text_align("center")

Output image

fairyflow frame 0

.wrap() and .size() compose the same way .font(size=) and .size() do: .size() fits the box to the wrapped measured extent, not the wrap width itself.

.wrap() resets with DEFAULT (there is no "auto wrap" default to fall back to — it's either on with an explicit width, or off entirely):

t.wrap(DEFAULT)  # turns wrapping back off

Transforms

Text supports .rotate(), .scale()/.scale_x()/.scale_y(), and .pivot(), same as Rect/Ellipse/Image — the whole resolved box (after any .size() fit) rotates/scales around its pivot, default the box center:

with Scene():
    Text("Rotated").font(size=20).xy(20, 60).fill("darkred").rotate(20)

Output image

fairyflow frame 0
with Scene():
    t = Text("Grow").font(size=16).xy(20, 60).fill("darkslateblue")
    t.pivot("top_left")
    t.scale(1.8)

Output image

fairyflow frame 0

Positioning

Text supports .xy(), .align(), and .move() for placement — see Positioning for details.

Placing individual lines and runs

A TextGroup line (from .line()) or a TextSpan run (from .span()) is placeable too: .xy()/.pos()/.move()/.next_to() override where that one run draws, without reflowing its siblings — the rest of the paragraph keeps its normal layout, leaving a gap where the overridden run used to sit. DEFAULT restores the paragraph position. This is the "word flies out of the sentence" primitive:

with Scene():
    t = Text().font(size=16).xy(4, 20).fill("black")
    t.span("The quick brown ")
    fox = t.span("fox")
    fox.fill("darkred")
    fox.move(15, 60)
    t.span(" jumps")

Output image

fairyflow frame 0

Overriding a TextGroup line moves every descendant span that doesn't have its own override, as a rigid unit — a nested span's own override always wins over its parent line's (nearest self-or-ancestor, independently per axis):

with Scene():
    t = Text().font(size=16).xy(4, 20).fill("black")
    t.line("Untouched line")
    line2 = t.line()
    line2.span("Moved ").fill("darkblue")
    line2.span("line")
    line2.xy(60, 100)

Output image

fairyflow frame 0

A line or run also supports .rotate()/.scale()/.scale_x()/.scale_y()/ .pivot() — spinning or growing it about its own measured box, in place:

with Scene():
    t = Text().font(size=20).xy(20, 40).fill("black")
    t.span("spin ")
    word = t.span("me")
    word.fill("darkred")
    word.rotate(30)

Output image

fairyflow frame 0
with Scene():
    t = Text().font(size=16).xy(20, 40).fill("black")
    t.span("grow ")
    word = t.span("me")
    word.fill("darkblue")
    word.pivot("top_left")
    word.scale(2.0)

Output image

fairyflow frame 0

Rotate/scale/pivot resolve independently of .xy()/.move() (their own nearest- self-or-ancestor cascade), so a run can spin in place and fly elsewhere at the same time — the run spins about its own natural position first, then the position override carries it to its new spot:

with Scene():
    t = Text().font(size=20).xy(20, 40).fill("black")
    t.span("spin and move ")
    word = t.span("me")
    word.fill("darkred")
    word.rotate(45)
    word.xy(180, 30)

Output image

fairyflow frame 0

Like position, a TextGroup line's transform applies to every descendant run that doesn't have its own closer override, as one rigid unit:

with Scene():
    t = Text().font(size=16).xy(60, 20).fill("black")
    t.line("Untouched line")
    line2 = t.line()
    line2.span("Rotated ").fill("darkblue")
    line2.span("line")
    line2.rotate(15)

Output image

fairyflow frame 0

stext

stext is a helper that builds a Text node from a string with optional inline markup. Tags can carry style attributes that are applied immediately, and the tag name is also preserved as a .name() on the span or group so you can look it up later.

# Plain text — equivalent to Text().span("Hello")
t = stext("Hello, world!")

Inline color and style

Use a color attribute on any named tag to set the text color:

with Scene():
    stext('<info color="green">INFO</info> server started').font("monospace", 22)

Output image

fairyflow frame 0
with Scene():
    stext('<span color="#e06c75" bold>ERROR</span> something\nwent wrong').font("monospace", 22)

Output image

fairyflow frame 0

Supported attributes

Attribute Effect
color='...' Text fill color (any CSS color)
font-size='N' Font size in pixels (also accepted: text-size)
font='...' Font family
font-weight='N' Numeric weight (100–900)
bold Shorthand for font-weight='800'
italic Enable italic

Multiple attributes on one tag are all applied:

stext("<span color='orange' font-size='20' bold>warning</span> check the logs")

Named spans

The tag name is still recorded via .name(), so you can look the span up and restyle it later:

t = stext("<title>FairyFlow\n<subtitle>Animation for Python")
t.find_node(name="title").font(size=32, bold=True).fill("steelblue")
t.find_node(name="subtitle").font(size=18).fill("gray")

Literal < characters

A < that has no matching > is treated as literal text, so you can safely pass arbitrary content (terminal output, ASCII art, file paths) without escaping:

stext("a < b")  # → "a < b"
stext(
    "path/to/<file>"
)  # → tag named "file" — wrap in a real tag name only when intended

By default stext uses <tag>...</tag> syntax. Pass delimiters="[]" to use [tag]...[/tag] instead.

For source-code snippets with syntax highlighting, see Codestext() runs everything through its markup parser, which is a trap for code containing <, >, or &.


Typewriter reveal

.type_on(dur=) animates glyphs appearing progressively, in reading order:

with Scene():
    Text("fairyflow renders this\nletter by letter").type_on(dur=1.5)

Output video

Glyphs pop in at their already-laid-out final position — the text never reflows as more of it becomes visible, and dur=/ease= follow the usual conventions (ease="linear" by default). Revealing is a whole-glyph cutoff, not a character-exact or fractional-glyph reveal, so timing is a close visual approximation rather than precise per-character pacing.