Skip to content

Spin, step 1: A sheet of text

This tutorial builds a 3D effect from scratch: Spin. The text starts far behind the canvas as a flat sheet, turned at a random angle. It comes forward while spinning, unwinds its tilt as it goes, and lands exactly on the input text. Each character brightens as it turns to face you. This is what you'll have at the end:

The finished effect: the text tumbles in from far away and settles into place

Your copy is called MySpin and runs as tte myspin. It uses the same input and canvas as the 3D Effects chapters. If you haven't written an effect before, the MySpray tutorial covers the parts every effect has, which this one skips over.

In this step you turn the text away on its sheet, the text plane, and turn it back.

3D chapters used: The world, Frames and coordinates, Rotation.

Create the module

Save this as myspin.py in your community effects directory:

myspin.py
"""MySpin, tutorial step 1: the text on its sheet, the text plane.

The text starts on a sheet turned away to one side, and the sheet turns back to face the viewer. Later steps build
it up into the Spin effect.
"""

from __future__ import annotations

from dataclasses import dataclass

from terminaltexteffects import 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 "myspin", MySpin, MySpinConfig


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

    parser_spec = argutils.ParserSpec(
        name="myspin",
        help="The text tumbles in from the distance as a flat sheet, spins, and settles into place.",
        description="myspin | The text tumbles in from the distance as a flat sheet, spins, and settles into place.",
        epilog="Example: tte myspin",
    )


class MySpinIterator(BaseEffectIterator[MySpinConfig]):
    """Produce the frames of the MySpin effect."""

    def __init__(self, effect: MySpin) -> None:
        """Turn the text plane away and queue the turn back."""
        super().__init__(effect)
        self.world = self.terminal.world
        self.build()

    def build(self) -> None:
        """Show every visible character, turn the text plane away, and queue the turn back."""
        world = self.world
        world.camera.set_perspective(0)
        characters = [character for character in self.terminal.get_characters() if character.input_symbol != " "]
        for character in characters:
            self.terminal.set_character_visibility(character, is_visible=True)
            self.active_characters.add(character)
        # The text plane is the body every character starts on, so turning it turns the text.
        self.sheet = world.text_plane
        self.sheet.rotate("y", 70)
        self.sheet.turn_to_rest(frames=120, ease=easing.out_cubic)

    def __next__(self) -> str:
        """Move the text plane one frame; the characters on it move with it."""
        if self.world.animating or self.active_characters:
            self.update()
            return self.frame
        raise StopIteration


class MySpin(BaseEffect[MySpinConfig]):
    """The text tumbles in from the distance as a flat sheet, spins, and settles into place."""

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

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

How it works

The world and the sheet

Every terminal has a world, self.terminal.world, with a camera looking at the canvas. world.camera.set_perspective(0) makes the view flat, so nothing grows or shrinks with distance yet. Step 3 turns perspective on.

The sheet is world.text_plane, the body every character starts on. Each character's position is its input cell on the text plane's surface, and those positions never change in this tutorial: the effect moves the plane, and the plane carries the text. The text is in its input cells whenever the plane is at rest.

Showing the text

build makes each character visible and adds it to active_characters, so it is drawn from the first frame. Characters in this step have nothing of their own to do, so update() drops them from active_characters after the first tick. They still move: whenever the view changes, the world projects every character on the plane again. Step 4 gives each character a scene, which does need a tick every frame.

Spaces are left out. They're invisible, so showing them would only cost time.

Turning away and back

rotate("y", 70) turns the sheet 70 degrees about the vertical axis, right away. turn_to_rest(frames=120, ease=easing.out_cubic) queues a tween that turns it back to rest along the shortest path, slowing as it arrives. The world plays the tween one frame at a time inside update().

Ending

__next__ keeps going while self.world.animating or self.active_characters is true. animating is true while any body or camera tween is queued or playing, so the effect ends on the frame after the sheet comes to rest, with the text in its input cells.

Run it

tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c myspin

MySpin after step 1: the text widens as the sheet turns back to face the viewer

With no perspective, turning the sheet only squeezes the text from the sides. Characters that land on the same cell hide each other, which is why letters go missing while it's turned.

Next step

Step 2 replaces the single turn with a spin.