Skip to content

Step 7: Harden

MySpray works on the tutorial text with its default options. A shared effect also meets input you didn't plan for: colored text, a single character, wide characters, a canvas much larger than the text. In this step MySpray learns to keep the input's colors, gets tests that run it on unusual input, and is compared with the built-in Spray.

Engine chapters used: Color, Discovery and loading.

Update the module

Replace myspray.py with this version:

myspray.py
"""MySpray, tutorial step 7: honor colors in the input.

With `--existing-color-handling dynamic`, characters keep their input colors instead of the final gradient.
"""

from __future__ import annotations

import random
import typing
from dataclasses import dataclass

from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient, easing
from terminaltexteffects.engine.base_config import (
    BaseConfig,
    FinalGradientDirectionArg,
    FinalGradientStepsArg,
    FinalGradientStopsArg,
)
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."""

    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 --spray-position center --final-gradient-direction radial",
    )

    spray_position: typing.Literal["n", "ne", "e", "se", "s", "sw", "w", "nw", "center"] = argutils.ArgSpec(
        name="--spray-position",
        choices=["n", "ne", "e", "se", "s", "sw", "w", "nw", "center"],
        default="e",
        help="Position for the spray origin.",
    )  # pyright: ignore[reportAssignmentType]
    "typing.Literal['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', 'center'] : Position for the spray origin."

    spray_volume: float = argutils.ArgSpec(
        name="--spray-volume",
        type=argutils.PositiveRatio.type_parser,
        default=0.005,
        metavar=argutils.PositiveRatio.METAVAR,
        help="Number of characters to spray per tick as a fraction of the total number of characters.",
    )  # pyright: ignore[reportAssignmentType]
    "float : Number of characters to spray per tick as a fraction of the total number of characters."

    movement_speed_range: tuple[float, float] = argutils.ArgSpec(
        name="--movement-speed-range",
        type=argutils.PositiveFloatRange.type_parser,
        default=(0.6, 1.4),
        metavar=argutils.PositiveFloatRange.METAVAR,
        help="Movement speed range of the characters.",
    )  # pyright: ignore[reportAssignmentType]
    "tuple[float, float] : Movement speed range of the characters."

    movement_easing: easing.EasingFunction = argutils.ArgSpec(
        name="--movement-easing",
        type=argutils.Ease.type_parser,
        default=easing.out_expo,
        metavar=argutils.Ease.METAVAR,
        help="Easing function to use for character movement.",
    )  # pyright: ignore[reportAssignmentType]
    "easing.EasingFunction : Easing function to use for character movement."

    final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg(
        default=(Color("#8A008A"), Color("#00D1FF"), Color("#FFFFFF")),
    )  # pyright: ignore[reportAssignmentType]
    "tuple[Color, ...] : Colors for the final gradient. With one color, the characters are shown in that color."

    final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg(
        default=12,
    )  # pyright: ignore[reportAssignmentType]
    "tuple[int, ...] | int : Number of steps between each pair of final gradient stops."

    final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg(
        default=Gradient.Direction.VERTICAL,
    )  # pyright: ignore[reportAssignmentType]
    "Gradient.Direction : Direction of the final gradient across the text."


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(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps)
        final_gradient_mapping = final_gradient.build_coordinate_color_mapping(
            canvas.text_bottom,
            canvas.text_top,
            canvas.text_left,
            canvas.text_right,
            self.config.final_gradient_direction,
        )
        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[self.config.spray_position]
        dynamic = self.terminal.config.existing_color_handling == "dynamic"
        for character in self.terminal.get_characters():
            character.motion.set_coordinate(spray_origin)
            input_coord_path = character.motion.new_path(
                speed=random.uniform(*self.config.movement_speed_range),
                ease=self.config.movement_easing,
            )
            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()
            if dynamic:
                # Show the input colors, with no fade, for as long as the gradient scene would play.
                input_colors = ColorPair(fg=character.animation.input_fg_color, bg=character.animation.input_bg_color)
                droplet_scn.add_frame(character.input_symbol, 160, colors=input_colors)
            else:
                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 `spray_volume` of the characters per frame, and at least one.
        self._volume = max(int(len(self.pending_chars) * self.config.spray_volume), 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

When the user passes --existing-color-handling dynamic, the engine leaves input colors to the effect. MySpray now checks for that setting and, when it is set, gives each character a droplet scene with a single frame in the character's input colors, instead of the fade into the final gradient. The frame lasts 160 ticks, as long as the gradient scene, so the effect's timing doesn't change.

A character without input colors gets a ColorPair of two None colors, so it is shown in the terminal's default colors. This matches the built-in Spray; Paint, in the Color chapter, falls back to its gradient instead. Either choice is reasonable, as long as your effect's documentation says which it makes.

The other two settings need no code. With ignore, the default, the engine drops the input colors. With always, the engine replaces the colors of every frame with the input colors.

Run it

Use the colored input file from the Color chapter:

tte --input-file color_input.txt --existing-color-handling dynamic myspray

The first word lands in its input colors, red, green, and blue, and the second word, which has no colors, lands in the terminal's default color:

MySpray with dynamic color handling: the first word keeps its red, green, and blue input colors

Test it

Testing an effect means running it on input that is likely to break it and checking that it finishes with the text in place. Save this file as test_myspray.py outside your community effects directory, since TTE imports every .py file in that directory:

test_myspray.py
"""Tests for the MySpray effect."""

from __future__ import annotations

import re
import typing

import pytest
from myspray import MySpray

from terminaltexteffects.utils.exceptions.terminal_exceptions import EmptyInputError

Position = typing.Literal["n", "ne", "e", "se", "s", "sw", "w", "nw", "center"]
ANSI_ESCAPE = re.compile(r"\x1b(\[[0-?]*[ -/]*[@-~]|[78])")
INPUTS = {
    "single character": "x",
    "one row": "Hello, world",
    "one column": "a\nb\nc",
    "sparse": "a\n\n\n          b",
    "wide characters": "漢字テスト",
    "tabs": "a\tb\tc",
}


def play(effect: MySpray) -> str:
    """Run the effect without a terminal or frame-rate delay, and return its last frame."""
    effect.terminal_config.frame_rate = 0
    effect.terminal_config.ignore_terminal_dimensions = True
    frames = list(effect)
    return frames[-1] if frames else ""


@pytest.mark.parametrize("text", INPUTS.values(), ids=INPUTS.keys())
def test_ends_with_the_input_text(text: str) -> None:
    """The last frame shows every line of the input."""
    effect = MySpray(text)
    effect.terminal_config.tab_width = 1
    last_frame = ANSI_ESCAPE.sub("", play(effect))
    for line in text.replace("\t", " ").splitlines():
        assert line.strip() in last_frame


def test_empty_input_is_rejected_by_the_engine() -> None:
    """Input with no visible characters never reaches the effect."""
    with pytest.raises(EmptyInputError):
        play(MySpray(""))


@pytest.mark.parametrize("position", typing.get_args(Position))
def test_every_spray_position_on_a_larger_canvas(position: Position) -> None:
    """Every spray position works when the canvas is larger than the text."""
    effect = MySpray("Hello")
    effect.effect_config.spray_position = position
    effect.terminal_config.canvas_width = 20
    effect.terminal_config.canvas_height = 5
    assert "Hello" in ANSI_ESCAPE.sub("", play(effect))


def test_dynamic_color_handling_keeps_input_colors() -> None:
    """With dynamic color handling, characters end in their input colors."""
    effect = MySpray("\x1b[38;2;255;0;0mRed\x1b[0m")
    effect.terminal_config.existing_color_handling = "dynamic"
    assert "38;2;255;0;0" in play(effect)

The tests use MySpray from Python, as described in Configuration from Python. play() sets the frame rate to 0 so the tests don't wait between frames, and sets ignore_terminal_dimensions so the effect runs without a terminal.

  • test_ends_with_the_input_text runs inputs that often expose bugs: one character, one row, one column, a sparse grid with empty lines, wide characters that take two cells, and tabs. It checks that the last frame contains every line of the input.
  • test_empty_input_is_rejected_by_the_engine shows that your effect never sees input without visible characters: the engine raises EmptyInputError first, and the CLI exits without playing anything.
  • test_every_spray_position_on_a_larger_canvas runs every spray position on a canvas larger than the text, where canvas and text bounds differ.
  • test_dynamic_color_handling_keeps_input_colors checks that the input's red reaches the last frame.

Run the tests with your effects directory on the import path, so import myspray finds the module:

PYTHONPATH="${XDG_CONFIG_HOME:-$HOME/.config}/terminaltexteffects/effects" python -m pytest test_myspray.py

All 17 tests pass. Test anything your own effect adds the same way: each option at its limits, and any input your effect treats specially.

Compare with Spray

MySpray now has the same options, defaults, and behavior as the built-in Spray. Here is Spray on the same input:

tte --input-file color_input.txt --existing-color-handling dynamic spray

The built-in Spray with dynamic color handling, landing the same way as MySpray

Read effect_spray.py next to your myspray.py. The differences are in structure, not behavior:

  • Spray converts --spray-position to a SprayPosition enum and keys its origins by the enum. MySpray keys spray_origin_map by the option's strings.
  • Spray computes every character's final color first and stores it in character_final_color_map. MySpray looks the color up as it builds each scene.
  • With dynamic color handling, Spray's scene has seven 20-tick frames in the input colors, 140 ticks in all, and MySpray's has one 160-tick frame. Both show the input colors until the scene ends.
  • Spray's docstrings list every option in an Attributes: section, and its epilog includes the shared description of easing functions, argutils.EASING_EPILOG, as built-in effects do.

What you built

Over seven steps, MySpray went from an effect that printed the text in place to a full copy of Spray. Along the way it used every major part of the engine: character visibility and release, paths with easing, events, scenes and gradients, command-line options, and input colors.

From here, the Patterns pages show techniques Spray doesn't use, such as multi-phase effects and particles, and Sharing your effect explains how to publish yours.