Text¶
Text¶
Text is a multi-line text node. The constructor takes an optional string,
split on \n into lines:
Output image
Output image
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
Output image
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
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
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.
After adding fonts, refer to them by family name in .font():
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.
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:
set_default_font("monospace", 22, bold=True, fill="steelblue")
with Scene(280, 40):
Text("Styled by default")
Output image
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 forTextnodes — a bareRect()/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 inprologue.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
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)
valueis a fraction in[0, 1](True/Falseare accepted as1/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
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 withfont(size=)for free — the three knobs are rarely touched:
- Available on
Text,TextGroupandTextSpan, and inherited likeitalic— 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 astrike()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
dur=/ease=behave like every other animatable setter — per-call,.anim()proxy, or an enclosinganim()block default. Nodur=means an instant change, matching the rest of the API.strike()andunderline()are independent — combine them freely, or in aPar()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.
Output image
When only one axis is given, the other is derived automatically to preserve the aspect ratio:
Output image
When both axes are given, the block is fit into the box scaled uniformly and centered
(letterboxed) by default (keep_aspect=True):
Output image
Pass .keep_aspect(False) to stretch the block to exactly fill the box instead:
Output image
.expand() fills the parent completely — handy for poster-style text:
Output image
.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=)):
Output image
.text_align(mode) sets how the block's lines are positioned relative to each other —
"left" (default), "center", "right", or "justify":
Output image
Output image
"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
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
.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):
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:
Output image
with Scene():
t = Text("Grow").font(size=16).xy(20, 60).fill("darkslateblue")
t.pivot("top_left")
t.scale(1.8)
Output image
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
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
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
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
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
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
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.
Inline color and style¶
Use a color attribute on any named tag to set the text color:
Output image
with Scene():
stext('<span color="#e06c75" bold>ERROR</span> something\nwent wrong').font("monospace", 22)
Output image
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:
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 Code —
stext() 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:
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.