Skip to content

Path

Module: terminaltexteffects.engine.motion

Represents a path consisting of multiple waypoints for motion.

Attributes:

Name Type Description
path_id str

The unique identifier for the path.

speed float

Path speed; must be greater than 0.

ease EasingFunction | None

Easing function applied across path traversal.

layer int | None

Character layer to apply when the path is activated. If None, the layer is unchanged.

hold_time int

Number of frames to remain at the end of the path before completion.

loop bool

Whether the path should restart after completion.

segments list[Segment]

Ordered segments traversed by the path.

waypoints list[Waypoint]

Ordered waypoints in the path.

waypoint_lookup dict[str, Waypoint]

Waypoints indexed by waypoint_id.

total_distance float

Total travel distance across all segments, including the origin segment when active.

current_step int

Current traversal step count.

max_steps int

Total number of traversal steps, derived from total_distance / speed.

hold_time_remaining int

Remaining hold frames once the path reaches its end.

last_distance_reached float

Most recent eased or linear distance traveled along the active path.

origin_segment Segment | None

Temporary segment from the current coordinate to the first waypoint, set on activation.

Methods:

Name Description
new_waypoint

Creates a new Waypoint and appends adds it to the Path.

query_waypoint

Returns the waypoint with the given waypoint_id.

step

Progresses to the next step along the path and returns the coordinate at that step.

