Skip to content

Looping and infinite effects

Some effects play a cycle that repeats: a color wave, a pulse, a rotation. They usually let the user choose how many cycles to play, and treat 0 as "forever". This page shows how to count cycles, how to end a looping effect cleanly, and what an infinite effect means for the people running it. Built-in effects with this option include ColorShift, whose --cycles 0 loops until interrupted.

The Wave example

Wave gives every character a looping scene that cycles through a looping gradient. Each character starts at a different point in the cycle, offset by its column, so a band of color sweeps across the text from right to left. After the last cycle, every character settles to a final color. Save it as wave.py in your community effects directory:

wave.py
"""Wave: a band of color sweeps across the text in a loop, for a set number of cycles or forever.

An Effect Guide example for the Looping and infinite effects pattern.
"""

from __future__ import annotations

from dataclasses import dataclass

from terminaltexteffects import Color, ColorPair, Gradient
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.utils import argutils

WAVE = Gradient(Color("#2040ff"), Color("#20e0ff"), Color("#ffffff"), steps=10, loop=True)
TICKS_PER_COLOR = 2
FINAL_COLOR = ColorPair(fg=Color("#20e0ff"))


def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]:
    """Return the command name, effect class, and configuration class TTE registers."""
    return "wave", Wave, WaveConfig


@dataclass
class WaveConfig(BaseConfig):
    """Configuration for the Wave effect."""

    parser_spec = argutils.ParserSpec(
        name="wave",
        help="A band of color sweeps across the text in a loop.",
        description="wave | A band of color sweeps across the text in a loop.",
        epilog="Example: tte wave --cycles 0",
    )
    cycles: int = argutils.ArgSpec(
        name="--cycles",
        type=argutils.NonNegativeInt.type_parser,
        default=2,
        metavar=argutils.NonNegativeInt.METAVAR,
        help="Number of times the wave crosses the text. 0 loops until interrupted.",
    )  # pyright: ignore[reportAssignmentType]
    "int : Number of times the wave crosses the text. 0 loops until interrupted."


class WaveIterator(BaseEffectIterator[WaveConfig]):
    """Produce the frames of the Wave effect."""

    def __init__(self, effect: Wave) -> None:
        """Give every character a looping scene, offset by its column so the colors travel."""
        super().__init__(effect)
        colors = WAVE.spectrum
        self.cycle_ticks = len(colors) * TICKS_PER_COLOR
        self.ticks = 0
        for character in self.terminal.get_characters():
            offset = character.input_coord.column % len(colors)
            scene = character.animation.new_scene(is_looping=True)
            for color in colors[offset:] + colors[:offset]:
                scene.add_frame(character.input_symbol, TICKS_PER_COLOR, colors=ColorPair(fg=color))
            character.animation.activate_scene(scene)
            self.terminal.set_character_visibility(character, is_visible=True)
            self.active_characters.add(character)

    def __next__(self) -> str:
        """Advance the wave; after the last cycle, settle every character and stop."""
        if not self.active_characters:
            raise StopIteration
        if self.config.cycles and self.ticks == self.config.cycles * self.cycle_ticks:
            for character in self.active_characters:
                character.animation.deactivate_scene()
                character.animation.set_appearance(character.input_symbol, FINAL_COLOR)
            self.active_characters.clear()
            return self.frame
        self.ticks += 1
        self.update()
        return self.frame


class Wave(BaseEffect[WaveConfig]):
    """A band of color sweeps across the text in a loop."""

    @property
    def _config_cls(self) -> type[WaveConfig]:
        return WaveConfig

    @property
    def _iterator_cls(self) -> type[WaveIterator]:
        return WaveIterator
tte --input-file concept_input.txt wave

Bands of blue, cyan, and white sweep across the text twice, then the text settles to cyan

With --cycles 0, the wave keeps sweeping until you press Ctrl+C:

tte --input-file concept_input.txt wave --cycles 0

Looping scenes and gradients

Two looping options work together here:

  • Gradient(..., loop=True) adds a transition from the last stop back to the first, so the spectrum ends where it starts, and cycling through it has no jump (see Gradients).
  • new_scene(is_looping=True) starts the scene again from its first frame after the last one, forever (see More scene options).

Rotating each character's list of colors by its column, colors[offset:] + colors[:offset], is what turns the same cycle into a moving wave: neighboring characters are one color apart at every tick.

Ending a loop

A looping scene never completes, so its character never becomes inactive, and an iterator that waits for active_characters to empty would never stop. The iterator has to decide when to stop, and Wave counts ticks:

  • One cycle lasts len(colors) * TICKS_PER_COLOR ticks, the length of the scene.
  • After cycles full cycles, __next__ stops each character's scene with deactivate_scene(), gives it its final appearance with set_appearance(), and clears active_characters. It returns that settled frame, and the next call raises StopIteration.

set_appearance() changes what is drawn at once, without an update(), so the settled frame shows the final colors. Ending on a deliberate final appearance, rather than wherever the loop happened to be, makes the effect look finished.

Other ways to end a loop:

  • Replace the looping scene with a final, non-looping scene, and wait for it to complete as usual.
  • For a looping path, call motion.deactivate_path() or activate a final path.

Infinite effects

With --cycles 0, Wave never raises StopIteration. That is fine on the command line, where the user stops it with Ctrl+C, but it changes how the effect can be used:

  • Someone running the effect from Python must stop iterating themselves, as the Library Guide explains for the built-in effects.
  • Tests and recordings need a finite setting, so make sure every option has a finite value, as Wave's default of 2 cycles is.
  • Say in the option's help that 0 loops until interrupted, as Wave does.

Where to go next