cmd-art
Render and design terminal visual effects with fx, a living-template engine: an Effect ABC + a @register decorator + pkgutil auto-discovery, so effects, themes, and multi-effect shows all compose. Pure Python stdlib (optional pyfiglet for fonts, PIL for real images). Truecolor + ANSI, tuned for Windows Terminal but works on any VT-capable terminal.
When to use
- User asks for a terminal/CMD animation or ANSI art: "spinning ball", "donut", "matrix rain", "plasma", "fire", "game of life", "3D title", "rainbow banner", "starfield", "tunnel", "fireworks".
- User wants a static ASCII title/banner with a color gradient, or an image turned into ASCII.
- User wants to invent a new console effect (drop one file in
fx/effects/).
The 30 effects (registry names)
Run python -m fx list for the live catalog. Names + aliases as registered:
| name |
kind |
what |
aliases |
sphere |
anim |
rotating Lambert-shaded 3D ball ("spinning ball") |
|
donut |
anim |
THE spinning ASCII torus (Sloane donut.c) |
|
solarsystem |
anim |
orrery: planets on elliptical orbits round a pulsing sun |
orrery, planets |
cube |
anim |
rotating wireframe cube |
|
tunnel |
anim |
demoscene tunnel fly-through |
|
starfield |
anim |
perspective starfield warp |
|
plasma |
anim |
full-screen sine interference field |
wave |
julia |
anim |
animated Julia-set fractal (morphing c) |
juliaset |
mandelbrot |
anim |
infinite zoom into the Mandelbrot set |
mandel |
perlin |
anim |
flowing Perlin gradient-noise field (clouds) |
noise, clouds |
flames |
anim |
noise-convection fire, black-body color (vs cellular fire) |
firefield, bonfire |
water |
anim |
water surface: sum-of-sines swell + caustic net |
ocean, waves |
nebula |
anim |
domain-warped gas nebula: filaments, multi-color, stars |
galaxy, cosmos |
spectrum_bars |
anim |
audio-style spectrum meter: log bins, gravity smoothing, eighth-blocks |
spectrum, bars |
text_flyin |
anim |
text intro: characters fly in and land |
flyin |
text_converge |
anim |
text intro: characters converge from a scattered cloud |
converge |
text_decrypt |
anim |
text intro: cells churn glyphs, then lock to the word |
decrypt_text |
fire |
anim |
demoscene fire (heat buffer + cooling) |
|
rain |
anim |
matrix digital rain |
|
life |
anim |
Conway's Game of Life, toroidal |
game_of_life |
boids |
anim |
Reynolds flocking |
|
cbonsai |
anim |
procedural ASCII bonsai grown by a stochastic branching turtle |
|
fireworks |
anim |
rising shells → gravity sparks |
|
sparkle |
anim |
dim text under bright glints |
|
text3d |
anim* |
big figlet/block banner, gradient; shimmer animates |
banner |
gradient_text |
anim |
lolcat rainbow / theme-gradient text |
lolcat |
banner_scroll |
anim |
scrolling figlet marquee |
marquee |
typewriter |
anim |
char-by-char reveal with a cursor |
|
decrypt |
anim |
scramble-to-plaintext reveal |
|
image2ascii |
static |
image → half-block truecolor / ASCII ramp |
img |
*text3d is static unless --set shimmer=true. image2ascii is a StaticEffect (one frame to the normal screen). Everything else animates in the alt-screen.
How to run
From skills/cmd-art, run the package:
python -m fx <command> [args]
Or by path from any cwd (a PEP-366 prelude bootstraps the package):
python skills/cmd-art/fx/cli.py <command> [args]
Legacy compat shim (preserves the old surface, translates to fx play):
python skills/cmd-art/scripts/ascii_fx.py [--fps N] [--seconds N] [--once] [--width W] [--height H] <sphere|plasma|wave|rain|text3d> [args]
Prefer the fx CLI for new work; use the shim only for old commands/imports.
Commands
list [--tag T] [--json] — every registered effect (kind, tags, description, aliases). --tag filters (e.g. 3d, text, particles, classic, field, matrix). --json emits full param schemas.
show <name> — one effect's details: animated?, tags, aliases, preferred theme, and every --set param (kind, default, choices, range, help), plus a ready-to-run example. show also plays scripted shows via --seq/--script (see Compose a show).
play <name> [flags] — play one effect. Bounded by default (10s on a TTY when no --seconds/--frames). Flags:
--theme NAME — palette (see Themes). Defaults to the effect's preferred_theme.
--seconds N — stop after N seconds.
--frames N — stop after N frames.
--once — render ONE frame to the normal screen and exit (best for text3d, image2ascii, previews).
--forever — explicitly unbounded (until Ctrl-C).
--fps N — frame rate (default: the effect's own default_fps).
--width W / --height H — 0 = terminal size (height leaves 1 row headroom).
--set K=V — override an effect param (repeatable). Unknown keys fail loudly.
gallery [--seconds-per N] [--tag T] [--theme NAME] — tour every animated effect back-to-back in one alt-screen (default 3s each).
random [play-flags] — play a random animated effect with a random theme (defaults to 8s). Ignores --once (always plays the animation).
Non-TTY safety: play/shows degrade to a single plain frame when stdout is not a terminal, and the play loop always restores the terminal (cursor, wrap, alt-screen) via try/finally. Prefer --seconds/--once in automated contexts so the process always terminates.
Themes
--theme names (from fx/theme.py, live via theme_names()):
mono, fire, ocean, synthwave, viridis, pastel, matrix-green, rainbow.
Default theme is synthwave. Effects never bake in hex; they sample the active Theme (color_at(t) gradient, cycle(phase) HSV/lolcat sweep, .primary/.accent/.base), so swapping the theme restyles every effect for free. Palette caveat: flames, water, and nebula default to a built-in physical palette (blackbody / ocean / cosmic) and only follow --theme with --set palette=theme; plasma, julia, mandelbrot, and perlin default to palette=theme. An unknown name passed to the --theme flag (play, show, gallery, random) fails loud; only an unknown theme name inside a --seq/--script segment slot silently falls back to the default.
Example commands
- Spinning ball, 5s, ocean:
python -m fx play sphere --seconds 5 --theme ocean
- THE donut, faster spin:
python -m fx play donut --set speed=2 --seconds 8
- Matrix rain:
python -m fx play rain --seconds 8
- Fire, shorter/calmer flames:
python -m fx play fire --set cool=2
- Game of Life, dense start:
python -m fx play life --set fill=0.35 --seconds 10
- 3D title, one static frame:
python -m fx play text3d --set text=KIRO --once
- Shimmering animated title:
python -m fx play text3d --set text=KIRO --set shimmer=true --seconds 6
- Rainbow lolcat text:
python -m fx play gradient_text --set text="hello" --theme rainbow
- Image → ASCII (half-block):
python -m fx play image2ascii --set path=pic.png --once
- Tour everything:
python -m fx gallery --seconds-per 3
- Surprise me:
python -m fx random
Compose a show
Play a timed sequence of segments in ONE alt-screen (no flicker between segments) via show:
- Compact
--seq grammar effect[:theme[:seconds]],..., with left|right split-screen in the effect slot:python -m fx show --seq "donut:fire:4,plasma::3,rain:matrix-green:5"
python -m fx show --seq "donut|fire:synthwave:6" # donut left, fire right
- JSON
--script show.json (list of segment objects):[
{"effect": "donut", "theme": "fire", "seconds": 4, "params": {"speed": 2}},
{"split": ["donut", "fire"], "themes": ["ocean", "fire"], "seconds": 6}
]
--seconds-per sets the default per-segment duration (default 4s). A segment that omits a theme uses its effect's preferred_theme (default synthwave otherwise); the show/gallery --theme flag is validated but does not currently affect segment colors. Each segment gets a local clock (t restarts at 0). Split is a combinator built from two real effects, not a registered effect.
Knowledge base — look before you build
Not sure how to render something? Start at knowledge/effects/choosing-an-effect.md
— it maps "I want to show X" to a direction, the exact-formula note, and the
shipped effect you can reuse or take apart (reuse as-is, tune params, or compose
your own from the primitives it cites). From there, or directly:
Before inventing or tuning an effect, consult the SmartCLI knowledge graph at
knowledge/INDEX.md (repo root, two levels above this skill's folder). Its
effects/ domain carries exact-formula
notes for most of what ships here — [[donut-torus]], [[plasma]], [[fire-lode]] /
[[fire-doom-psx]], [[matrix-rain]], [[game-of-life]], [[boids]], [[starfield]],
[[tunnel]], [[rotating-cube]], [[ascii-luminance-ramp]] — plus [[color-interpolation]]
and [[hsv-cycling-lolcat]] for theme/lolcat color. The works/ wing ([[donut-c]],
[[lolcat]], [[cmatrix]], [[terminaltexteffects]], ...) is the design brain for a NEW
effect: pick your lane — replica a real effect by measuring its source math, or
compose a new one from documented primitives. Read the relevant note before writing
render code from scratch; references/effects.md is the local techniques sampler, the
graph is the sourced depth.
Add a NEW effect
Drop one module in fx/effects/ — pkgutil auto-imports it, @register wires it into list/show/play/gallery. A module that fails to import is reported (stderr + registry.load_errors()) but never takes the catalog down.
Minimal contract (fx/base.py):
- Subclass
Effect (or StaticEffect for one-shot art). Set class metadata: name (lowercase, unique registry key), description, tags, optional aliases, preferred_theme, default_fps, and a params tuple of Param(...).
Param(name, kind, default, help, choices=None, min=None, max=None) — kind ∈ int float str bool color. The CLI lists/parses/validates --set against these; unknown keys and out-of-range values fail loud. color accepts #RRGGBB/RRGGBB (empty = None).
- Implement
render(self, ctx: FrameCtx) -> str: return ONE full frame — ctx.height rows joined by \n, no trailing newline, EVERY cell written. Effects are pure frame producers: never print, sleep, or touch ANSI modes — the play loop owns the terminal.
ctx: FrameCtx fields: t (seconds since start), frame_index, width, height, theme (active Theme, never None), params (coerced dict).
- Optional lifecycle:
setup() (allocate buffers/particles before the first frame) and teardown() (release state; also runs on error/Ctrl-C). Override is_animated(cls, params) when animation depends on a param — in either direction: text3d is static by default and only animates with --set shimmer=true; gradient_text animates by default and goes static with --set drift=0.
Skeleton:
from fx.base import Effect, FrameCtx, Param
from fx.registry import register
@register
class Swirl(Effect):
name = "swirl"
description = "example: a themed diagonal sweep."
tags = ("field", "demo")
default_fps = 24.0
params = (Param("speed", "float", 1.0, "sweep speed", min=0.0, max=10.0),)
def render(self, ctx: FrameCtx) -> str:
rows = []
for y in range(ctx.height):
cells = []
for x in range(ctx.width):
t = ((x + y) / max(1, ctx.width + ctx.height)
+ ctx.t * ctx.params["speed"] * 0.1) % 1.0
r, g, b = ctx.theme.color_at(t)
cells.append(f"\x1b[38;2;{r};{g};{b}m#")
rows.append("".join(cells) + "\x1b[0m")
return "\n".join(rows)
Then python -m fx play swirl --theme rainbow --seconds 5. Duplicate names raise RegistryError (pass @register(replace=True) to intentionally override a built-in).
Constraints / gotchas
- Keep frames exactly
ctx.height rows and ctx.width cells; auto-size leaves 1 row headroom so the top doesn't scroll off.
plasma and full-field effects are the heaviest (O(width×height) escapes/frame); shrink dimensions or lower --fps if they stutter.
pyfiglet powers text3d/banner_scroll/gradient_text --set big=true if importable; otherwise a built-in block font covers A-Z, 0-9, space. image2ascii uses PIL if present, else a PNM/built-in demo fallback.
- ANSI on Windows is enabled automatically before every
play/render_once (enable_vt()), no action needed.
references/effects.md is a techniques sampler — it works a few effects in depth (sphere projection, block-text gradient, plasma field, matrix rain), not one section per shipped effect. Read it, plus the per-effect formula notes in knowledge/effects/ (see Knowledge base above), when adding or tuning an effect.
- Path bases:
fx/, references/, tmux/, and scripts/ are relative to this skill's folder; knowledge/ is relative to the repo root (two levels above it).
Optional tmux launchers
The play loop already owns a single alt-screen session, so tmux is never required. For convenience, tmux/ ships two POSIX-sh launchers that drop an effect into a tmux popup or split pane:
tmux/fx-popup.sh <effect|gallery|random> [fx args] — display-popup -E (tmux >= 3.2).
tmux/fx-split.sh [-v] EFFECT [EFFECT2] [fx args] — split the current window (one effect in a new pane, or a 2-up of two).
Both guard on command -v tmux: with no tmux they print the direct/compositor alternative plus install hints and exit 0. play/random runs get a default --seconds 10 bound. Truecolor through tmux needs set -g default-terminal "tmux-256color" + set -ga terminal-overrides ",*:Tc". See tmux/README.md. For a seamless side-by-side without tmux, use the compositor: python -m fx show --seq "donut|fire:synthwave:6".
1---2name: cmd-art3description: Design and render terminal/CMD visual effects and ASCII art from a one-line request via the pluggable `fx` engine (30 effects, hot-swappable and themeable, plus scripted shows). Effects include donut, matrix rain, plasma, fire, a spinning 3D ball, a solar-system orrery, Game of Life, wireframe cube, 3D text banners, rainbow/lolcat gradient text, starfield, tunnel, fireworks, image-to-ASCII, and more. Use when the request is for a terminal animation, ANSI/CLI art, or a new console effect. Pure Python stdlib; truecolor.4---56# cmd-art78Render and design terminal visual effects with `fx`, a living-template engine: an `Effect` ABC + a `@register` decorator + pkgutil auto-discovery, so effects, themes, and multi-effect shows all compose. Pure Python stdlib (optional `pyfiglet` for fonts, `PIL` for real images). Truecolor + ANSI, tuned for Windows Terminal but works on any VT-capable terminal.910## When to use11- User asks for a terminal/CMD animation or ANSI art: "spinning ball", "donut", "matrix rain", "plasma", "fire", "game of life", "3D title", "rainbow banner", "starfield", "tunnel", "fireworks".12- User wants a static ASCII title/banner with a color gradient, or an image turned into ASCII.13- User wants to invent a new console effect (drop one file in `fx/effects/`).1415## The 30 effects (registry names)16Run `python -m fx list` for the live catalog. Names + aliases as registered:1718| name | kind | what | aliases |19|------|------|------|---------|20| `sphere` | anim | rotating Lambert-shaded 3D ball ("spinning ball") | |21| `donut` | anim | THE spinning ASCII torus (Sloane donut.c) | |22| `solarsystem` | anim | orrery: planets on elliptical orbits round a pulsing sun | `orrery`, `planets` |23| `cube` | anim | rotating wireframe cube | |24| `tunnel` | anim | demoscene tunnel fly-through | |25| `starfield` | anim | perspective starfield warp | |26| `plasma` | anim | full-screen sine interference field | `wave` |27| `julia` | anim | animated Julia-set fractal (morphing c) | `juliaset` |28| `mandelbrot` | anim | infinite zoom into the Mandelbrot set | `mandel` |29| `perlin` | anim | flowing Perlin gradient-noise field (clouds) | `noise`, `clouds` |30| `flames` | anim | noise-convection fire, black-body color (vs cellular `fire`) | `firefield`, `bonfire` |31| `water` | anim | water surface: sum-of-sines swell + caustic net | `ocean`, `waves` |32| `nebula` | anim | domain-warped gas nebula: filaments, multi-color, stars | `galaxy`, `cosmos` |33| `spectrum_bars` | anim | audio-style spectrum meter: log bins, gravity smoothing, eighth-blocks | `spectrum`, `bars` |34| `text_flyin` | anim | text intro: characters fly in and land | `flyin` |35| `text_converge` | anim | text intro: characters converge from a scattered cloud | `converge` |36| `text_decrypt` | anim | text intro: cells churn glyphs, then lock to the word | `decrypt_text` |37| `fire` | anim | demoscene fire (heat buffer + cooling) | |38| `rain` | anim | matrix digital rain | |39| `life` | anim | Conway's Game of Life, toroidal | `game_of_life` |40| `boids` | anim | Reynolds flocking | |41| `cbonsai` | anim | procedural ASCII bonsai grown by a stochastic branching turtle | |42| `fireworks` | anim | rising shells → gravity sparks | |43| `sparkle` | anim | dim text under bright glints | |44| `text3d` | anim* | big figlet/block banner, gradient; shimmer animates | `banner` |45| `gradient_text` | anim | lolcat rainbow / theme-gradient text | `lolcat` |46| `banner_scroll` | anim | scrolling figlet marquee | `marquee` |47| `typewriter` | anim | char-by-char reveal with a cursor | |48| `decrypt` | anim | scramble-to-plaintext reveal | |49| `image2ascii` | static | image → half-block truecolor / ASCII ramp | `img` |5051`*text3d` is static unless `--set shimmer=true`. `image2ascii` is a `StaticEffect` (one frame to the normal screen). Everything else animates in the alt-screen.5253## How to run54From `skills/cmd-art`, run the package:55```56python -m fx <command> [args]57```58Or by path from any cwd (a PEP-366 prelude bootstraps the package):59```60python skills/cmd-art/fx/cli.py <command> [args]61```62Legacy compat shim (preserves the old surface, translates to `fx play`):63```64python skills/cmd-art/scripts/ascii_fx.py [--fps N] [--seconds N] [--once] [--width W] [--height H] <sphere|plasma|wave|rain|text3d> [args]65```66Prefer the `fx` CLI for new work; use the shim only for old commands/imports.6768## Commands69- `list [--tag T] [--json]` — every registered effect (kind, tags, description, aliases). `--tag` filters (e.g. `3d`, `text`, `particles`, `classic`, `field`, `matrix`). `--json` emits full param schemas.70- `show <name>` — one effect's details: animated?, tags, aliases, preferred theme, and every `--set` param (kind, default, choices, range, help), plus a ready-to-run example. `show` also plays scripted shows via `--seq`/`--script` (see Compose a show).71- `play <name> [flags]` — play one effect. **Bounded by default** (10s on a TTY when no `--seconds`/`--frames`). Flags:72 - `--theme NAME` — palette (see Themes). Defaults to the effect's `preferred_theme`.73 - `--seconds N` — stop after N seconds.74 - `--frames N` — stop after N frames.75 - `--once` — render ONE frame to the normal screen and exit (best for `text3d`, `image2ascii`, previews).76 - `--forever` — explicitly unbounded (until Ctrl-C).77 - `--fps N` — frame rate (default: the effect's own `default_fps`).78 - `--width W` / `--height H` — `0` = terminal size (height leaves 1 row headroom).79 - `--set K=V` — override an effect param (repeatable). Unknown keys fail loudly.80- `gallery [--seconds-per N] [--tag T] [--theme NAME]` — tour every animated effect back-to-back in one alt-screen (default 3s each).81- `random [play-flags]` — play a random animated effect with a random theme (defaults to 8s). Ignores `--once` (always plays the animation).8283Non-TTY safety: `play`/shows degrade to a single plain frame when stdout is not a terminal, and the play loop always restores the terminal (cursor, wrap, alt-screen) via try/finally. Prefer `--seconds`/`--once` in automated contexts so the process always terminates.8485## Themes86`--theme` names (from `fx/theme.py`, live via `theme_names()`):87`mono`, `fire`, `ocean`, `synthwave`, `viridis`, `pastel`, `matrix-green`, `rainbow`.88Default theme is `synthwave`. Effects never bake in hex; they sample the active `Theme` (`color_at(t)` gradient, `cycle(phase)` HSV/lolcat sweep, `.primary`/`.accent`/`.base`), so swapping the theme restyles every effect for free. Palette caveat: `flames`, `water`, and `nebula` default to a built-in physical palette (`blackbody` / `ocean` / `cosmic`) and only follow `--theme` with `--set palette=theme`; `plasma`, `julia`, `mandelbrot`, and `perlin` default to `palette=theme`. An unknown name passed to the `--theme` flag (`play`, `show`, `gallery`, `random`) fails loud; only an unknown theme name inside a `--seq`/`--script` segment slot silently falls back to the default.8990## Example commands91- Spinning ball, 5s, ocean: `python -m fx play sphere --seconds 5 --theme ocean`92- THE donut, faster spin: `python -m fx play donut --set speed=2 --seconds 8`93- Matrix rain: `python -m fx play rain --seconds 8`94- Fire, shorter/calmer flames: `python -m fx play fire --set cool=2`95- Game of Life, dense start: `python -m fx play life --set fill=0.35 --seconds 10`96- 3D title, one static frame: `python -m fx play text3d --set text=KIRO --once`97- Shimmering animated title: `python -m fx play text3d --set text=KIRO --set shimmer=true --seconds 6`98- Rainbow lolcat text: `python -m fx play gradient_text --set text="hello" --theme rainbow`99- Image → ASCII (half-block): `python -m fx play image2ascii --set path=pic.png --once`100- Tour everything: `python -m fx gallery --seconds-per 3`101- Surprise me: `python -m fx random`102103## Compose a show104Play a timed sequence of segments in ONE alt-screen (no flicker between segments) via `show`:105- Compact `--seq` grammar `effect[:theme[:seconds]],...`, with `left|right` split-screen in the effect slot:106 ```107 python -m fx show --seq "donut:fire:4,plasma::3,rain:matrix-green:5"108 python -m fx show --seq "donut|fire:synthwave:6" # donut left, fire right109 ```110- JSON `--script show.json` (list of segment objects):111 ```json112 [113 {"effect": "donut", "theme": "fire", "seconds": 4, "params": {"speed": 2}},114 {"split": ["donut", "fire"], "themes": ["ocean", "fire"], "seconds": 6}115 ]116 ```117`--seconds-per` sets the default per-segment duration (default 4s). A segment that omits a theme uses its effect's `preferred_theme` (default `synthwave` otherwise); the `show`/`gallery` `--theme` flag is validated but does not currently affect segment colors. Each segment gets a local clock (t restarts at 0). `Split` is a combinator built from two real effects, not a registered effect.118119## Knowledge base — look before you build120**Not sure how to render something? Start at `knowledge/effects/choosing-an-effect.md`**121— it maps "I want to show X" to a direction, the exact-formula note, and the122shipped effect you can reuse or take apart (reuse as-is, tune params, or compose123your own from the primitives it cites). From there, or directly:124125Before inventing or tuning an effect, consult the SmartCLI knowledge graph at126`knowledge/INDEX.md` (repo root, two levels above this skill's folder). Its127`effects/` domain carries exact-formula128notes for most of what ships here — [[donut-torus]], [[plasma]], [[fire-lode]] /129[[fire-doom-psx]], [[matrix-rain]], [[game-of-life]], [[boids]], [[starfield]],130[[tunnel]], [[rotating-cube]], [[ascii-luminance-ramp]] — plus [[color-interpolation]]131and [[hsv-cycling-lolcat]] for theme/lolcat color. The `works/` wing ([[donut-c]],132[[lolcat]], [[cmatrix]], [[terminaltexteffects]], ...) is the design brain for a NEW133effect: pick your lane — **replica** a real effect by measuring its source math, or134**compose** a new one from documented primitives. Read the relevant note before writing135render code from scratch; `references/effects.md` is the local techniques sampler, the136graph is the sourced depth.137138## Add a NEW effect139Drop one module in `fx/effects/` — pkgutil auto-imports it, `@register` wires it into `list`/`show`/`play`/`gallery`. A module that fails to import is reported (stderr + `registry.load_errors()`) but never takes the catalog down.140141Minimal contract (`fx/base.py`):142- Subclass `Effect` (or `StaticEffect` for one-shot art). Set class metadata: `name` (lowercase, unique registry key), `description`, `tags`, optional `aliases`, `preferred_theme`, `default_fps`, and a `params` tuple of `Param(...)`.143- `Param(name, kind, default, help, choices=None, min=None, max=None)` — `kind` ∈ `int float str bool color`. The CLI lists/parses/validates `--set` against these; unknown keys and out-of-range values fail loud. `color` accepts `#RRGGBB`/`RRGGBB` (empty = None).144- Implement `render(self, ctx: FrameCtx) -> str`: return ONE full frame — `ctx.height` rows joined by `\n`, no trailing newline, EVERY cell written. Effects are pure frame producers: never print, sleep, or touch ANSI modes — the play loop owns the terminal.145- `ctx: FrameCtx` fields: `t` (seconds since start), `frame_index`, `width`, `height`, `theme` (active `Theme`, never None), `params` (coerced dict).146- Optional lifecycle: `setup()` (allocate buffers/particles before the first frame) and `teardown()` (release state; also runs on error/Ctrl-C). Override `is_animated(cls, params)` when animation depends on a param — in either direction: `text3d` is static by default and only animates with `--set shimmer=true`; `gradient_text` animates by default and goes static with `--set drift=0`.147148Skeleton:149```python150from fx.base import Effect, FrameCtx, Param151from fx.registry import register152153@register154class Swirl(Effect):155 name = "swirl"156 description = "example: a themed diagonal sweep."157 tags = ("field", "demo")158 default_fps = 24.0159 params = (Param("speed", "float", 1.0, "sweep speed", min=0.0, max=10.0),)160161 def render(self, ctx: FrameCtx) -> str:162 rows = []163 for y in range(ctx.height):164 cells = []165 for x in range(ctx.width):166 t = ((x + y) / max(1, ctx.width + ctx.height)167 + ctx.t * ctx.params["speed"] * 0.1) % 1.0168 r, g, b = ctx.theme.color_at(t)169 cells.append(f"\x1b[38;2;{r};{g};{b}m#")170 rows.append("".join(cells) + "\x1b[0m")171 return "\n".join(rows)172```173Then `python -m fx play swirl --theme rainbow --seconds 5`. Duplicate names raise `RegistryError` (pass `@register(replace=True)` to intentionally override a built-in).174175## Constraints / gotchas176- Keep frames exactly `ctx.height` rows and `ctx.width` cells; auto-size leaves 1 row headroom so the top doesn't scroll off.177- `plasma` and full-field effects are the heaviest (O(width×height) escapes/frame); shrink dimensions or lower `--fps` if they stutter.178- `pyfiglet` powers `text3d`/`banner_scroll`/`gradient_text --set big=true` if importable; otherwise a built-in block font covers A-Z, 0-9, space. `image2ascii` uses `PIL` if present, else a PNM/built-in demo fallback.179- ANSI on Windows is enabled automatically before every `play`/`render_once` (`enable_vt()`), no action needed.180- `references/effects.md` is a **techniques sampler** — it works a few effects in depth (sphere projection, block-text gradient, plasma field, matrix rain), not one section per shipped effect. Read it, plus the per-effect formula notes in `knowledge/effects/` (see Knowledge base above), when adding or tuning an effect.181- Path bases: `fx/`, `references/`, `tmux/`, and `scripts/` are relative to this skill's folder; `knowledge/` is relative to the repo root (two levels above it).182183## Optional tmux launchers184The play loop already owns a single alt-screen session, so tmux is never required. For convenience, `tmux/` ships two POSIX-sh launchers that drop an effect into a tmux popup or split pane:185- `tmux/fx-popup.sh <effect|gallery|random> [fx args]` — `display-popup -E` (tmux >= 3.2).186- `tmux/fx-split.sh [-v] EFFECT [EFFECT2] [fx args]` — split the current window (one effect in a new pane, or a 2-up of two).187188Both guard on `command -v tmux`: with no tmux they print the direct/compositor alternative plus install hints and exit `0`. `play`/`random` runs get a default `--seconds 10` bound. Truecolor through tmux needs `set -g default-terminal "tmux-256color"` + `set -ga terminal-overrides ",*:Tc"`. See `tmux/README.md`. For a seamless side-by-side without tmux, use the compositor: `python -m fx show --seq "donut|fire:synthwave:6"`.