Source code in terminaltexteffects/engine/motion.py
@dataclass
class Path:
    """Represents a path consisting of multiple waypoints for motion.

    Attributes:
        path_id (str): The unique identifier for the path.
        speed (float): Path speed; must be greater than 0.
        ease (easing.EasingFunction | None): Easing function applied across path traversal.
        layer (int | None): Character layer to apply when the path is activated. If None, the layer is unchanged.
        hold_time (int): Number of frames to remain at the end of the path before completion.
        loop (bool): Whether the path should restart after completion.
        segments (list[Segment]): Ordered segments traversed by the path.
        waypoints (list[Waypoint]): Ordered waypoints in the path.
        waypoint_lookup (dict[str, Waypoint]): Waypoints indexed by `waypoint_id`.
        total_distance (float): Total travel distance across all segments, including the origin segment when active.
        current_step (int): Current traversal step count.
        max_steps (int): Total number of traversal steps, derived from `total_distance / speed`.
        hold_time_remaining (int): Remaining hold frames once the path reaches its end.
        last_distance_reached (float): Most recent eased or linear distance traveled along the active path.
        origin_segment (Segment | None): Temporary segment from the current coordinate to the first waypoint,
            set on activation.

    Methods:
        new_waypoint:
            Creates a new Waypoint and appends adds it to the Path.
        query_waypoint:
            Returns the waypoint with the given waypoint_id.
        step:
            Progresses to the next step along the path and returns the coordinate at that step.

    """

    path_id: str
    speed: float = 1.0
    ease: easing.EasingFunction | None = None
    layer: int | None = None
    hold_time: int = 0
    loop: bool = False

    def __post_init__(self) -> None:
        """Initialize the Path object and calculates the total distance and maximum steps."""
        self.segments: list[Segment] = []
        self.waypoints: list[Waypoint] = []
        self.waypoint_lookup: dict[str, Waypoint] = {}
        self.total_distance: float = 0
        self.current_step: int = 0
        self.max_steps: int = 0
        self.hold_time_remaining = self.hold_time
        self.last_distance_reached: float = 0  # used for animation syncing to distance
        self.origin_segment: Segment | None = None
        if self.speed <= 0:
            raise PathInvalidSpeedError(self.speed)

    def new_waypoint(
        self,
        coord: Coord,
        *,
        bezier_control: tuple[Coord, ...] | Coord | None = None,
        waypoint_id: str = "",
    ) -> Waypoint:
        """Create a new Waypoint and appends adds it to the Path.

        Args:
            waypoint_id (str): Unique identifier for the waypoint. Used to query for the waypoint.
            coord (Coord): coordinate
            bezier_control (tuple[Coord, ...] | Coord | None): Optional bezier control point(s) for the
                segment ending at this waypoint. A single Coord is normalized to a one-item tuple.
                Defaults to None.

        Returns:
            Waypoint: The new waypoint.

        """
        if not waypoint_id:
            found_unique = False
            current_id = len(self.waypoints)
            while not found_unique:
                waypoint_id = f"{current_id}"
                if waypoint_id not in self.waypoint_lookup:
                    found_unique = True
                else:
                    current_id += 1
        if waypoint_id in self.waypoint_lookup:
            raise DuplicateWaypointIDError(waypoint_id)
        bezier_control_tuple: tuple[Coord, ...] | None
        if bezier_control and isinstance(bezier_control, Coord):
            bezier_control_tuple = (bezier_control,)
        elif bezier_control and isinstance(bezier_control, tuple):
            bezier_control_tuple = bezier_control
        else:
            bezier_control_tuple = None
        new_waypoint = Waypoint(waypoint_id, coord, bezier_control=bezier_control_tuple)
        self._add_waypoint_to_path(new_waypoint)
        return new_waypoint

    def _add_waypoint_to_path(self, waypoint: Waypoint) -> None:
        """Add a waypoint to the path and update the total distance and maximum steps.

        Args:
            waypoint (Waypoint): waypoint to add

        """
        self.waypoint_lookup[waypoint.waypoint_id] = waypoint
        self.waypoints.append(waypoint)
        if len(self.waypoints) < 2:
            return

        if waypoint.bezier_control:
            distance_from_previous = geometry.find_length_of_bezier_curve(
                self.waypoints[-2].coord,
                waypoint.bezier_control,
                waypoint.coord,
            )
        else:
            distance_from_previous = geometry.find_length_of_line(
                self.waypoints[-2].coord,
                waypoint.coord,
                double_row_diff=True,
            )
        self.total_distance += distance_from_previous
        self.segments.append(Segment(self.waypoints[-2], waypoint, distance_from_previous))
        self.max_steps = round(self.total_distance / self.speed)

    def query_waypoint(self, waypoint_id: str) -> Waypoint:
        """Return the waypoint with the given waypoint_id.

        Args:
            waypoint_id (str): waypoint_id

        Returns:
            Waypoint: The waypoint with the given waypoint_id.

        """
        waypoint = self.waypoint_lookup.get(waypoint_id, None)
        if not waypoint:
            raise WaypointNotFoundError(waypoint_id)
        return waypoint

    def step(self, event_handler: base_character.EventHandler) -> Coord:
        """Progresses to the next step along the path and returns the coordinate at that step.

        This method is called by the Motion.move() method. It calculates the next coordinate based on the current step,
        total distance, bezier control points, and the easing function if provided. It also handles the triggering
        of segment enter and exit events.

        Args:
            event_handler (base_character.EventHandler): The EventHandler for the character.

        Returns:
            Coord: The next coordinate on the path.

        """
        if not self.max_steps or self.current_step >= self.max_steps or not self.total_distance:
            # if the path has zero distance or there are no more steps, return the final waypoint coordinate
            return self.segments[-1].end.coord
        self.current_step += 1
        if self.ease:
            distance_factor = self.ease(self.current_step / self.max_steps)
        else:
            distance_factor = self.current_step / self.max_steps

        distance_to_travel = distance_factor * self.total_distance
        self.last_distance_reached = distance_to_travel
        for segment in self.segments:
            if distance_to_travel <= segment.distance:
                active_segment = segment
                if not segment.enter_event_triggered:
                    segment.enter_event_triggered = True
                    event_handler._handle_event(event_handler.Event.SEGMENT_ENTERED, segment.end)
                break
            distance_to_travel -= segment.distance
            if not segment.exit_event_triggered:
                segment.exit_event_triggered = True
                event_handler._handle_event(event_handler.Event.SEGMENT_EXITED, segment.end)
        # if the distance_to_travel is further than the last waypoint,
        # preserve the distance from the start of the final segment
        else:
            active_segment = self.segments[-1]
            distance_to_travel += active_segment.distance
        if active_segment.distance == 0:
            segment_distance_to_travel_factor = 0.0
        elif self.ease:
            segment_distance_to_travel_factor = distance_to_travel / active_segment.distance
        else:
            segment_distance_to_travel_factor = min((distance_to_travel / active_segment.distance, 1))

        if active_segment.end.bezier_control:
            next_coord = geometry.find_coord_on_bezier_curve(
                active_segment.start.coord,
                active_segment.end.bezier_control,
                active_segment.end.coord,
                segment_distance_to_travel_factor,
            )
        else:
            next_coord = geometry.find_coord_on_line(
                active_segment.start.coord,
                active_segment.end.coord,
                segment_distance_to_travel_factor,
            )

        return next_coord

    def __eq__(self, other: object) -> bool:
        """Check if two Path objects are equal.

        Args:
            other (object): object to compare

        Returns:
            bool: True if the two Path objects are equal, False otherwise.

        """
        if not isinstance(other, Path):
            return NotImplemented
        return self.path_id == other.path_id

    def __hash__(self) -> int:
        """Return the hash value of the Path.

        Hash is calculated using the path_id.
        """
        return hash(self.path_id)

