Skip to content

Step 5: Color

So far every character has been drawn in the terminal's default color. In Spray, the text ends in a gradient that runs from purple at the bottom, through cyan, to white at the top, and each character fades into its color from a random color of that gradient while it flies. In this step each character gets a scene that does this.

Engine chapters used: Animation, Color.

Update the module

Replace myspray.py with this version:

myspray.py
"""MySpray, tutorial step 5: color the characters.

Each character fades from a random color of the final gradient to its own place in that gradient.
"""

from __future__ import annotations

import random
from dataclasses import dataclass

from terminaltexteffects import Color, Coord, EffectCharacter, EventHandler, Gradient, easing
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.utils import argutils


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


@dataclass
class MySprayConfig(BaseConfig):
    """Configuration for the MySpray effect. It has no options yet."""

    parser_spec = argutils.ParserSpec(
        name="myspray",
        help="Sprays the characters from a single point.",
        description="myspray | Sprays the characters from a single point.",
        epilog="Example: tte myspray",
    )


class MySprayIterator(BaseEffectIterator[MySprayConfig]):
    """Produce the frames of the MySpray effect."""

    def __init__(self, effect: MySpray) -> None:
        """Set up every character before the first frame."""
        super().__init__(effect)
        self.pending_chars: list[EffectCharacter] = []
        self.build()

    def build(self) -> None:
        """Give every character a path home and a droplet scene, and queue it for release."""
        canvas = self.terminal.canvas
        final_gradient = Gradient(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF"), steps=12)
        final_gradient_mapping = final_gradient.build_coordinate_color_mapping(
            canvas.text_bottom,
            canvas.text_top,
            canvas.text_left,
            canvas.text_right,
            Gradient.Direction.VERTICAL,
        )
        spray_origin_map = {
            "center": canvas.center,
            "n": Coord(canvas.center_column, canvas.top),
            "nw": Coord(canvas.left, canvas.top),
            "w": Coord(canvas.left, canvas.center_row),
            "sw": Coord(canvas.left, canvas.bottom),
            "s": Coord(canvas.center_column, canvas.bottom),
            "se": Coord(canvas.right - 1, canvas.bottom),
            "e": Coord(canvas.right - 1, canvas.center_row),
            "ne": Coord(canvas.right - 1, canvas.top),
        }
        spray_origin = spray_origin_map["e"]
        for character in self.terminal.get_characters():
            character.motion.set_coordinate(spray_origin)
            input_coord_path = character.motion.new_path(
                speed=random.uniform(0.6, 1.4),
                ease=easing.out_expo,
            )
            input_coord_path.new_waypoint(character.input_coord)
            character.event_handler.register_event(
                EventHandler.Event.PATH_ACTIVATED,
                input_coord_path,
                EventHandler.Action.SET_LAYER,
                1,
            )
            character.event_handler.register_event(
                EventHandler.Event.PATH_COMPLETE,
                input_coord_path,
                EventHandler.Action.SET_LAYER,
                0,
            )
            droplet_scn = character.animation.new_scene()
            spray_gradient = Gradient(
                random.choice(final_gradient.spectrum),
                final_gradient_mapping[character.input_coord],
                steps=7,
            )
            droplet_scn.apply_gradient_to_symbols(character.input_symbol, 20, fg_gradient=spray_gradient)
            character.animation.activate_scene(droplet_scn)
            character.motion.activate_path(input_coord_path)
            self.pending_chars.append(character)
        random.shuffle(self.pending_chars)
        # Release up to 0.5% of the characters per frame, and at least one.
        self._volume = max(int(len(self.pending_chars) * 0.005), 1)

    def __next__(self) -> str:
        """Release a few characters, advance the animation, and return the frame."""
        if self.pending_chars or self.active_characters:
            for _ in range(random.randint(1, self._volume)):
                if self.pending_chars:
                    next_character = self.pending_chars.pop()
                    self.terminal.set_character_visibility(next_character, is_visible=True)
                    self.active_characters.add(next_character)
            self.update()
            return self.frame
        raise StopIteration


class MySpray(BaseEffect[MySprayConfig]):
    """Sprays the characters from a single point."""

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

    @property
    def _iterator_cls(self) -> type[MySprayIterator]:
        return MySprayIterator

What changed

The final gradient

build() starts by creating the final gradient, from purple through cyan to white with 12 steps between each pair of stops. build_coordinate_color_mapping() then gives every coordinate in the text a color from it. With Gradient.Direction.VERTICAL, every character in a row gets the same color, from the first stop on the bottom row to the last stop on the top row.

The mapping uses the text bounds, not the canvas bounds, so the gradient spans the text even when the canvas is larger.

A droplet scene for every character

For each character, build() also:

  1. Creates a scene with new_scene().
  2. Builds a short gradient from a random color in the final gradient to the character's own color, with 7 steps, so 8 colors.
  3. Adds a frame for each of those colors with apply_gradient_to_symbols(). Every frame shows the input symbol and lasts 20 ticks, so the scene lasts 160 ticks.
  4. Activates the scene.

The random starting colors make the flying characters a mix of the gradient's colors, and each one settles to its place in the gradient.

The scene isn't synced to the path, so it plays at its own pace. Every path here takes under 100 ticks, so each character lands before its scene ends and keeps fading in place. A character stays active until both its path and its scene are complete, so the effect now ends when the last character finishes fading rather than when it lands.

Run it

tte --input-file tutorial_input.txt myspray

The characters fly in colored and fade to the final gradient, purple at the bottom and white at the top:

MySpray after step 5: colored characters fly in and fade into a vertical gradient

MySpray now looks like the built-in Spray effect with its default options.

Next step

Step 6 adds command-line options for the spray position, speed, colors, and more, in place of the values written into the code.