diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1a4a54..b5e23b7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ --- +#### New Effects (0.16.0) + +--- + +* Added Snake, an orthogonal grid animation in which multiple edge-entering snakes carry input characters and + deposit them into their final positions while their bodies shrink. + #### Development Tooling (0.16.0) --- diff --git a/README.md b/README.md index e0930a27..8560ccd5 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ View the [Documentation](https://chrisbuilds.github.io/terminaltexteffects/) for Effect: Name of the effect to apply. Use -h for effect specific help. - {beams,binarypath,blackhole,bouncyballs,bubbles,burn,colorshift,crumble,decrypt,errorcorrect,expand,fireworks,highlight,laseretch,matrix,middleout,orbittingvolley,overflow,pour,print,rain,randomsequence,rings,scattered,slice,slide,smoke,spotlights,spray,swarm,sweep,synthgrid,thunderstorm,unstable,vhstape,waves,wipe} + {beams,binarypath,blackhole,bouncyballs,bubbles,burn,colorshift,crumble,decrypt,errorcorrect,expand,fireworks,highlight,laseretch,matrix,middleout,orbittingvolley,overflow,pour,print,rain,randomsequence,rings,scattered,slice,slide,smoke,snake,spotlights,spray,swarm,sweep,synthgrid,thunderstorm,unstable,vhstape,waves,wipe} Available Effects beams Create beams which travel over the canvas illuminating the characters behind them. binarypath Binary representations of each character move towards the home coordinate of the character. @@ -218,6 +218,7 @@ View the [Documentation](https://chrisbuilds.github.io/terminaltexteffects/) for slice Slices the input in half and slides it into place from opposite directions. slide Slide characters into view from outside the terminal. smoke Smoke floods the canvas colorizing any characters it crosses. + snake Snakes carry characters through the canvas and assemble the input text. spotlights Spotlights search the text area, illuminating characters, before converging in the center and expanding. spray Draws the characters spawning at varying rates from a single point. swarm Characters are grouped into swarms and move around the terminal before settling into position. diff --git a/docs/effects/snake.md b/docs/effects/snake.md new file mode 100644 index 00000000..a7da8209 --- /dev/null +++ b/docs/effects/snake.md @@ -0,0 +1,16 @@ +# Snake + +Multiple snakes enter from the canvas edges, carry the input characters through orthogonal paths, and deposit them into the final text. + +## Quick Start + +``` py title="snake.py" +from terminaltexteffects.effects.effect_snake import Snake + +effect = Snake("YourTextHere") +with effect.terminal_output() as terminal: + for frame in effect: + terminal.print(frame) +``` + +::: terminaltexteffects.effects.effect_snake diff --git a/docs/showroom.md b/docs/showroom.md index 9848f783..0f34912e 100644 --- a/docs/showroom.md +++ b/docs/showroom.md @@ -1214,6 +1214,36 @@ Smoke floods the canvas colorizing any characters it crosses. ``` --- +## Snake + +Multiple snakes enter from the canvas edges, carry the input characters through orthogonal paths, and deposit them into the final text. + +[Reference](./effects/snake.md){ .md-button } [Config](./effects/snake.md#terminaltexteffects.effects.effect_snake.SnakeConfig){ .md-button } + +??? example "Snake Command Line Arguments" + + ``` + --snake-count (int > 0) + Maximum number of snakes. The effective count is limited by the number of input characters. (default: 4) + --snake-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] + Colors assigned to snakes in sequence. (default: (Color('22c55e'), Color('84cc16'), Color('a3e635'))) + --movement-speed (float > 0) + Speed of snake head movement along the grid. (default: 1.0) + --spawn-delay (int >= 0) + Number of frames between snake spawns. (default: 3) + --head-symbol (ASCII/UTF-8 character) + Symbol used for each snake head. (default: ●) + --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] + Space separated, unquoted, list of colors for the character gradient (applied across the canvas). If only one color is provided, the characters will be displayed in that color. (default: (Color('22c55e'), Color('84cc16'), Color('fde047'))) + --final-gradient-steps (int > 0) [(int > 0) ...] + Space separated, unquoted, list of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. (default: 12) + --final-gradient-direction (diagonal, horizontal, vertical, radial) + Direction of the final gradient across the text. (default: Direction.HORIZONTAL) + + Example: terminaltexteffects snake --snake-count 4 --snake-colors 22c55e 84cc16 a3e635 --movement-speed 1 --spawn-delay 3 --head-symbol ● --final-gradient-stops 22c55e 84cc16 fde047 --final-gradient-steps 12 --final-gradient-direction horizontal + ``` +--- + ## Spotlights Spotlights search the text area, illuminating characters, before converging in the center and expanding. diff --git a/mkdocs.yml b/mkdocs.yml index 48033045..0cb7ad77 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -126,6 +126,7 @@ nav: - effects/slice.md - effects/slide.md - effects/smoke.md + - effects/snake.md - effects/spotlights.md - effects/spray.md - effects/swarm.md diff --git a/terminaltexteffects/effects/__init__.py b/terminaltexteffects/effects/__init__.py index a30224a6..d3725482 100644 --- a/terminaltexteffects/effects/__init__.py +++ b/terminaltexteffects/effects/__init__.py @@ -27,6 +27,7 @@ from terminaltexteffects.effects.effect_slice import Slice from terminaltexteffects.effects.effect_slide import Slide from terminaltexteffects.effects.effect_smoke import Smoke +from terminaltexteffects.effects.effect_snake import Snake from terminaltexteffects.effects.effect_spotlights import Spotlights from terminaltexteffects.effects.effect_spray import Spray from terminaltexteffects.effects.effect_swarm import Swarm diff --git a/terminaltexteffects/effects/effect_snake.py b/terminaltexteffects/effects/effect_snake.py new file mode 100644 index 00000000..e91442af --- /dev/null +++ b/terminaltexteffects/effects/effect_snake.py @@ -0,0 +1,350 @@ +"""Snakes carry input characters through orthogonal paths and assemble the final text.""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from typing import cast + +from terminaltexteffects import Color, ColorPair, Coord, EffectCharacter, EventHandler, Gradient +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 CLI command, effect class, and configuration class.""" + return "snake", Snake, SnakeConfig + + +@dataclass +class SnakeConfig(BaseConfig): + """Configuration for the Snake effect.""" + + parser_spec: argutils.ParserSpec = argutils.ParserSpec( + name="snake", + help="Snakes carry characters through the canvas and assemble the input text.", + description="snake | Snakes carry characters through the canvas and assemble the input text.", + epilog=( + "Example: terminaltexteffects snake --snake-count 4 --snake-colors 22c55e 84cc16 a3e635 " + "--movement-speed 1 --spawn-delay 3 --head-symbol ● --final-gradient-stops 22c55e 84cc16 fde047 " + "--final-gradient-steps 12 --final-gradient-direction horizontal" + ), + ) + + snake_count: int = argutils.ArgSpec( + name="--snake-count", + type=argutils.PositiveInt.type_parser, + default=4, + metavar=argutils.PositiveInt.METAVAR, + help="Maximum number of snakes. The effective count is limited by the number of input characters.", + ) # pyright: ignore[reportAssignmentType] + + snake_colors: tuple[Color, ...] = argutils.ArgSpec( + name="--snake-colors", + type=argutils.ColorArg.type_parser, + nargs="+", + action=argutils.TupleAction, + default=(Color("#22c55e"), Color("#84cc16"), Color("#a3e635")), + metavar=argutils.ColorArg.METAVAR, + help="Colors assigned to snakes in sequence.", + ) # pyright: ignore[reportAssignmentType] + + movement_speed: float = argutils.ArgSpec( + name="--movement-speed", + type=argutils.PositiveFloat.type_parser, + default=1.0, + metavar=argutils.PositiveFloat.METAVAR, + help="Speed of snake head movement along the grid.", + ) # pyright: ignore[reportAssignmentType] + + spawn_delay: int = argutils.ArgSpec( + name="--spawn-delay", + type=argutils.NonNegativeInt.type_parser, + default=3, + metavar=argutils.NonNegativeInt.METAVAR, + help="Number of frames between snake spawns.", + ) # pyright: ignore[reportAssignmentType] + + head_symbol: str = argutils.ArgSpec( + name="--head-symbol", + type=argutils.Symbol.type_parser, + default="●", + metavar=argutils.Symbol.METAVAR, + help="Symbol used for each snake head.", + ) # pyright: ignore[reportAssignmentType] + + final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( + default=(Color("#22c55e"), Color("#84cc16"), Color("#fde047")), + ) # pyright: ignore[reportAssignmentType] + + final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( + default=12, + ) # pyright: ignore[reportAssignmentType] + + final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( + default=Gradient.Direction.HORIZONTAL, + ) # pyright: ignore[reportAssignmentType] + + +@dataclass +class _SnakeState: + head: EffectCharacter + carried: deque[EffectCharacter] + entry_edge: str + trail: deque[Coord] + arrived: bool = False + horizontal_first: bool = True + route_number: int = 0 + + +class SnakeIterator(BaseEffectIterator[SnakeConfig]): + """Iterator for the Snake effect.""" + + DYNAMIC_NEUTRAL_GRAY = Color("#808080") + + def __init__(self, effect: Snake) -> None: + """Initialize Snake state for the effect.""" + super().__init__(effect) + self.snakes: list[_SnakeState] = [] + self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} + self.build() + self.pending_snakes = deque(self.snakes) + self.active_snakes: list[_SnakeState] = [] + self.spawn_countdown = 0 + self.final_frame_provided = False + + @staticmethod + def _orthogonal_waypoints(start: Coord, target: Coord, *, horizontal_first: bool) -> list[Coord]: + """Return a direct grid route from start to target with at most one corner.""" + if start.column == target.column or start.row == target.row: + return [target] + corner = Coord(target.column, start.row) if horizontal_first else Coord(start.column, target.row) + return [corner, target] + + @staticmethod + def _update_trail(snake: _SnakeState) -> None: + """Record each crossed grid cell and place body characters along the trail.""" + previous = snake.trail[0] + current = snake.head.motion.current_coord + if current != previous: + next_column = previous.column + column_step = 1 if current.column > previous.column else -1 + while next_column != current.column: + next_column += column_step + snake.trail.appendleft(Coord(next_column, previous.row)) + next_row = previous.row + row_step = 1 if current.row > previous.row else -1 + while next_row != current.row: + next_row += row_step + snake.trail.appendleft(Coord(current.column, next_row)) + while len(snake.trail) > max(len(snake.carried), 1): + snake.trail.pop() + for index, character in enumerate(snake.carried): + trail_index = min(index, len(snake.trail) - 1) + character.motion.set_coordinate(snake.trail[trail_index]) + + @staticmethod + def _mark_arrived(_head: EffectCharacter, snake: _SnakeState) -> None: + snake.arrived = True + + def _activate_route(self, snake: _SnakeState) -> None: + target = snake.carried[0].input_coord + path = snake.head.motion.new_path( + speed=self.config.movement_speed, + path_id=f"route_{snake.route_number}", + ) + snake.route_number += 1 + for waypoint in self._orthogonal_waypoints( + snake.head.motion.current_coord, + target, + horizontal_first=snake.horizontal_first, + ): + path.new_waypoint(waypoint) + snake.horizontal_first = not snake.horizontal_first + snake.head.event_handler.register_event( + EventHandler.Event.PATH_COMPLETE, + path, + EventHandler.Action.CALLBACK, + EventHandler.Callback(self._mark_arrived, snake), + ) + snake.head.motion.activate_path(path) + self.active_characters.add(snake.head) + + def _spawn_snake(self, snake: _SnakeState) -> None: + self.terminal.set_character_visibility(snake.head, is_visible=True) + for character in snake.carried: + self.terminal.set_character_visibility(character, is_visible=True) + self._activate_route(snake) + self.active_snakes.append(snake) + + def _build_settle_scene( + self, + character: EffectCharacter, + snake_color: Color, + final_colors: ColorPair, + ) -> None: + settle_scene = character.animation.new_scene(scene_id="settle") + final_fg = final_colors.fg_color + final_bg = final_colors.bg_color + if self.terminal.config.existing_color_handling == "dynamic": + if final_fg or final_bg: + settle_scene.apply_gradient_to_symbols( + character.input_symbol, + 2, + fg_gradient=Gradient(snake_color, final_fg, steps=6) if final_fg else None, + bg_gradient=( + Gradient(self.terminal.config.terminal_background_color, final_bg, steps=6) + if final_bg + else None + ), + ) + else: + settle_scene.apply_gradient_to_symbols( + character.input_symbol, + 2, + fg_gradient=Gradient(snake_color, self.DYNAMIC_NEUTRAL_GRAY, steps=4), + ) + settle_scene.add_frame(character.input_symbol, 2, colors=ColorPair()) + else: + final_fg = cast("Color", final_fg) + settle_scene.apply_gradient_to_symbols( + character.input_symbol, + 2, + fg_gradient=Gradient(snake_color, final_fg, steps=6), + ) + + def _deposit_character(self, snake: _SnakeState) -> None: + character = snake.carried.popleft() + character.motion.set_coordinate(character.input_coord) + character.layer = 0 + character.animation.activate_scene("settle") + self.active_characters.add(character) + snake.arrived = False + if snake.carried: + self._update_trail(snake) + self._activate_route(snake) + else: + self.terminal.set_character_visibility(snake.head, is_visible=False) + + def _distance_to_edge(self, character: EffectCharacter, entry_edge: str) -> int: + """Return a character's grid distance from an assigned canvas edge.""" + if entry_edge == "left": + return character.input_coord.column - self.terminal.canvas.left + if entry_edge == "right": + return self.terminal.canvas.right - character.input_coord.column + if entry_edge == "top": + return self.terminal.canvas.top - character.input_coord.row + return character.input_coord.row - self.terminal.canvas.bottom + + def build(self) -> None: + """Assign every input character to one effective snake.""" + rows: dict[int, list[EffectCharacter]] = {} + for character in self.terminal.get_characters(): + rows.setdefault(character.input_coord.row, []).append(character) + characters: list[EffectCharacter] = [] + for row_index, row in enumerate(sorted(rows, reverse=True)): + row_characters = sorted(rows[row], key=lambda character: character.input_coord.column) + if row_index % 2: + row_characters.reverse() + characters.extend(row_characters) + snake_count = min(self.config.snake_count, len(characters)) + if not snake_count: + return + final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) + final_gradient_mapping = final_gradient.build_coordinate_color_mapping( + self.terminal.canvas.text_bottom, + self.terminal.canvas.text_top, + self.terminal.canvas.text_left, + self.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + for character in characters: + if self.terminal.config.existing_color_handling == "dynamic": + self.character_final_color_map[character] = ColorPair( + fg=character.animation.input_fg_color, + bg=character.animation.input_bg_color, + ) + else: + self.character_final_color_map[character] = ColorPair( + fg=final_gradient_mapping[character.input_coord], + ) + group_size, remainder = divmod(len(characters), snake_count) + start = 0 + edges = ("left", "right", "top", "bottom") + for snake_index in range(snake_count): + size = group_size + (1 if snake_index < remainder else 0) + entry_edge = edges[snake_index % len(edges)] + carried_group = characters[start : start + size] + if self._distance_to_edge(carried_group[-1], entry_edge) < self._distance_to_edge( + carried_group[0], + entry_edge, + ): + carried_group.reverse() + carried = deque(carried_group) + target = carried[0].input_coord + if entry_edge == "left": + spawn_coord = Coord(self.terminal.canvas.left - 1, target.row) + elif entry_edge == "right": + spawn_coord = Coord(self.terminal.canvas.right + 1, target.row) + elif entry_edge == "top": + spawn_coord = Coord(target.column, self.terminal.canvas.top + 1) + else: + spawn_coord = Coord(target.column, self.terminal.canvas.bottom - 1) + color = self.config.snake_colors[snake_index % len(self.config.snake_colors)] + head = self.terminal.add_character(self.config.head_symbol, spawn_coord) + head.motion.set_coordinate(spawn_coord) + head.animation.set_appearance(self.config.head_symbol, ColorPair(fg=color)) + head.layer = 2 + snake_index * 2 + for character in carried: + character.motion.set_coordinate(spawn_coord) + character.animation.set_appearance(character.input_symbol, ColorPair(fg=color)) + character.layer = head.layer - 1 + self._build_settle_scene(character, color, self.character_final_color_map[character]) + self.snakes.append(_SnakeState(head, carried, entry_edge, deque((spawn_coord,)))) + start += size + + def __next__(self) -> str: + """Advance snake movement, deposition, and settling by one frame.""" + if self.pending_snakes or self.active_snakes or self.active_characters: + self.update() + completed_snakes: list[_SnakeState] = [] + for snake in self.active_snakes: + self._update_trail(snake) + if snake.arrived: + self._deposit_character(snake) + if not snake.carried: + completed_snakes.append(snake) + for snake in completed_snakes: + self.active_snakes.remove(snake) + if self.spawn_countdown: + self.spawn_countdown -= 1 + elif self.pending_snakes: + if self.config.spawn_delay == 0: + while self.pending_snakes: + self._spawn_snake(self.pending_snakes.popleft()) + else: + self._spawn_snake(self.pending_snakes.popleft()) + self.spawn_countdown = self.config.spawn_delay + return self.frame + if not self.final_frame_provided: + self.final_frame_provided = True + return self.frame + raise StopIteration + + +class Snake(BaseEffect[SnakeConfig]): + """Snakes carry characters through the canvas and assemble the input text.""" + + @property + def _config_cls(self) -> type[SnakeConfig]: + return SnakeConfig + + @property + def _iterator_cls(self) -> type[SnakeIterator]: + return SnakeIterator diff --git a/terminaltexteffects/engine/terminal.py b/terminaltexteffects/engine/terminal.py index 2bb57882..4f18cd41 100644 --- a/terminaltexteffects/engine/terminal.py +++ b/terminaltexteffects/engine/terminal.py @@ -359,11 +359,10 @@ def _anchor_text( ) -> list[EffectCharacter]: """Anchors the text within the canvas based on the specified anchor point. - The `characters` argument must be non-empty; this method expects at least one - character when calculating anchored text bounds. + If `characters` is empty, the default zero-valued text bounds are preserved. Args: - characters (list[EffectCharacter]): Non-empty list of characters to reposition within the canvas. + characters (list[EffectCharacter]): Characters to reposition within the canvas. anchor (Literal["n", "ne", "e", "se", "s", "sw", "w", "nw", "c"]): Anchor point for the text within the Canvas. @@ -372,6 +371,9 @@ def _anchor_text( coordinates within the canvas after anchoring. """ + if not characters: + return [] + # translate coordinate based on anchor within the canvas input_width = max([character._input_coord.column for character in characters]) input_height = max([character._input_coord.row for character in characters]) diff --git a/tests/conftest.py b/tests/conftest.py index 0e932f92..cc5ed8ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,7 @@ effect_slice, effect_slide, effect_smoke, + effect_snake, effect_spotlights, effect_spray, effect_swarm, @@ -177,6 +178,7 @@ effect_slice.Slice, effect_slide.Slide, effect_smoke.Smoke, + effect_snake.Snake, effect_spotlights.Spotlights, effect_spray.Spray, effect_thunderstorm.Thunderstorm, diff --git a/tests/effects_tests/test_snake.py b/tests/effects_tests/test_snake.py new file mode 100644 index 00000000..5e70937e --- /dev/null +++ b/tests/effects_tests/test_snake.py @@ -0,0 +1,318 @@ +"""Tests for the Snake effect.""" + +from __future__ import annotations + +import importlib +from typing import Literal, cast + +import pytest + +from terminaltexteffects.effects import effect_snake +from terminaltexteffects.engine.terminal import TerminalConfig +from terminaltexteffects.utils.geometry import Coord +from terminaltexteffects.utils.graphics import Color, ColorPair, Gradient + + +def _make_terminal_config( + existing_color_handling: Literal["always", "dynamic", "ignore"] = "ignore", +) -> TerminalConfig: + """Build a no-frame-rate terminal config for effect tests.""" + terminal_config = TerminalConfig._build_config() + terminal_config.frame_rate = 0 + terminal_config.existing_color_handling = existing_color_handling + return terminal_config + + +def _run_to_completion( + effect: effect_snake.Snake, + frame_limit: int = 500, +) -> effect_snake.SnakeIterator: + """Run an effect to completion with an infinite-iteration guard.""" + iterator = cast("effect_snake.SnakeIterator", iter(effect)) + for _ in range(frame_limit): + try: + next(iterator) + except StopIteration: # noqa: PERF203 + return iterator + pytest.fail(f"Snake iterator did not terminate within {frame_limit} frames") + + +def test_snake_module_exposes_public_effect_resources() -> None: + """Snake modules expose the standard discovery resources.""" + effect_snake = importlib.import_module("terminaltexteffects.effects.effect_snake") + + assert effect_snake.get_effect_resources() == ( + "snake", + effect_snake.Snake, + effect_snake.SnakeConfig, + ) + + +def test_snake_is_exported_from_effects_package() -> None: + """Snake is available from the public effects package.""" + effects = importlib.import_module("terminaltexteffects.effects") + + assert effects.Snake.__name__ == "Snake" + + +def test_snake_config_builds_expected_defaults() -> None: + """Snake config builds the documented defaults.""" + effect_snake = importlib.import_module("terminaltexteffects.effects.effect_snake") + + config = effect_snake.SnakeConfig._build_config() + + assert config.snake_count == 4 + assert config.snake_colors == (Color("#22c55e"), Color("#84cc16"), Color("#a3e635")) + assert config.movement_speed == 1.0 + assert config.spawn_delay == 3 + assert config.head_symbol == "●" + assert config.final_gradient_stops == (Color("#22c55e"), Color("#84cc16"), Color("#fde047")) + assert config.final_gradient_steps == 12 + assert config.final_gradient_direction is Gradient.Direction.HORIZONTAL + + +def test_snake_count_clamps_and_assigns_each_target_once() -> None: + """Effective snake count clamps and every target is uniquely assigned.""" + effect = effect_snake.Snake("ABCDEF") + effect.effect_config.snake_count = 20 + effect.terminal_config = TerminalConfig._build_config() + effect.terminal_config.frame_rate = 0 + + iterator = cast("effect_snake.SnakeIterator", iter(effect)) + assigned = [character for snake in iterator.snakes for character in snake.carried] + + assert len(iterator.snakes) == 6 + assert len(assigned) == 6 + assert set(assigned) == set(iterator.terminal.get_characters()) + + +def test_snake_targets_follow_a_row_serpentine_route() -> None: + """Targets follow a coherent alternating row route.""" + effect = effect_snake.Snake("ABC\nDEF") + effect.effect_config.snake_count = 1 + + iterator = cast("effect_snake.SnakeIterator", iter(effect)) + + assert "".join(character.input_symbol for character in iterator.snakes[0].carried) == "ABCFED" + + +def test_snake_head_starts_outside_its_assigned_canvas_edge() -> None: + """A snake head begins one cell outside its assigned edge.""" + effect = effect_snake.Snake("ABCD") + effect.effect_config.snake_count = 1 + + iterator = cast("effect_snake.SnakeIterator", iter(effect)) + snake = iterator.snakes[0] + + assert snake.entry_edge == "left" + assert snake.head.motion.current_coord.column == iterator.terminal.canvas.left - 1 + assert snake.head.motion.current_coord.row == snake.carried[0].input_coord.row + + +def test_snake_orients_each_target_group_toward_its_entry_edge() -> None: + """Each group starts from the endpoint nearest its entry edge.""" + effect = effect_snake.Snake("ABCDEFGH") + effect.effect_config.snake_count = 2 + + iterator = cast("effect_snake.SnakeIterator", iter(effect)) + + assert iterator.snakes[1].entry_edge == "right" + assert iterator.snakes[1].carried[0].input_symbol == "H" + + +def test_snake_route_inserts_an_orthogonal_corner() -> None: + """Routes between offset targets contain only orthogonal segments.""" + iterator = cast("effect_snake.SnakeIterator", iter(effect_snake.Snake("A"))) + + waypoints = iterator._orthogonal_waypoints(Coord(1, 1), Coord(4, 3), horizontal_first=True) + + assert waypoints == [Coord(4, 1), Coord(4, 3)] + assert all( + start.column == end.column or start.row == end.row for start, end in zip([Coord(1, 1), *waypoints], waypoints) + ) + + +def test_snake_trail_records_every_grid_cell_crossed_by_the_head() -> None: + """Head jumps are expanded into every crossed grid coordinate.""" + effect = effect_snake.Snake("ABCD") + effect.effect_config.snake_count = 1 + iterator = cast("effect_snake.SnakeIterator", iter(effect)) + snake = iterator.snakes[0] + start = snake.head.motion.current_coord + snake.head.motion.set_coordinate(Coord(start.column + 3, start.row)) + + iterator._update_trail(snake) + + assert list(snake.trail)[:4] == [ + Coord(start.column + 3, start.row), + Coord(start.column + 2, start.row), + Coord(start.column + 1, start.row), + start, + ] + assert [character.motion.current_coord for character in snake.carried] == list(snake.trail)[:4] + + +def test_snake_zero_spawn_delay_activates_multiple_snakes_together() -> None: + """A zero delay starts multiple snakes in the same frame.""" + effect = effect_snake.Snake("ABCDEFGH") + effect.effect_config.snake_count = 4 + effect.effect_config.spawn_delay = 0 + iterator = cast("effect_snake.SnakeIterator", iter(effect)) + + next(iterator) + + assert all(snake.head.is_visible for snake in iterator.snakes) + assert all(snake.head.motion.active_path is not None for snake in iterator.snakes) + + +def test_snake_deposits_every_character_and_cleans_up_heads() -> None: + """Completion deposits exact input state and hides auxiliary heads.""" + effect = effect_snake.Snake("SNAKE\nGAME!") + effect.effect_config.snake_count = 3 + effect.effect_config.spawn_delay = 0 + effect.effect_config.movement_speed = 2 + effect.terminal_config = TerminalConfig._build_config() + effect.terminal_config.frame_rate = 0 + iterator = _run_to_completion(effect) + + assert all(character.is_visible for character in iterator.terminal.get_characters()) + assert all( + character.motion.current_coord == character.input_coord for character in iterator.terminal.get_characters() + ) + assert all( + character.animation.current_character_visual.symbol == character.input_symbol + for character in iterator.terminal.get_characters() + ) + assert all(not snake.head.is_visible for snake in iterator.snakes) + assert iterator.active_characters == set() + + +def test_snake_body_follows_the_head_around_a_turn() -> None: + """Body segments retain the head's corner in their trail.""" + effect = effect_snake.Snake("ABCDE") + effect.effect_config.snake_count = 1 + iterator = cast("effect_snake.SnakeIterator", iter(effect)) + snake = iterator.snakes[0] + start = snake.head.motion.current_coord + snake.head.motion.set_coordinate(Coord(start.column + 3, start.row)) + iterator._update_trail(snake) + snake.head.motion.set_coordinate(Coord(start.column + 3, start.row + 2)) + + iterator._update_trail(snake) + + body_coords = [character.motion.current_coord for character in snake.carried] + assert Coord(start.column + 3, start.row) in body_coords + assert all( + abs(first.column - second.column) + abs(first.row - second.row) <= 1 + for first, second in zip(body_coords, body_coords[1:]) + ) + + +def test_snake_whitespace_only_input_emits_one_blank_frame_and_stops() -> None: + """Whitespace-only input produces a blank finite effect.""" + effect = effect_snake.Snake(" \n ") + effect.terminal_config = _make_terminal_config() + iterator = cast("effect_snake.SnakeIterator", iter(effect)) + + assert next(iterator).strip() == "" + with pytest.raises(StopIteration): + next(iterator) + + +def test_snake_dynamic_mode_restores_input_foreground_and_background_colors() -> None: + """Dynamic mode restores parsed foreground and background colors.""" + effect = effect_snake.Snake("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") + effect.effect_config.movement_speed = 2 + effect.terminal_config = _make_terminal_config("dynamic") + + iterator = _run_to_completion(effect) + character = iterator.terminal.get_characters()[0] + + assert character.animation.current_character_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) + + +def test_snake_dynamic_mode_preserves_a_background_colored_space() -> None: + """Dynamic mode preserves a background-colored input space.""" + effect = effect_snake.Snake("\x1b[48;5;106m \x1b[0m") + effect.effect_config.movement_speed = 2 + effect.terminal_config = _make_terminal_config("dynamic") + + iterator = _run_to_completion(effect) + character = iterator.terminal.get_characters()[0] + + assert character.input_symbol == " " + assert character.animation.current_character_visual.colors == ColorPair(bg=Color(106)) + + +def test_snake_ignore_mode_finishes_with_the_configured_gradient_color() -> None: + """Ignore mode settles into the configured final gradient.""" + effect = effect_snake.Snake("A") + effect.effect_config.movement_speed = 2 + effect.effect_config.final_gradient_stops = (Color("#ff00ff"),) + effect.terminal_config = _make_terminal_config("ignore") + + iterator = _run_to_completion(effect) + character = iterator.terminal.get_characters()[0] + + assert character.animation.current_character_visual.colors == ColorPair(fg=Color("#ff00ff")) + + +def test_snake_always_mode_finishes_with_input_colors() -> None: + """Always mode finishes with engine-controlled input colors.""" + effect = effect_snake.Snake("\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m") + effect.effect_config.movement_speed = 2 + effect.terminal_config = _make_terminal_config("always") + + iterator = _run_to_completion(effect) + character = iterator.terminal.get_characters()[0] + + assert character.animation.current_character_visual.colors == ColorPair(fg=Color(196), bg=Color(106)) + + +def test_snake_empty_input_uses_the_terminal_no_input_convention() -> None: + """Empty input retains the engine's No Input convention.""" + effect = effect_snake.Snake("") + effect.effect_config.movement_speed = 4 + effect.effect_config.spawn_delay = 0 + effect.terminal_config = _make_terminal_config() + + iterator = _run_to_completion(effect) + + assert "".join(character.input_symbol for character in iterator.terminal.get_characters()) == "NoInput." + + +def test_snake_visibly_shrinks_after_depositing_a_character() -> None: + """A deposited character leaves the carried body shorter.""" + effect = effect_snake.Snake("AB") + effect.effect_config.snake_count = 1 + effect.effect_config.spawn_delay = 0 + effect.effect_config.movement_speed = 1 + effect.terminal_config = _make_terminal_config() + iterator = cast("effect_snake.SnakeIterator", iter(effect)) + snake = iterator.snakes[0] + + for _ in range(20): + next(iterator) + if len(snake.carried) == 1: + break + + assert len(snake.carried) == 1 + deposited = iterator.terminal.get_characters()[0] + assert deposited.motion.current_coord == deposited.input_coord + assert deposited.layer == 0 + assert snake.head.is_visible + + +def test_snake_tiny_canvas_clamps_to_the_retained_character() -> None: + """Tiny canvases animate only the input character retained by Terminal.""" + effect = effect_snake.Snake("AB") + effect.effect_config.snake_count = 4 + effect.effect_config.movement_speed = 2 + effect.terminal_config = _make_terminal_config() + effect.terminal_config.canvas_width = 1 + effect.terminal_config.canvas_height = 1 + + iterator = _run_to_completion(effect) + + assert len(iterator.snakes) == 1 + assert [character.input_symbol for character in iterator.terminal.get_characters()] == ["A"] diff --git a/tests/engine_tests/test_terminal.py b/tests/engine_tests/test_terminal.py index 27336aa0..8b8380c8 100644 --- a/tests/engine_tests/test_terminal.py +++ b/tests/engine_tests/test_terminal.py @@ -71,6 +71,17 @@ def test_canvas_anchor_text(anchor) -> None: assert chars[1].motion.current_coord == Coord(6, 6) +def test_terminal_accepts_plain_whitespace_without_input_characters() -> None: + """Plain whitespace creates a valid Terminal with no input characters.""" + terminal = Terminal(" \n ") + + assert terminal.get_characters() == [] + assert terminal.canvas.text_left == 0 + assert terminal.canvas.text_right == 0 + assert terminal.canvas.text_bottom == 0 + assert terminal.canvas.text_top == 0 + + def test_canvas_coord_is_in_canvas() -> None: canvas = Canvas(10, 10) assert canvas.coord_is_in_canvas(Coord(5, 5)) diff --git a/tests/test_cli.py b/tests/test_cli.py index db092da1..794afeed 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -88,10 +88,64 @@ def test_build_parser_registers_effects() -> None: assert "matrix" in effect_resource_map assert "highlight" in effect_resource_map + assert "snake" in effect_resource_map help_output = parser.format_help() assert "matrix" in help_output assert "highlight" in help_output + assert "snake" in help_output + + +def test_snake_help_exposes_effect_options(capsys: pytest.CaptureFixture[str]) -> None: + """Snake help lists its effect-specific configuration options.""" + parser, _ = __main__.build_parser() + + with pytest.raises(SystemExit, match="0"): + parser.parse_args(["snake", "--help"]) + + output = capsys.readouterr().out + assert "--snake-count" in output + assert "--snake-colors" in output + assert "--head-symbol" in output + assert "--final-gradient-direction" in output + + +def test_snake_count_rejects_zero() -> None: + """Snake count uses positive-integer CLI validation.""" + parser, _ = __main__.build_parser() + + with pytest.raises(SystemExit, match="2"): + parser.parse_args(["snake", "--snake-count", "0"]) + + +def test_snake_cli_invocation_renders_the_final_input() -> None: + """A representative Snake subprocess renders the final input successfully.""" + result = subprocess.run( + [ + sys.executable, + "-m", + "terminaltexteffects", + "--frame-rate", + "0", + "snake", + "--snake-count", + "2", + "--spawn-delay", + "0", + "--movement-speed", + "2", + ], + input="SNAKE", + capture_output=True, + text=True, + cwd=str(__main__.Path(__file__).resolve().parents[1]), + check=False, + ) + + assert result.returncode == 0 + assert result.stderr == "" + plain_output = __main__.Terminal.ansi_escape_sequence_pattern.sub("", result.stdout) + assert "SNAKE" in plain_output def test_main_print_completion_bash_outputs_script(