__eq__(other)

Check if two Path objects are equal.

Parameters:

Name Type Description Default
other object

object to compare

required

Returns:

Name Type Description
bool bool

True if the two Path objects are equal, False otherwise.

Source code in terminaltexteffects/engine/motion.py
def __eq__(self, other: object) -> bool:
    """Check if two Path objects are equal.

    Args:
        other (object): object to compare

    Returns:
        bool: True if the two Path objects are equal, False otherwise.

    """
    if not isinstance(other, Path):
        return NotImplemented
    return self.path_id == other.path_id

__hash__()

Return the hash value of the Path.

Hash is calculated using the path_id.

Source code in terminaltexteffects/engine/motion.py
def __hash__(self) -> int:
    """Return the hash value of the Path.

    Hash is calculated using the path_id.
    """
    return hash(self.path_id)

__post_init__()

Initialize the Path object and calculates the total distance and maximum steps.

Source code in terminaltexteffects/engine/motion.py
def __post_init__(self) -> None:
    """Initialize the Path object and calculates the total distance and maximum steps."""
    self.segments: list[Segment] = []
    self.waypoints: list[Waypoint] = []
    self.waypoint_lookup: dict[str, Waypoint] = {}
    self.total_distance: float = 0
    self.current_step: int = 0
    self.max_steps: int = 0
    self.hold_time_remaining = self.hold_time
    self.last_distance_reached: float = 0  # used for animation syncing to distance
    self.origin_segment: Segment | None = None
    if self.speed <= 0:
        raise PathInvalidSpeedError(self.speed)

new_waypoint(coord, *, bezier_control=None, waypoint_id='')

Create a new Waypoint and appends adds it to the Path.

Parameters:

Name Type Description Default
waypoint_id str

Unique identifier for the waypoint. Used to query for the waypoint.

''
coord Coord

coordinate

required
bezier_control tuple[Coord, ...] | Coord | None

Optional bezier control point(s) for the segment ending at this waypoint. A single Coord is normalized to a one-item tuple. Defaults to None.

None

Returns:

Name Type Description
Waypoint Waypoint

The new waypoint.

Source code in terminaltexteffects/engine/motion.py
def new_waypoint(
    self,
    coord: Coord,
    *,
    bezier_control: tuple[Coord, ...] | Coord | None = None,
    waypoint_id: str = "",
) -> Waypoint:
    """Create a new Waypoint and appends adds it to the Path.

    Args:
        waypoint_id (str): Unique identifier for the waypoint. Used to query for the waypoint.
        coord (Coord): coordinate
        bezier_control (tuple[Coord, ...] | Coord | None): Optional bezier control point(s) for the
            segment ending at this waypoint. A single Coord is normalized to a one-item tuple.
            Defaults to None.

    Returns:
        Waypoint: The new waypoint.

    """
    if not waypoint_id:
        found_unique = False
        current_id = len(self.waypoints)
        while not found_unique:
            waypoint_id = f"{current_id}"
            if waypoint_id not in self.waypoint_lookup:
                found_unique = True
            else:
                current_id += 1
    if waypoint_id in self.waypoint_lookup:
        raise DuplicateWaypointIDError(waypoint_id)
    bezier_control_tuple: tuple[Coord, ...] | None
    if bezier_control and isinstance(bezier_control, Coord):
        bezier_control_tuple = (bezier_control,)
    elif bezier_control and isinstance(bezier_control, tuple):
        bezier_control_tuple = bezier_control
    else:
        bezier_control_tuple = None
    new_waypoint = Waypoint(waypoint_id, coord, bezier_control=bezier_control_tuple)
    self._add_waypoint_to_path(new_waypoint)
    return new_waypoint

