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 mount the text on a sheet, turn the sheet away, and turn it back.

3D chapters used: Space and coordinates, Transforms, Rotation.

Create the module

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

myspin.py
"""MySpin, tutorial step 1: the text mounted on a flat sheet.

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.engine.space3d import Body, Space3D, rotation_about
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:
        """Mount the text on a sheet and queue its turn."""
        super().__init__(effect)
        self.space = Space3D(self.terminal, perspective=0)
        self.build()

    def build(self) -> None:
        """Mount every visible character on the sheet, turn the sheet away, and queue the turn back."""
        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)
        self.sheet = self.space.body()
        self.sheet.add_chars(characters, self.sheet.text_plane(characters))
        self.sheet.set_rotation(rotation_about("y", 70))
        self.sheet.turn_to_rest(frames=120, ease=easing.out_cubic)
        self.sheet.on_complete(self.finish)

    def finish(self, sheet: Body) -> None:
        """Hand the characters back, on their input cells."""
        sheet.release_all()

    def __next__(self) -> str:
        """Move the sheet one frame, then the characters with it."""
        if self.space.animating or self.active_characters:
            self.space.step()
            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 space and the sheet

Space3D(self.terminal, perspective=0) sets up a camera looking at the canvas. Perspective 0 is a flat view, so nothing grows or shrinks with distance yet. Step 3 turns it on.

self.space.body() makes a body with no shape. A body is a position, a rotation and a scale. It owns no characters and draws nothing; it only carries the characters you mount on it.

Mounting the text

text_plane(characters) returns one anchor per character: a point on the body, placed so that it lands on the character's input cell while the body is at rest. add_chars mounts each character on its anchor. From then on, space.step() works out where each anchor is on screen, and update() moves the character there.

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

Turning away and back

set_rotation(rotation_about("y", 70)) turns the sheet 70 degrees about the vertical axis, right away. Because the anchors were placed first, they turn with the sheet. 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.

Handing the text back

on_complete(self.finish) calls finish when the sheet's last tween ends. release_all() gives the characters back, on their input cells, as ordinary 2D characters. Once they have nothing left to do, update() drops them from active_characters and the effect ends.

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.