query_waypoint(waypoint_id)

Return the waypoint with the given waypoint_id.

Parameters:

Name Type Description Default
waypoint_id str

waypoint_id

required

Returns:

Name Type Description
Waypoint Waypoint

The waypoint with the given waypoint_id.

Source code in terminaltexteffects/engine/motion.py
def query_waypoint(self, waypoint_id: str) -> Waypoint:
    """Return the waypoint with the given waypoint_id.

    Args:
        waypoint_id (str): waypoint_id

    Returns:
        Waypoint: The waypoint with the given waypoint_id.

    """
    waypoint = self.waypoint_lookup.get(waypoint_id, None)
    if not waypoint:
        raise WaypointNotFoundError(waypoint_id)
    return waypoint

step(event_handler)

Progresses to the next step along the path and returns the coordinate at that step.

This method is called by the Motion.move() method. It calculates the next coordinate based on the current step, total distance, bezier control points, and the easing function if provided. It also handles the triggering of segment enter and exit events.

Parameters:

Name Type Description Default
event_handler EventHandler

The EventHandler for the character.

required

Returns:

Name Type Description
Coord Coord

The next coordinate on the path.

Source code in terminaltexteffects/engine/motion.py
def step(self, event_handler: base_character.EventHandler) -> Coord:
    """Progresses to the next step along the path and returns the coordinate at that step.

    This method is called by the Motion.move() method. It calculates the next coordinate based on the current step,
    total distance, bezier control points, and the easing function if provided. It also handles the triggering
    of segment enter and exit events.

    Args:
        event_handler (base_character.EventHandler): The EventHandler for the character.

    Returns:
        Coord: The next coordinate on the path.

    """
    if not self.max_steps or self.current_step >= self.max_steps or not self.total_distance:
        # if the path has zero distance or there are no more steps, return the final waypoint coordinate
        return self.segments[-1].end.coord
    self.current_step += 1
    if self.ease:
        distance_factor = self.ease(self.current_step / self.max_steps)
    else:
        distance_factor = self.current_step / self.max_steps

    distance_to_travel = distance_factor * self.total_distance
    self.last_distance_reached = distance_to_travel
    for segment in self.segments:
        if distance_to_travel <= segment.distance:
            active_segment = segment
            if not segment.enter_event_triggered:
                segment.enter_event_triggered = True
                event_handler._handle_event(event_handler.Event.SEGMENT_ENTERED, segment.end)
            break
        distance_to_travel -= segment.distance
        if not segment.exit_event_triggered:
            segment.exit_event_triggered = True
            event_handler._handle_event(event_handler.Event.SEGMENT_EXITED, segment.end)
    # if the distance_to_travel is further than the last waypoint,
    # preserve the distance from the start of the final segment
    else:
        active_segment = self.segments[-1]
        distance_to_travel += active_segment.distance
    if active_segment.distance == 0:
        segment_distance_to_travel_factor = 0.0
    elif self.ease:
        segment_distance_to_travel_factor = distance_to_travel / active_segment.distance
    else:
        segment_distance_to_travel_factor = min((distance_to_travel / active_segment.distance, 1))

    if active_segment.end.bezier_control:
        next_coord = geometry.find_coord_on_bezier_curve(
            active_segment.start.coord,
            active_segment.end.bezier_control,
            active_segment.end.coord,
            segment_distance_to_travel_factor,
        )
    else:
        next_coord = geometry.find_coord_on_line(
            active_segment.start.coord,
            active_segment.end.coord,
            segment_distance_to_travel_factor,
        )

    return next_coord