diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1a4a54..0250b2e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ --- +* Added Elephant Splash, a responsive effect where a large purple side-profile elephant walks along the floor, draws + bubbles from a bright two-row puddle, sprays pooled water droplets upward, and celebrates the radial branding + reveal. Compact and tiny-canvas fallbacks keep the choreography usable in smaller terminals. * Burn smoke now uses `ParticlePool` for pooled helper characters and event-based reclaim behavior. * LaserEtch sparks now use `ParticlePool` for pooled helper characters and event-based reclaim behavior. @@ -52,6 +55,15 @@ --- * Animation - Fixed `adjust_color_brightness()` rounding so a brightness factor of `1` preserves mixed-channel RGB colors instead of subtly darkening individual channels. +* Animation - Looping sequential and eased scenes now trigger `SCENE_COMPLETE` once at each completed loop boundary; + synced looping scenes no longer trigger the event on every animation tick or discard their frames when no motion + path is active. +* Animation - `Scene.apply_gradient_to_symbols()` now rejects empty symbol sequences and symbols that are not exactly + one character long with an `AnimationSceneError`. +* Animation - Creating a scene with an explicit ID that is already in use now raises `DuplicateSceneIDError` instead + of silently replacing the original scene. +* Application - Invalid user effect plugins now produce a path-specific warning and are skipped without hiding + built-in effects or other valid user plugins. * Blackhole - Fixed repeated in-process renders mutating cached circle coordinates during the collapse phase, which could cause later runs with the same canvas geometry to fail with an `IndexError`. * Thunderstorm - Fixed `text_glow_time` being ignored due to a hardcoded frame duration. It now controls the number diff --git a/docs/appguide.md b/docs/appguide.md index e633baf0..16fdfdf2 100644 --- a/docs/appguide.md +++ b/docs/appguide.md @@ -59,6 +59,8 @@ ls | tte --random-effect --seed 123 --include-effects beams decrypt rain Custom effect modules are discovered from `${XDG_CONFIG_HOME}/terminaltexteffects/effects`, or `~/.config/terminaltexteffects/effects` when `XDG_CONFIG_HOME` is not set. Any `.py` file in that directory that provides `get_effect_resources()` can register an effect command alongside the built-in effects. +If a custom effect cannot be imported or registered, TTE prints a warning to standard error, skips that file, and +keeps the built-in effects and other valid custom effects available. The example below will pass the output of the `ls` command to TTE with the following options: diff --git a/docs/effects/elephantsplash.md b/docs/effects/elephantsplash.md new file mode 100644 index 00000000..033ac23e --- /dev/null +++ b/docs/effects/elephantsplash.md @@ -0,0 +1,27 @@ +# Elephant Splash + +![Demo](../img/effects_demos/elephantsplash_demo.gif) + +A large purple elephant walks along the canvas floor as one coordinated ASCII sprite, stops at a rippling puddle, +lowers its trunk as bubbles draw the water upward, then sprays it toward the centred input. It holds still while the +branding settles, raises its trunk, and walks off while the revealed text remains in place. + +## Quick Start + +``` py title="elephantsplash.py" +from terminaltexteffects.effects.effect_elephant_splash import ElephantSplash + +effect = ElephantSplash("YourTextHere") +with effect.terminal_output() as terminal: + for frame in effect: + terminal.print(frame) +``` + +For the full elephant choreography, use a canvas of at least 41 columns by 16 rows. A taller canvas gives the clearest +separation between the floor-level elephant and centred branding. Smaller canvases automatically use a compact +elephant or a particle-free splash reveal. + +The full-size elephant artwork is adapted from an ASCII elephant by `jgs`, published at +[asciiart.website](https://asciiart.website/art/4937). The on-screen signature is omitted to keep the animation clean. + +::: terminaltexteffects.effects.effect_elephant_splash diff --git a/docs/img/effects_demos/elephantsplash_demo.gif b/docs/img/effects_demos/elephantsplash_demo.gif new file mode 100644 index 00000000..a237828c Binary files /dev/null and b/docs/img/effects_demos/elephantsplash_demo.gif differ diff --git a/docs/showroom.md b/docs/showroom.md index 9848f783..eb13d04c 100644 --- a/docs/showroom.md +++ b/docs/showroom.md @@ -353,6 +353,41 @@ Movie style text decryption effect. ``` --- +## Elephant Splash + +A large purple elephant walks along the bottom, draws bubbles from a bright rippling puddle, and sprays the water +upward to reveal the centred input before celebrating and walking away. + +![Demo](./img/effects_demos/elephantsplash_demo.gif) + +[Reference](./effects/elephantsplash.md){ .md-button } [Config](./effects/elephantsplash.md#terminaltexteffects.effects.effect_elephant_splash.ElephantSplashConfig){ .md-button } + +??? example "Elephant Splash Command Line Arguments" + + ``` + --elephant-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) + Primary color of the elephant. (default: 8B5CF6) + --elephant-highlight-color (XTerm [0-255] OR RGB Hex [000000-ffffff]) + Highlight color used for the elephant's ears, eye, and smile. (default: C4B5FD) + --water-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] + Colors used for the water droplets and splash reveal. (default: 38BDF8 7DD3FC E0F2FE) + --movement-speed (float > 0) + Speed of the elephant's entrance and exit. (default: 0.35) + --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] + Colors used for the completed branding gradient. (default: 8B5CF6 C4B5FD F5F3FF) + --final-gradient-steps (int > 0) [(int > 0) ...] + Number of steps between final gradient stops. (default: 12) + --final-gradient-frames (int > 0) + Frames displayed for each branding cooling step. (default: 4) + --final-gradient-direction (diagonal, horizontal, vertical, radial) + Direction of the completed branding gradient. (default: radial) + --final-hold-frames (int >= 0) + Frames to hold the completed branding. Zero still emits one clean frame. (default: 120) + + Example: terminaltexteffects --canvas-width 0 --canvas-height 0 --anchor-canvas c --anchor-text c elephantsplash + ``` +--- + ## ErrorCorrect Swaps characters from an incorrect initial position to the correct position. diff --git a/mkdocs.yml b/mkdocs.yml index 48033045..85fc8791 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -108,6 +108,7 @@ nav: - effects/colorshift.md - effects/crumble.md - effects/decrypt.md + - effects/elephantsplash.md - effects/errorcorrect.md - effects/expand.md - effects/fireworks.md diff --git a/terminaltexteffects/__main__.py b/terminaltexteffects/__main__.py index 53863880..3e995b8c 100644 --- a/terminaltexteffects/__main__.py +++ b/terminaltexteffects/__main__.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import hashlib import importlib import importlib.util import os @@ -25,20 +26,136 @@ from terminaltexteffects.engine.base_effect import BaseEffect +def _get_effect_resources( + module: ModuleType, +) -> tuple[str, type[BaseEffect], type[BaseConfig]] | None: + """Return validated effect resources from a module when provided.""" + if not hasattr(module, "get_effect_resources"): + return None + resources = module.get_effect_resources() + if not isinstance(resources, tuple) or len(resources) != 3: + msg = "get_effect_resources() must return a three-item tuple" + raise ValueError(msg) + return resources + + +def _register_effect_resources( + resources: tuple[str, type[BaseEffect], type[BaseConfig]], + subparsers: argparse._SubParsersAction, + effect_resource_map: dict[str, tuple[type[BaseEffect], type[BaseConfig]]], +) -> None: + """Register effect resources and populate their CLI options. + + The configuration parser is populated before the resource map is mutated so a + parser failure cannot leave an effect command that is not invokable. + + Raises: + ValueError: If the effect command has already been registered. + + """ + effect_cmd, effect_class, config_class = resources + if effect_cmd in effect_resource_map: + msg = f"Duplicate effect command detected: {effect_cmd}" + raise ValueError(msg) + previous_choices = dict(subparsers.choices) + previous_choice_actions = list(subparsers._choices_actions) + parser_populated = False + try: + config_class._populate_parser(subparsers) + parser_populated = True + finally: + if not parser_populated: + subparsers.choices.clear() + subparsers.choices.update(previous_choices) + subparsers._choices_actions[:] = previous_choice_actions + effect_resource_map[effect_cmd] = (effect_class, config_class) + + +def _validate_user_effect_resources( + resources: tuple[str, type[BaseEffect], type[BaseConfig]], + effect_resource_map: dict[str, tuple[type[BaseEffect], type[BaseConfig]]], +) -> None: + """Validate user resources against disposable parser state.""" + effect_cmd, _, config_class = resources + if not isinstance(effect_cmd, str) or not effect_cmd: + msg = "Effect command must be a non-empty string" + raise ValueError(msg) + if effect_cmd in effect_resource_map: + msg = f"Duplicate effect command detected: {effect_cmd}" + raise ValueError(msg) + parser_spec = config_class.parser_spec + if parser_spec.name != effect_cmd: + msg = f"Effect command '{effect_cmd}' does not match parser command '{parser_spec.name}'" + raise ValueError(msg) + + +def _warn_user_plugin(plugin_file: Path, exc: Exception) -> None: + """Write a non-fatal user plugin warning to stderr.""" + print( + f"Warning: Failed to load user effect plugin '{plugin_file}': {type(exc).__name__}: {exc}", + file=sys.stderr, + ) + + +def _load_user_effect_module(plugin_file: Path, module_name: str) -> ModuleType: + """Load one user effect module under its collision-safe module name.""" + spec = importlib.util.spec_from_file_location(module_name, plugin_file) + if spec is None or spec.loader is None: + msg = "Unable to create a module specification" + raise ImportError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def _register_discovered_effects( + subparsers: argparse._SubParsersAction, + effect_resource_map: dict[str, tuple[type[BaseEffect], type[BaseConfig]]], +) -> None: + """Register built-in effects and isolate failures in user effect plugins.""" + for module_info in pkgutil.iter_modules( + terminaltexteffects.effects.__path__, + terminaltexteffects.effects.__name__ + ".", + ): + module = importlib.import_module(module_info.name) + resources = _get_effect_resources(module) + if resources is not None: + _register_effect_resources(resources, subparsers, effect_resource_map) + + plugins_dir = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "terminaltexteffects" / "effects" + if not plugins_dir.exists(): + return + + for plugin_file in sorted(plugins_dir.glob("*.py")): + if plugin_file.name == "__init__.py": + continue + path_digest = hashlib.sha256(str(plugin_file.resolve()).encode()).hexdigest()[:12] + module_name = f"_terminaltexteffects_user_effect_{plugin_file.stem}_{path_digest}" + try: + module = _load_user_effect_module(plugin_file, module_name) + resources = _get_effect_resources(module) + if resources is not None: + _validate_user_effect_resources(resources, effect_resource_map) + _register_effect_resources(resources, subparsers, effect_resource_map) + except Exception as exc: # noqa: BLE001 + sys.modules.pop(module_name, None) + _warn_user_plugin(plugin_file, exc) + + def build_parser() -> tuple[argparse.ArgumentParser, dict[str, tuple[type[BaseEffect], type[BaseConfig]]]]: """Build the CLI parser and discover available effects. - This includes registering built-in effect modules and user-provided effect - modules from the XDG config effects directory, then returning the parsed CLI - parser together with a mapping of effect command names to their effect and - config classes. + This includes registering built-in effect modules and valid user-provided effect + modules from the XDG config effects directory. User modules that fail to import + or register are skipped with a warning to standard error. Returns: tuple[argparse.ArgumentParser, dict[str, tuple[type[BaseEffect], type[BaseConfig]]]]: The CLI parser and a mapping of effect names to their classes and configurations. Raises: - ValueError: If two discovered effect modules register the same effect command. + ValueError: If built-in effect modules register the same effect command. """ parser = argparse.ArgumentParser( @@ -95,51 +212,7 @@ def build_parser() -> tuple[argparse.ArgumentParser, dict[str, tuple[type[BaseEf effect_resource_map: dict[str, tuple[type[BaseEffect], type[BaseConfig]]] = {} - def _register_effect_from_module(module: ModuleType) -> None: - """Register an effect module's resources and populate its CLI options. - - If the module defines `get_effect_resources()`, that callable is expected to - return the effect command name, effect class, and config class. The config class - is then used to populate the subparser for that effect command. - - Args: - module: The module to inspect for effect resources. - - Raises: - ValueError: If the module registers an effect command that has already been - registered. - - """ - if hasattr(module, "get_effect_resources"): - effect_cmd: str - effect_class: type[BaseEffect] - config_class: type[BaseConfig] - effect_cmd, effect_class, config_class = module.get_effect_resources() - if effect_cmd in effect_resource_map: - msg = f"Duplicate effect command detected: {effect_cmd}" - raise ValueError(msg) - effect_resource_map[effect_cmd] = (effect_class, config_class) - config_class._populate_parser(subparsers) - - for module_info in pkgutil.iter_modules( - terminaltexteffects.effects.__path__, - terminaltexteffects.effects.__name__ + ".", - ): - module = importlib.import_module(module_info.name) - _register_effect_from_module(module) - - plugins_dir = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "terminaltexteffects" / "effects" - if plugins_dir.exists(): - for plugin_file in plugins_dir.glob("*.py"): - if plugin_file.name == "__init__.py": - continue - module_name = plugin_file.stem - spec = importlib.util.spec_from_file_location(module_name, plugin_file) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - _register_effect_from_module(module) + _register_discovered_effects(subparsers, effect_resource_map) return parser, effect_resource_map diff --git a/terminaltexteffects/effects/__init__.py b/terminaltexteffects/effects/__init__.py index a30224a6..bebf1dc8 100644 --- a/terminaltexteffects/effects/__init__.py +++ b/terminaltexteffects/effects/__init__.py @@ -9,6 +9,7 @@ from terminaltexteffects.effects.effect_colorshift import ColorShift from terminaltexteffects.effects.effect_crumble import Crumble from terminaltexteffects.effects.effect_decrypt import Decrypt +from terminaltexteffects.effects.effect_elephant_splash import ElephantSplash, ElephantSplashConfig from terminaltexteffects.effects.effect_errorcorrect import ErrorCorrect from terminaltexteffects.effects.effect_expand import Expand from terminaltexteffects.effects.effect_fireworks import Fireworks diff --git a/terminaltexteffects/effects/effect_beams.py b/terminaltexteffects/effects/effect_beams.py index 3aa96cb4..7c818773 100644 --- a/terminaltexteffects/effects/effect_beams.py +++ b/terminaltexteffects/effects/effect_beams.py @@ -341,8 +341,12 @@ def build(self) -> None: inner_fill_chars=True, ): groups.append(BeamsIterator.Group(column, "column", self.terminal, self.config)) # noqa: PERF401 + configured_characters: set[tte.EffectCharacter] = set() for group in groups: for character in group.characters: + if character in configured_characters: + continue + configured_characters.add(character) beam_row_scn = character.animation.new_scene(scene_id="beam_row") beam_column_scn = character.animation.new_scene(scene_id="beam_column") brigthen_scn = character.animation.new_scene(scene_id="brighten") @@ -393,7 +397,11 @@ def build(self) -> None: bg_gradient=bg_brighten_gradient, ) else: - brigthen_scn.add_frame(character.input_symbol, self.config.final_gradient_frames, colors=tte.ColorPair()) + brigthen_scn.add_frame( + character.input_symbol, + self.config.final_gradient_frames, + colors=tte.ColorPair(), + ) self.pending_groups = groups random.shuffle(self.pending_groups) diff --git a/terminaltexteffects/effects/effect_elephant_splash.py b/terminaltexteffects/effects/effect_elephant_splash.py new file mode 100644 index 00000000..1e88a663 --- /dev/null +++ b/terminaltexteffects/effects/effect_elephant_splash.py @@ -0,0 +1,937 @@ +"""A playful elephant splashes water to reveal the input text.""" + +from __future__ import annotations + +import typing +from dataclasses import dataclass +from enum import Enum, auto + +from terminaltexteffects.engine.base_character import EffectCharacter, EventHandler +from terminaltexteffects.engine.base_config import ( + BaseConfig, + FinalGradientDirectionArg, + FinalGradientFramesArg, + FinalGradientStepsArg, + FinalGradientStopsArg, +) +from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator +from terminaltexteffects.engine.effect_support.particles import ParticlePool +from terminaltexteffects.utils import argutils, easing, geometry +from terminaltexteffects.utils.geometry import Coord +from terminaltexteffects.utils.graphics import Color, ColorPair, Gradient + +if typing.TYPE_CHECKING: + from terminaltexteffects.engine.terminal import Terminal + + +def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: + """Return the command, effect class, and configuration class.""" + return "elephantsplash", ElephantSplash, ElephantSplashConfig + + +def _pad_sprite_poses(poses: dict[str, tuple[str, ...]]) -> dict[str, tuple[str, ...]]: + """Pad authored sprite grids to one shared rectangular bounding box.""" + height = max(len(pose) for pose in poses.values()) + width = max(len(row) for pose in poses.values() for row in pose) + return { + name: tuple(row.ljust(width) for row in (*pose, *("",) * (height - len(pose)))) for name, pose in poses.items() + } + + +@dataclass(frozen=True) +class SpriteCell: + """One persistent local coordinate in a multiline sprite grid.""" + + row: int + column: int + character: EffectCharacter + + +class ElephantState(Enum): + """Authoritative choreography state for coordinated elephant movement.""" + + ENTERING = auto() + WALKING_TO_TARGET = auto() + SETTLING = auto() + LOWERING_TRUNK = auto() + REVEALING_LOGO = auto() + HOLDING = auto() + RAISING_TRUNK = auto() + WALKING_OUT = auto() + COMPLETE = auto() + + +@dataclass +class ElephantSplashConfig(BaseConfig): + """Configuration for the Elephant Splash effect. + + Attributes: + elephant_color: Primary color of the elephant. + elephant_highlight_color: Highlight color for the elephant's expressive details. + water_colors: Colors used by droplets and the initial branding splash. + movement_speed: Legacy movement-speed setting retained for configuration compatibility. + walk_pose_frames: Frames to hold each authored walking pose. + horizontal_step_frames: Frames between one-column sprite-origin steps. + final_gradient_stops: Colors used for the completed branding gradient. + final_gradient_steps: Number of steps between final gradient stops. + final_gradient_frames: Frames displayed for each branding cooling step. + final_gradient_direction: Direction of the completed branding gradient. + final_hold_frames: Frames to hold the completed branding before stopping. + + """ + + parser_spec: argutils.ParserSpec = argutils.ParserSpec( + name="elephantsplash", + help="A playful elephant splashes water to reveal the input text.", + description="elephantsplash | A playful elephant splashes water to reveal the input text.", + epilog=( + "Example: terminaltexteffects --canvas-width 0 --canvas-height 0 --anchor-canvas c " + "--anchor-text c elephantsplash" + ), + ) + + elephant_color: Color = argutils.ArgSpec( + name="--elephant-color", + type=argutils.ColorArg.type_parser, + default=Color("#8B5CF6"), + metavar=argutils.ColorArg.METAVAR, + help="Primary color of the elephant.", + ) # pyright: ignore[reportAssignmentType] + elephant_highlight_color: Color = argutils.ArgSpec( + name="--elephant-highlight-color", + type=argutils.ColorArg.type_parser, + default=Color("#C4B5FD"), + metavar=argutils.ColorArg.METAVAR, + help="Highlight color used for the elephant's ears, eye, and smile.", + ) # pyright: ignore[reportAssignmentType] + water_colors: tuple[Color, ...] = argutils.ArgSpec( + name="--water-colors", + type=argutils.ColorArg.type_parser, + nargs="+", + action=argutils.TupleAction, + default=(Color("#38BDF8"), Color("#7DD3FC"), Color("#E0F2FE")), + metavar=argutils.ColorArg.METAVAR, + help="Colors used for the water droplets and splash reveal.", + ) # pyright: ignore[reportAssignmentType] + movement_speed: float = argutils.ArgSpec( + name="--movement-speed", + type=argutils.PositiveFloat.type_parser, + default=0.35, + metavar=argutils.PositiveFloat.METAVAR, + help="Legacy movement-speed setting retained for configuration compatibility.", + ) # pyright: ignore[reportAssignmentType] + walk_pose_frames: int = argutils.ArgSpec( + name="--walk-pose-frames", + type=argutils.PositiveInt.type_parser, + default=8, + metavar=argutils.PositiveInt.METAVAR, + help="Number of frames to hold each walking pose.", + ) # pyright: ignore[reportAssignmentType] + horizontal_step_frames: int = argutils.ArgSpec( + name="--horizontal-step-frames", + type=argutils.PositiveInt.type_parser, + default=4, + metavar=argutils.PositiveInt.METAVAR, + help="Number of frames between one-column elephant steps.", + ) # pyright: ignore[reportAssignmentType] + final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( + default=(Color("#8B5CF6"), Color("#C4B5FD"), Color("#F5F3FF")), + ) # pyright: ignore[reportAssignmentType] + final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg(default=12) # pyright: ignore[reportAssignmentType] + final_gradient_frames: int = FinalGradientFramesArg(default=4) # pyright: ignore[reportAssignmentType] + final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( + default=Gradient.Direction.RADIAL, + ) # pyright: ignore[reportAssignmentType] + final_hold_frames: int = argutils.ArgSpec( + name="--final-hold-frames", + type=argutils.NonNegativeInt.type_parser, + default=120, + metavar=argutils.NonNegativeInt.METAVAR, + help="Number of frames to hold the completed branding. Zero still emits one clean final frame.", + ) # pyright: ignore[reportAssignmentType] + + +class ElephantSplashIterator(BaseEffectIterator[ElephantSplashConfig]): + """Iterator for the Elephant Splash effect.""" + + DRINK_FRAMES: typing.ClassVar[int] = 72 + CELEBRATE_FRAMES: typing.ClassVar[int] = 48 + + _FULL_TRUNK_DOWN: typing.ClassVar[tuple[str, ...]] = ( + "", + "", + "", + ' .-""-.-""""-.', + " /' \\ \\", + ' .-""""-/ ( \'-.', + " .' | ; e \\", + " / \\ | __. |", + " / '._ ; .-' |", + " //| \\ \\,-' |", + " // | `;.___> \\ |", + " /`| / |`\\ \\ |", + " |/ / _,.-----\\ | \\ | |", + " / .; | | | \\ | |", + " | / | \\ / |\\__/ | |", + " \\__/ \\___/ \\___/ \\_)", + ) + _FULL_WALKING_TRUNK: typing.ClassVar[tuple[str, ...]] = ( + *_FULL_TRUNK_DOWN[:14], + " | / | \\ / |\\__/ \\_)", + " \\__/ \\___/ \\___/", + ) + _FULL_TRUNK_LOWERING_MID: typing.ClassVar[tuple[str, ...]] = ( + *_FULL_TRUNK_DOWN[:14], + " | / | \\ / |\\__/ \\ |", + " \\__/ \\___/ \\___/", + ) + _FULL_TRUNK_MID: typing.ClassVar[tuple[str, ...]] = ( + "", + "", + " .-~", + ' .-""-.-""""-. / ;', + " /' \\ \\ / /", + ' .-""""-/ ( \'--\' /', + " .' | ; e /", + " / \\ | __.'", + " / '._ ; .-'", + " //| \\ \\,-'", + " // | `;.___>", + " /`| / |`\\", + " |/ / _,.-----\\ | \\", + " / .; | | | \\", + " | / | \\ / |\\__/", + " \\__/ \\___/ \\___/", + ) + _FULL_TRUNK_UP: typing.ClassVar[tuple[str, ...]] = ( + " _", + " / )", + " ; |", + ' .-""-.-""""-. | ;', + " /' \\ \\ / /", + ' .-""""-/ ( \'--\' /', + " .' | ; e /", + " / \\ | __.'", + " / '._ ; .-'", + " //| \\ \\,-'", + " // | `;.___>", + " /`| / |`\\", + " |/ / _,.-----\\ | \\", + " / .; | | | \\", + " | / | \\ / |\\__/", + " \\__/ \\___/ \\___/", + ) + _FULL_TRUNK_UP_WIGGLE: typing.ClassVar[tuple[str, ...]] = _FULL_TRUNK_UP + FULL_POSES: typing.ClassVar[dict[str, tuple[str, ...]]] = _pad_sprite_poses( + { + "walk_1": _FULL_WALKING_TRUNK, + "walk_2": ( + *_FULL_WALKING_TRUNK[:-3], + " / .; | | | \\ | |", + " / / | \\ / |\\__/ \\_)", + " \\___/ \\___/ \\___/", + ), + "walk_3": ( + *_FULL_WALKING_TRUNK[:-3], + " / .; | | | \\ | |", + " | / | \\ / |\\__/ \\_)", + " \\___/ \\___/ \\___/", + ), + "walk_4": ( + *_FULL_WALKING_TRUNK[:-3], + " / .; | | | \\ | |", + " | / | \\ / \\__/ \\_)", + " \\__/ \\___/ \\__/", + ), + "drink_1": _FULL_WALKING_TRUNK, + "drink_2": _FULL_TRUNK_LOWERING_MID, + "drink_3": _FULL_TRUNK_DOWN, + "raise_1": _FULL_TRUNK_DOWN, + "raise_2": _FULL_TRUNK_MID, + "raise_3": _FULL_TRUNK_UP, + "spray_1": _FULL_TRUNK_UP, + "spray_2": _FULL_TRUNK_UP_WIGGLE, + "wiggle_1": _FULL_TRUNK_UP, + "wiggle_2": _FULL_TRUNK_UP_WIGGLE, + }, + ) + FULL_TRUNK_TIP_ROWS: typing.ClassVar[dict[str, int]] = { + "walk_1": 14, + "walk_2": 14, + "walk_3": 14, + "walk_4": 14, + "drink_1": 14, + "drink_2": 15, + "drink_3": 15, + "raise_1": 15, + "raise_2": 2, + "raise_3": 0, + "spray_1": 0, + "spray_2": 0, + "wiggle_1": 0, + "wiggle_2": 0, + } + COMPACT_POSES: typing.ClassVar[dict[str, tuple[str, ...]]] = _pad_sprite_poses( + { + "walk_1": (" __", " /' '-.", "| (o) |__", " \\ / ')", " /_\\ /_\\"), + "walk_2": (" __", " /' '-.", "| (o) |__", " \\ / ')", " _/\\ /_\\"), + "walk_3": (" __", " /' '-.", "| (o) |__", " \\ / ')", " /_\\ _/\\"), + "walk_4": (" __", " /' '-.", "| (o) |__", " \\ / ')", " _/\\ _/\\"), + "drink_1": (" __", " /' '-.", "| (o) \\", " \\ __ \\", " /_\\ /_\\ \\~"), + "drink_2": (" __", " /' '-.", "| (o) \\", " \\ __ \\", " /_\\ /_\\ \\o"), + "drink_3": (" __", " /' '-.", "| (o) \\", " \\ __ \\", " /_\\ /_\\ \\."), + "raise_1": (" __", " /' '-.", "| (o) \\_", " \\ __/'", " /_\\ /_\\"), + "raise_2": (" __", " /' '-.", "| (o) \\__", " \\ __/'", " /_\\ /_\\"), + "raise_3": (" __", " /' '-.", "| (o) \\___", " \\ __/'", " /_\\ /_\\"), + "spray_1": (" __", " /' '-.", "| (o) \\___", " \\ __/'", " /_\\ /_\\"), + "spray_2": (" __", " /' '-.", "| (o) \\___", " \\ __/'", " /_\\ /_\\"), + "wiggle_1": (" __", " /' '-.", "| (o) \\___", " \\ __/'", " /_\\ /_\\"), + "wiggle_2": (" __", " /' '-.", "| (o) \\___", " \\ __/'", " /_\\ /_\\"), + }, + ) + COMPACT_TRUNK_TIP_ROWS: typing.ClassVar[dict[str, int]] = { + "walk_1": 3, + "walk_2": 3, + "walk_3": 3, + "walk_4": 3, + "drink_1": 4, + "drink_2": 4, + "drink_3": 4, + "raise_1": 2, + "raise_2": 2, + "raise_3": 2, + "spray_1": 2, + "spray_2": 2, + "wiggle_1": 2, + "wiggle_2": 2, + } + + class Elephant: + """A rigid, pose-driven group of effect-owned characters.""" + + WALK_POSES: typing.ClassVar[tuple[str, ...]] = ("walk_1", "walk_2", "walk_3", "walk_4") + + def __init__( + self, + terminal: Terminal, + config: ElephantSplashConfig, + poses: dict[str, tuple[str, ...]], + trunk_tip_rows: dict[str, int], + ) -> None: + """Create a pose-driven elephant on the supplied terminal.""" + self.terminal = terminal + self.config = config + self.height = max(len(pose) for pose in poses.values()) + self.width = max(len(row) for pose in poses.values() for row in pose) + self.poses = { + name: tuple(row.ljust(self.width) for row in (*pose, *("",) * (self.height - len(pose)))) + for name, pose in poses.items() + } + self.trunk_tip_offsets = { + name: Coord( + max(column for column, symbol in enumerate(self.poses[name][row_index]) if symbol != " "), + self.height - row_index - 1, + ) + for name, row_index in trunk_tip_rows.items() + } + baseline = terminal.canvas.bottom + self.start_coord = Coord(terminal.canvas.left - self.width, baseline) + self.elephant_x = self.start_coord.column + self.elephant_y = self.start_coord.row + self.anchor = terminal.add_character(" ", self.start_coord) + target_column = max( + terminal.canvas.left, + min(terminal.canvas.center_column - self.width // 2, terminal.canvas.right - self.width + 1), + ) + self.target_coord = Coord(target_column, baseline) + self.character_offsets: dict[EffectCharacter, Coord] = {} + self.cells: list[SpriteCell] = [] + for row in range(self.height): + for column in range(self.width): + offset = Coord(column, row) + character = terminal.add_character(" ", self._coord_for_offset(offset)) + character.layer = 2 + terminal.set_character_visibility(character, is_visible=False) + self.character_offsets[character] = offset + self.cells.append(SpriteCell(row, column, character)) + self.characters = list(self.character_offsets) + self.current_pose = 0 + self.current_pose_name = "walk_1" + self.walk_frame = 0 + self.horizontal_step_frame = 0 + self.exit_column = terminal.canvas.right + 1 + self.apply_pose(self.current_pose_name) + + def _coord_for_offset(self, offset: Coord, anchor_coord: Coord | None = None) -> Coord: + anchor_coord = anchor_coord or Coord(self.elephant_x, self.elephant_y) + return Coord( + anchor_coord.column + offset.column, + anchor_coord.row + offset.row, + ) + + def set_origin(self, elephant_x: int, elephant_y: int) -> None: + """Set the shared integer sprite origin used by every persistent cell.""" + self.elephant_x = elephant_x + self.elephant_y = elephant_y + self.anchor.motion.set_coordinate(Coord(elephant_x, elephant_y)) + + def trunk_coord_for_pose(self, pose_name: str, anchor_coord: Coord | None = None) -> Coord: + """Return the declared trunk-tip coordinate for one pose.""" + return self._coord_for_offset(self.trunk_tip_offsets[pose_name], anchor_coord) + + def apply_pose(self, pose_name: str) -> None: + """Apply one fixed ASCII pose to all sprite characters.""" + pose = self.poses[pose_name] + shadow_color = self.characters[0].animation.adjust_color_brightness(self.config.elephant_color, 0.65) + for cell in self.cells: + row_index = self.height - cell.row - 1 + symbol = pose[row_index][cell.column] + if symbol in {"e", "o", ">"} or "(" in pose[row_index][max(0, cell.column - 1) : cell.column + 2]: + color = self.config.elephant_highlight_color + elif cell.row <= 1: + color = shadow_color + else: + color = self.config.elephant_color + cell.character.animation.set_appearance(symbol, ColorPair(fg=color)) + coord = self._coord_for_offset(Coord(cell.column, cell.row)) + cell.character.motion.set_coordinate(coord) + is_inside_canvas = ( + self.terminal.canvas.left <= coord.column <= self.terminal.canvas.right + and self.terminal.canvas.bottom <= coord.row <= self.terminal.canvas.top + ) + self.terminal.set_character_visibility( + cell.character, + is_visible=symbol != " " and is_inside_canvas, + ) + self.current_pose_name = pose_name + + def step_walk(self, direction: int, limit_column: int | None = None) -> bool: + """Advance the shared origin and central walk cycle using integer counters.""" + self.current_pose = self.walk_frame // self.config.walk_pose_frames % len(self.WALK_POSES) + pose_name = self.WALK_POSES[self.current_pose] + self.horizontal_step_frame += 1 + if self.horizontal_step_frame >= self.config.horizontal_step_frames: + next_column = self.elephant_x + direction + if limit_column is not None: + next_column = min(next_column, limit_column) if direction > 0 else max(next_column, limit_column) + self.set_origin(next_column, self.elephant_y) + self.horizontal_step_frame = 0 + self.apply_pose(pose_name) + self.walk_frame += 1 + return limit_column is not None and self.elephant_x == limit_column + + def tick_walk(self, frame: int) -> None: + """Compatibility wrapper for the central integer walk controller.""" + del frame + self.step_walk(direction=1) + + def start_walk_out(self) -> None: + """Reset the central walk cycle before leaving to the right.""" + self.current_pose = 0 + self.current_pose_name = "walk_1" + self.walk_frame = 0 + self.horizontal_step_frame = 0 + self.apply_pose("walk_1") + + def hide(self) -> None: + """Hide every visible sprite character.""" + for cell in self.cells: + self.terminal.set_character_visibility(cell.character, is_visible=False) + + @property + def trunk_coord(self) -> Coord: + """Return the declared trunk-tip coordinate in the current pose.""" + return self.trunk_coord_for_pose(self.current_pose_name) + + class Puddle: + """A small effect-owned water source resting on the canvas floor.""" + + def __init__( + self, + terminal: Terminal, + colors: tuple[Color, ...], + width: int, + height: int, + near_column: int, + ) -> None: + """Create a visible, canvas-bounded row of water characters.""" + self.terminal = terminal + self.colors = colors + self.width = width + self.height = height + self.start_column = max(terminal.canvas.left, min(near_column, terminal.canvas.right - width + 1)) + symbol_rows = (" .~~~~~~~~~. ", ".~~~~~~~~~~~~~.") if height == 2 else ("~~~",) + self.characters: list[EffectCharacter] = [] + for row_offset, symbols in enumerate(reversed(symbol_rows)): + for column_offset, symbol in enumerate(symbols): + character = terminal.add_character( + symbol, + Coord(self.start_column + column_offset, terminal.canvas.bottom + row_offset), + ) + character.layer = 2 + character.animation.set_appearance( + symbol, + ColorPair(fg=colors[(row_offset + column_offset) % len(colors)]), + ) + terminal.set_character_visibility(character, is_visible=True) + self.characters.append(character) + + @property + def visible_count(self) -> int: + """Return the number of water characters currently on screen.""" + return sum(character in self.terminal._visible_characters for character in self.characters) + + def ripple(self, frame: int) -> None: + """Animate bright surface ripples without changing the puddle footprint.""" + ripple_step = frame // 6 + bubble_column = 2 + ripple_step * 2 % max(1, self.width - 4) + for index, character in enumerate(self.characters): + row_offset, column_offset = divmod(index, self.width) + if self.height == 1: + symbol = "~" if (column_offset + ripple_step) % 2 else "_" + elif row_offset == 0: + symbol = "." if column_offset in {0, self.width - 1} else "~_"[(column_offset + ripple_step) % 2] + elif column_offset == bubble_column: + symbol = "o" + elif 2 <= column_offset < self.width - 2 and (column_offset + ripple_step) % 3 == 0: + symbol = "~" + else: + symbol = " " + color = self.colors[(row_offset + column_offset + ripple_step) % len(self.colors)] + character.animation.set_appearance(symbol, ColorPair(fg=color)) + + def shrink_to(self, visible_count: int) -> None: + """Keep a centred subset visible to make the puddle contract.""" + visible_column_count = min( + self.width, + (max(0, visible_count) + self.height - 1) // self.height, + ) + center = (self.width - 1) / 2 + visible_columns = set( + sorted(range(self.width), key=lambda column: abs(column - center))[:visible_column_count], + ) + for index, character in enumerate(self.characters): + _, column_offset = divmod(index, self.width) + self.terminal.set_character_visibility(character, is_visible=column_offset in visible_columns) + + class Phase(Enum): + """Ordered phases in the Elephant Splash choreography.""" + + WALK_IN = auto() + DRINK = auto() + RAISE_TRUNK = auto() + SPLASH = auto() + REVEAL = auto() + CELEBRATE = auto() + WALK_OUT = auto() + HOLD = auto() + COMPLETE = auto() + + def __init__(self, effect: ElephantSplash) -> None: + """Build the responsive sprite, branding scenes, and particle pool.""" + super().__init__(effect) + full_sprite_width = max(len(row) for pose in self.FULL_POSES.values() for row in pose) + full_sprite_height = max(len(pose) for pose in self.FULL_POSES.values()) + if self.terminal.canvas.width >= full_sprite_width and self.terminal.canvas.height >= full_sprite_height: + self.sprite_mode = "full" + elif self.terminal.canvas.width >= 12 and self.terminal.canvas.height >= 6: + self.sprite_mode = "compact" + else: + self.sprite_mode = "fallback" + self.phase = self.Phase.SPLASH if self.sprite_mode == "fallback" else self.Phase.WALK_IN + self.state = ElephantState.REVEALING_LOGO if self.sprite_mode == "fallback" else ElephantState.ENTERING + self.input_characters = self.terminal.get_characters() + self.reveal_groups: list[list[EffectCharacter]] = [[] for _ in range(12)] + self.character_final_color_map: dict[EffectCharacter, Color] = {} + self._build_branding_reveal() + pose_set = self.FULL_POSES if self.sprite_mode == "full" else self.COMPACT_POSES + trunk_tip_rows = self.FULL_TRUNK_TIP_ROWS if self.sprite_mode == "full" else self.COMPACT_TRUNK_TIP_ROWS + self.elephant = ( + self.Elephant(self.terminal, self.config, pose_set, trunk_tip_rows) + if self.sprite_mode != "fallback" + else None + ) + puddle_width = 3 if self.sprite_mode == "compact" else 15 + puddle_height = 1 if self.sprite_mode == "compact" else 2 + drinking_tip = ( + self.elephant.trunk_coord_for_pose("drink_1", self.elephant.target_coord) + if self.elephant is not None + else None + ) + self.puddle = ( + self.Puddle( + self.terminal, + self.config.water_colors, + puddle_width, + puddle_height, + drinking_tip.column + 1, + ) + if drinking_tip is not None + else None + ) + self.intake_characters = self._make_intake_characters() + self.water_pool = self._make_water_pool() if self.sprite_mode != "fallback" else None + self.phase_frame = 0 + self.droplets_emitted = 0 + self.next_reveal_group = 0 + self.returning_to_walk = False + + @property + def elephant_x(self) -> int: + """Return the shared horizontal sprite origin.""" + return self.elephant.elephant_x if self.elephant is not None else self.terminal.canvas.left + + @property + def elephant_y(self) -> int: + """Return the shared vertical sprite origin.""" + return self.elephant.elephant_y if self.elephant is not None else self.terminal.canvas.bottom + + @property + def current_pose(self) -> int: + """Return the authoritative central walk-pose index.""" + return self.elephant.current_pose if self.elephant is not None else 0 + + def _make_intake_characters(self) -> list[EffectCharacter]: + """Create a small hidden stream used while the elephant drinks.""" + if self.elephant is None or self.puddle is None: + return [] + count = 3 if self.sprite_mode == "full" else 1 + origin = Coord( + self.puddle.start_column + self.puddle.width // 2, + self.terminal.canvas.bottom + self.puddle.height - 1, + ) + intake_characters: list[EffectCharacter] = [] + for index in range(count): + character = self.terminal.add_character((".", "o", "*")[index], origin) + character.layer = 3 + character.animation.set_appearance( + (".", "o", "*")[index], + ColorPair(fg=self.config.water_colors[index % len(self.config.water_colors)]), + ) + self.terminal.set_character_visibility(character, is_visible=False) + intake_characters.append(character) + return intake_characters + + def _animate_intake(self) -> None: + """Pull a cycling line of bubbles from the puddle toward the trunk.""" + assert self.elephant is not None + assert self.puddle is not None + origin = Coord( + self.puddle.start_column + self.puddle.width // 2, + self.terminal.canvas.bottom + self.puddle.height - 1, + ) + destination = self.elephant.trunk_coord + for index, character in enumerate(self.intake_characters): + progress = ((self.phase_frame // 3 + index * 3) % 10 + 1) / 10 + coord = Coord( + round(origin.column + (destination.column - origin.column) * progress), + round(origin.row + (destination.row - origin.row) * progress), + ) + character.motion.set_coordinate(coord) + symbol = (".", "o", "*")[(self.phase_frame // 4 + index) % 3] + color = self.config.water_colors[(self.phase_frame // 6 + index) % len(self.config.water_colors)] + character.animation.set_appearance(symbol, ColorPair(fg=color)) + self.terminal.set_character_visibility(character, is_visible=True) + + def _build_branding_reveal(self) -> None: + """Prepare hidden input characters and their bounded radial reveal scenes.""" + final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) + final_color_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, + ) + water_start = self.config.water_colors[0] + water_finish = self.config.water_colors[-1] + for character in self.input_characters: + character.layer = 1 + self.terminal.set_character_visibility(character, is_visible=False) + self.character_final_color_map[character] = final_color_mapping[character.input_coord] + normalized_distance = geometry.find_normalized_distance_from_center( + self.terminal.canvas.text_bottom, + self.terminal.canvas.text_top, + self.terminal.canvas.text_left, + self.terminal.canvas.text_right, + character.input_coord, + ) + band_index = min(int(normalized_distance * len(self.reveal_groups)), len(self.reveal_groups) - 1) + self.reveal_groups[band_index].append(character) + + reveal_scene = character.animation.new_scene(scene_id="reveal") + reveal_scene.add_frame(".", 2, colors=ColorPair(fg=water_start)) + reveal_scene.add_frame("*", 2, colors=ColorPair(fg=water_finish)) + if self.terminal.config.existing_color_handling == "dynamic": + fg_gradient = ( + Gradient(water_finish, character.animation.input_fg_color, steps=8) + if character.animation.input_fg_color + else None + ) + bg_gradient = ( + Gradient(water_finish, character.animation.input_bg_color, steps=8) + if character.animation.input_bg_color + else None + ) + if fg_gradient or bg_gradient: + reveal_scene.apply_gradient_to_symbols( + character.input_symbol, + self.config.final_gradient_frames, + fg_gradient=fg_gradient, + bg_gradient=bg_gradient, + ) + else: + reveal_scene.add_frame( + character.input_symbol, + self.config.final_gradient_frames, + colors=ColorPair(), + ) + else: + cooling_gradient = Gradient( + water_finish, + self.character_final_color_map[character], + steps=8, + ) + reveal_scene.apply_gradient_to_symbols( + character.input_symbol, + self.config.final_gradient_frames, + fg_gradient=cooling_gradient, + ) + + def _make_water_pool(self) -> ParticlePool: + """Create a fixed-size pool of reusable water droplets.""" + + def initialize_droplet(particle: EffectCharacter) -> None: + particle.layer = 3 + droplet_scene = particle.animation.new_scene(scene_id="droplet", is_looping=True) + for water_color in self.config.water_colors: + droplet_scene.add_frame(particle.input_symbol, 3, colors=ColorPair(fg=water_color)) + + droplet_count = 16 if self.sprite_mode == "compact" else min(48, max(24, (len(self.input_characters) + 1) // 2)) + return ParticlePool( + self.terminal, + self.active_characters, + symbols=(".", "o", "*", "'"), + initial_count=droplet_count, + max_size=droplet_count, + initializer=initialize_droplet, + ) + + def _emit_droplet(self) -> None: + """Emit one curved droplet from the elephant's trunk.""" + if self.elephant is None or self.water_pool is None: + return + origin = self.elephant.trunk_coord + ordered_targets = sorted( + self.input_characters, + key=lambda character: (character.input_coord.column, character.input_coord.row), + ) + target_character = ordered_targets[self.droplets_emitted % len(ordered_targets)] + target = target_character.input_coord + if target == origin: + alternate_column = ( + self.terminal.canvas.right if origin.column != self.terminal.canvas.right else self.terminal.canvas.left + ) + target = Coord(alternate_column, target.row) + + def configure_droplet(particle: EffectCharacter) -> None: + control_row = min( + self.terminal.canvas.top, + max(origin.row, target.row) + 3 + self.droplets_emitted % 4, + ) + control = Coord((origin.column + target.column) // 2, control_row) + droplet_path = particle.motion.new_path(speed=1.6, ease=easing.out_sine) + droplet_path.new_waypoint(target, bezier_control=control) + particle.motion.activate_path(droplet_path) + particle.animation.activate_scene("droplet") + self.water_pool.reclaim_on_event( + particle, + droplet_path, + event=EventHandler.Event.PATH_COMPLETE, + ) + + emitted = self.water_pool.emit(origin, on_emit=configure_droplet) + if emitted is not None: + self.droplets_emitted += 1 + + def __next__(self) -> str: + """Advance and render one frame of the effect.""" + phase_handlers: dict[ElephantSplashIterator.Phase, typing.Callable[[], None]] = { + self.Phase.WALK_IN: self._step_walk_in, + self.Phase.DRINK: self._step_drink, + self.Phase.RAISE_TRUNK: self._step_raise_trunk, + self.Phase.SPLASH: self._step_splash, + self.Phase.REVEAL: self._step_reveal, + self.Phase.CELEBRATE: self._step_celebrate, + self.Phase.WALK_OUT: self._step_walk_out, + self.Phase.HOLD: self._step_hold, + } + handler = phase_handlers.get(self.phase) + if handler is None: + raise StopIteration + handler() + return self.frame + + def _step_walk_in(self) -> None: + """Advance the walking entrance by one frame.""" + assert self.elephant is not None + assert self.puddle is not None + self.puddle.ripple(self.phase_frame) + if self.state in {ElephantState.ENTERING, ElephantState.WALKING_TO_TARGET}: + reached_target = self.elephant.step_walk(direction=1, limit_column=self.elephant.target_coord.column) + if self.elephant.elephant_x >= self.terminal.canvas.left: + self.state = ElephantState.WALKING_TO_TARGET + if reached_target: + self.state = ElephantState.SETTLING + self.phase_frame = 0 + else: + self.phase_frame += 1 + return + walk_cycle_frames = self.config.walk_pose_frames * len(self.elephant.WALK_POSES) + if self.elephant.walk_frame % walk_cycle_frames: + self.elephant.step_walk(direction=0) + return + self.elephant.current_pose = 0 + self.elephant.apply_pose("walk_1") + self.phase_frame += 1 + if self.phase_frame >= 8: + self.phase = self.Phase.DRINK + self.state = ElephantState.LOWERING_TRUNK + self.phase_frame = 0 + + def _step_drink(self) -> None: + """Lower the trunk and consume the puddle from its edges inward.""" + assert self.elephant is not None + assert self.puddle is not None + self.state = ElephantState.LOWERING_TRUNK + self.puddle.ripple(self.phase_frame) + pose_index = min(self.phase_frame // (self.DRINK_FRAMES // 3) + 1, 3) + self.elephant.apply_pose(f"drink_{pose_index}") + self._animate_intake() + self.phase_frame += 1 + remaining_water = ( + len(self.puddle.characters) - self.phase_frame * len(self.puddle.characters) // self.DRINK_FRAMES + ) + self.puddle.shrink_to(max(0, remaining_water)) + if self.phase_frame >= self.DRINK_FRAMES: + for character in self.intake_characters: + self.terminal.set_character_visibility(character, is_visible=False) + self.phase = self.Phase.RAISE_TRUNK + self.state = ElephantState.RAISING_TRUNK + self.phase_frame = 0 + + def _step_raise_trunk(self) -> None: + """Advance the three-pose trunk raise by one frame.""" + assert self.elephant is not None + self.state = ElephantState.RAISING_TRUNK + if self.returning_to_walk: + pose_index = max(3 - self.phase_frame // 10, 1) + self.elephant.apply_pose(f"raise_{pose_index}") + self.phase_frame += 1 + if self.phase_frame >= 30: + self.returning_to_walk = False + self.elephant.start_walk_out() + self.phase = self.Phase.WALK_OUT + self.state = ElephantState.WALKING_OUT + self.phase_frame = 0 + return + pose_index = min(self.phase_frame // 10 + 1, 3) + self.elephant.apply_pose(f"raise_{pose_index}") + self.phase_frame += 1 + if self.phase_frame >= 30: + self.phase = self.Phase.SPLASH + self.state = ElephantState.REVEALING_LOGO + self.phase_frame = 0 + + def _step_splash(self) -> None: + """Advance either the particle splash or the tiny-canvas fallback.""" + self.state = ElephantState.REVEALING_LOGO + if self.elephant is None: + symbol = "." if self.phase_frame < 3 else "*" + color = self.config.water_colors[0] if self.phase_frame < 3 else self.config.water_colors[-1] + for character in self.input_characters: + self.terminal.set_character_visibility(character, is_visible=True) + character.animation.set_appearance(symbol, ColorPair(fg=color)) + self.phase_frame += 1 + if self.phase_frame >= 6: + self.phase = self.Phase.REVEAL + self.phase_frame = 0 + return + assert self.water_pool is not None + self.elephant.apply_pose("spray_1") + if self.droplets_emitted < len(self.water_pool): + self._emit_droplet() + self.update() + self.phase_frame += 1 + if self.droplets_emitted == len(self.water_pool) and len(self.water_pool.available) == len(self.water_pool): + self.phase = self.Phase.REVEAL + self.phase_frame = 0 + + def _step_reveal(self) -> None: + """Release one radial band every two frames and await scene completion.""" + self.state = ElephantState.REVEALING_LOGO + if self.phase_frame % 2 == 0 and self.next_reveal_group < len(self.reveal_groups): + for character in self.reveal_groups[self.next_reveal_group]: + self.terminal.set_character_visibility(character, is_visible=True) + character.animation.activate_scene("reveal") + self.active_characters.add(character) + self.next_reveal_group += 1 + if self.elephant is not None: + self.elephant.apply_pose("spray_1") + self.update() + self.phase_frame += 1 + input_characters_are_active = any(character in self.active_characters for character in self.input_characters) + if self.next_reveal_group == len(self.reveal_groups) and not input_characters_are_active: + if self.elephant is not None: + self.phase = self.Phase.CELEBRATE + self.state = ElephantState.HOLDING + self.phase_frame = 0 + else: + self.phase = self.Phase.HOLD + self.state = ElephantState.HOLDING + self.phase_frame = 1 + + def _step_celebrate(self) -> None: + """Hold the complete elephant and branding before leaving.""" + assert self.elephant is not None + self.state = ElephantState.HOLDING + self.elephant.apply_pose("spray_1") + self.phase_frame += 1 + if self.phase_frame >= self.CELEBRATE_FRAMES: + self.returning_to_walk = True + self.phase = self.Phase.RAISE_TRUNK + self.state = ElephantState.RAISING_TRUNK + self.phase_frame = 0 + + def _step_walk_out(self) -> None: + """Advance the elephant beyond the right edge and hide its helpers.""" + assert self.elephant is not None + self.state = ElephantState.WALKING_OUT + reached_exit = self.elephant.step_walk(direction=1, limit_column=self.elephant.exit_column) + self.phase_frame += 1 + if reached_exit: + self.elephant.hide() + self.phase = self.Phase.HOLD + self.state = ElephantState.HOLDING + self.phase_frame = 1 + + def _step_hold(self) -> None: + """Hold the clean final branding frame for the configured duration.""" + self.state = ElephantState.HOLDING + if self.phase_frame >= max(1, self.config.final_hold_frames): + self.phase = self.Phase.COMPLETE + self.state = ElephantState.COMPLETE + raise StopIteration + self.phase_frame += 1 + + +class ElephantSplash(BaseEffect[ElephantSplashConfig]): + """A playful elephant splashes water to reveal the input text.""" + + @property + def _config_cls(self) -> type[ElephantSplashConfig]: + return ElephantSplashConfig + + @property + def _iterator_cls(self) -> type[ElephantSplashIterator]: + return ElephantSplashIterator diff --git a/terminaltexteffects/engine/animation.py b/terminaltexteffects/engine/animation.py index f0b3434d..29ee8719 100644 --- a/terminaltexteffects/engine/animation.py +++ b/terminaltexteffects/engine/animation.py @@ -18,6 +18,7 @@ from terminaltexteffects.utils.exceptions import ( ActivateEmptySceneError, AnimationSceneError, + DuplicateSceneIDError, FrameDurationError, SceneNotFoundError, ) @@ -198,6 +199,7 @@ def __init__( self.easing_current_step: int = 0 self.preexisting_colors: graphics.ColorPair | None = None self.preexisting_bold: bool = False + self._loop_cycle_complete: bool = False def _get_color_code(self, color: graphics.Color | None) -> str | int | None: """Get the color code for the given color. @@ -328,6 +330,7 @@ def get_next_visual(self) -> CharacterVisual: CharacterVisual: The visual of the current frame in the Scene. """ + self._loop_cycle_complete = False current_frame = self.frames[0] next_visual = current_frame.character_visual current_frame.ticks_elapsed += 1 @@ -337,6 +340,7 @@ def get_next_visual(self) -> CharacterVisual: if self.is_looping and not self.frames: self.frames.extend(self.played_frames) self.played_frames.clear() + self._loop_cycle_complete = True return next_visual def apply_gradient_to_symbols( @@ -359,7 +363,8 @@ def apply_gradient_to_symbols( None Raises: - AnimationSceneError: if gradients are invalid or symbols are invalid + AnimationSceneError: If the gradients are invalid, the symbol sequence is empty, or any symbol is not + exactly one character long. """ T = typing.TypeVar("T") @@ -415,8 +420,11 @@ def cyclic_distribution( "Foreground and background gradient are empty. At least one gradient must have at least one color." ) raise AnimationSceneError(message) + if not symbols: + message = "Symbols must contain at least one symbol." + raise AnimationSceneError(message) for symbol in symbols: - if len(symbol) > 1: + if len(symbol) != 1: message = f"Symbol must be a string with a length of 1. Received: `{symbol}`." raise AnimationSceneError(message) color_pairs: list[graphics.ColorPair] = [] @@ -457,6 +465,7 @@ def reset_scene(self) -> None: self.frames.extend(self.played_frames) self.played_frames.clear() self.easing_current_step = 0 + self._loop_cycle_complete = False def __eq__(self, other: object) -> bool: """Check if two Scene objects are equal based on their scene_id.""" @@ -562,8 +571,7 @@ def new_scene( """Create a new Scene and add it to the Animation. If no ID is provided, a unique ID is generated. If `existing_color_handling` is `"always"`, - the Scene inherits the animation's input colors as `preexisting_colors`. If a Scene with the - same ID already exists, it is replaced in the animation's scene mapping. + the Scene inherits the animation's input colors as `preexisting_colors`. Args: scene_id (str): Name for the scene. Used to query for the scene. @@ -574,6 +582,9 @@ def new_scene( Returns: Scene: The new Scene. + Raises: + DuplicateSceneIDError: If an explicitly provided Scene ID has already been used. + """ if not scene_id: found_unique = False @@ -584,8 +595,8 @@ def new_scene( found_unique = True else: current_id += 1 - # Future: review whether scene IDs should be enforced as unique and raise on duplicates. - # Confirm no effects intentionally overwrite scenes today, then update this behavior and docs together. + elif scene_id in self.scenes: + raise DuplicateSceneIDError(scene_id) if self.existing_color_handling == "always" and self.character.uses_input_preexisting_colors: preexisting_colors = graphics.ColorPair(fg=self.input_fg_color, bg=self.input_bg_color) preexisting_bold = self.input_bold @@ -796,14 +807,17 @@ def step_animation(self) -> None: * Synced scenes select a frame based on the active motion path's progress. * Eased scenes select a frame from `frame_index_map` using easing progress. * All other scenes advance by consuming frame duration through `Scene.get_next_visual()`. - * If a synced scene no longer has an active motion path, the final frame is applied and - the scene is marked complete. + * If a synced scene no longer has an active motion path, the final frame is applied. A + non-looping synced scene is then marked complete, while a looping scene keeps its frames. * When a non-looping scene completes, it is reset, deactivated, and a `SCENE_COMPLETE` event is triggered. + * Looping sequential and eased scenes trigger `SCENE_COMPLETE` once when each playback cycle wraps. + Synced looping scenes rely on their associated motion path events instead. """ scene = self.active_scene if scene is None or not scene.frames: return + scene._loop_cycle_complete = False if scene.sync: self._step_synced_scene(scene) @@ -819,6 +833,8 @@ def _step_synced_scene(self, scene: Scene) -> None: active_path = self.character.motion.active_path if active_path is None: self.current_character_visual = scene.frames[-1].character_visual + if scene.is_looping: + return scene.played_frames.extend(scene.frames) scene.frames.clear() return @@ -853,13 +869,17 @@ def _step_eased_scene(self, scene: Scene) -> None: if scene.easing_current_step == scene.easing_total_steps: if scene.is_looping: scene.easing_current_step = 0 + scene._loop_cycle_complete = True else: scene.played_frames.extend(scene.frames) scene.frames.clear() def _complete_scene_if_finished(self, scene: Scene) -> None: """Reset completed scenes and trigger completion events.""" - if not self.active_scene_is_complete(): + if scene.is_looping: + if not scene._loop_cycle_complete: + return + elif not self.active_scene_is_complete(): return if not scene.is_looping: @@ -898,6 +918,7 @@ def activate_scene(self, scene: Scene | str) -> None: else: found_scene = scene self.active_scene = found_scene + self.active_scene._loop_cycle_complete = False self.active_scene_current_step = 0 self.current_character_visual = self.active_scene.activate() self.character.event_handler._handle_event(self.character.event_handler.Event.SCENE_ACTIVATED, found_scene) diff --git a/terminaltexteffects/utils/exceptions/__init__.py b/terminaltexteffects/utils/exceptions/__init__.py index e8599f3e..789aef9d 100644 --- a/terminaltexteffects/utils/exceptions/__init__.py +++ b/terminaltexteffects/utils/exceptions/__init__.py @@ -3,6 +3,7 @@ from terminaltexteffects.utils.exceptions.animation_exceptions import ( ActivateEmptySceneError, AnimationSceneError, + DuplicateSceneIDError, FrameDurationError, SceneNotFoundError, ) diff --git a/terminaltexteffects/utils/exceptions/animation_exceptions.py b/terminaltexteffects/utils/exceptions/animation_exceptions.py index 429f641c..8a803d18 100644 --- a/terminaltexteffects/utils/exceptions/animation_exceptions.py +++ b/terminaltexteffects/utils/exceptions/animation_exceptions.py @@ -4,6 +4,7 @@ FrameDurationError: Raised when a frame is added to a Scene with an invalid duration. ActivateEmptySceneError: Raised when a Scene without any frames is activated. AnimationSceneError: Generic Scene/animation error with a provided message. + DuplicateSceneIDError: Raised when a Scene ID has already been used. """ @@ -83,3 +84,18 @@ def __init__(self, scene_id: str) -> None: self.scene_id = scene_id self.message = f"Scene with scene_id `{scene_id}` not found." super().__init__(self.message) + + +class DuplicateSceneIDError(TerminalTextEffectsError): + """Raised when a Scene is created with an ID that has already been used.""" + + def __init__(self, scene_id: str) -> None: + """Initialize a DuplicateSceneIDError. + + Args: + scene_id: Scene ID that has already been used. + + """ + self.scene_id = scene_id + self.message = f"Scene ID `{scene_id}` has already been used." + super().__init__(self.message) diff --git a/tests/conftest.py b/tests/conftest.py index 0e932f92..3b5fa5c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ effect_colorshift, effect_crumble, effect_decrypt, + effect_elephant_splash, effect_errorcorrect, effect_expand, effect_fireworks, @@ -159,6 +160,7 @@ effect_colorshift.ColorShift, effect_crumble.Crumble, effect_decrypt.Decrypt, + effect_elephant_splash.ElephantSplash, effect_errorcorrect.ErrorCorrect, effect_expand.Expand, effect_fireworks.Fireworks, diff --git a/tests/effects_tests/test_elephant_splash.py b/tests/effects_tests/test_elephant_splash.py new file mode 100644 index 00000000..cc8f85be --- /dev/null +++ b/tests/effects_tests/test_elephant_splash.py @@ -0,0 +1,1032 @@ +"""Tests for the Elephant Splash effect.""" + +from __future__ import annotations + +import importlib.util +from importlib import import_module +from itertools import islice +from pathlib import Path +from typing import Any + +import pytest + +from terminaltexteffects import __main__ +from terminaltexteffects.engine.terminal import TerminalConfig +from terminaltexteffects.utils.geometry import Coord +from terminaltexteffects.utils.graphics import Color, ColorPair, Gradient + + +def _make_iterator( + canvas_width: int, + canvas_height: int, + input_data: str = "TTE", + *, + final_hold_frames: int | None = None, + existing_color_handling: str = "ignore", + no_color: bool = False, + xterm_colors: bool = False, + anchor_canvas: str | None = None, + anchor_text: str | None = None, +) -> Any: + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + terminal_config = TerminalConfig._build_config() + terminal_config.canvas_width = canvas_width + terminal_config.canvas_height = canvas_height + terminal_config.ignore_terminal_dimensions = True + terminal_config.frame_rate = 0 + terminal_config.existing_color_handling = existing_color_handling + terminal_config.no_color = no_color + terminal_config.xterm_colors = xterm_colors + if anchor_canvas is not None: + terminal_config.anchor_canvas = anchor_canvas + if anchor_text is not None: + terminal_config.anchor_text = anchor_text + effect = module.ElephantSplash(input_data, terminal_config=terminal_config) + if final_hold_frames is not None: + effect.effect_config.final_hold_frames = final_hold_frames + return iter(effect) + + +def test_elephant_splash_effect_module_exists() -> None: + """The built-in Elephant Splash effect has a discoverable module.""" + module_spec = importlib.util.find_spec("terminaltexteffects.effects.effect_elephant_splash") + + assert module_spec is not None + + +def test_elephant_splash_exposes_effect_resources() -> None: + """The effect module exposes the standard built-in effect contract.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + + assert hasattr(module, "ElephantSplash") + assert hasattr(module, "ElephantSplashConfig") + assert hasattr(module, "ElephantSplashIterator") + assert module.get_effect_resources() == ( + "elephantsplash", + module.ElephantSplash, + module.ElephantSplashConfig, + ) + + +def test_elephant_state_machine_exposes_the_required_choreography_states() -> None: + """The central iterator state names document which sprite motions are permitted.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + + assert [state.name for state in module.ElephantState] == [ + "ENTERING", + "WALKING_TO_TARGET", + "SETTLING", + "LOWERING_TRUNK", + "REVEALING_LOGO", + "HOLDING", + "RAISING_TRUNK", + "WALKING_OUT", + "COMPLETE", + ] + + +def test_effect_begins_in_entering_state_with_shared_integer_sprite_coordinates() -> None: + """The authoritative state and origin exist before the first rendered frame.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + iterator = _make_iterator(80, 24) + + assert iterator.state is module.ElephantState.ENTERING + assert iterator.elephant_x == iterator.elephant.elephant_x + assert iterator.elephant_y == iterator.elephant.elephant_y + assert iterator.current_pose == iterator.elephant.current_pose == 0 + + +def test_elephant_splash_config_defaults() -> None: + """The default palette and timing match the public effect contract.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + + config = module.ElephantSplashConfig._build_config() + + assert config.elephant_color == Color("#8B5CF6") + assert config.elephant_highlight_color == Color("#C4B5FD") + assert config.water_colors == (Color("#38BDF8"), Color("#7DD3FC"), Color("#E0F2FE")) + assert config.movement_speed == 0.35 + assert config.walk_pose_frames == 8 + assert config.horizontal_step_frames == 4 + assert config.final_gradient_stops == (Color("#8B5CF6"), Color("#C4B5FD"), Color("#F5F3FF")) + assert config.final_gradient_steps == 12 + assert config.final_gradient_frames == 4 + assert config.final_gradient_direction is Gradient.Direction.RADIAL + assert config.final_hold_frames == 120 + + +def test_elephant_splash_has_a_public_library_export() -> None: + """Library users can import the effect from the public effects package.""" + effects_module = import_module("terminaltexteffects.effects") + + assert hasattr(effects_module, "ElephantSplash") + assert hasattr(effects_module, "ElephantSplashConfig") + + +def test_full_poses_have_a_tall_recognisable_elephant_silhouette() -> None: + """Every full pose retains the scale and defining features of a side-profile elephant.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + for pose in module.ElephantSplashIterator.FULL_POSES.values(): + occupied_columns = [column for row in pose for column, symbol in enumerate(row) if symbol != " "] + occupied_rows = [row_index for row_index, row in enumerate(pose) if row.strip()] + occupied_width = max(occupied_columns) - min(occupied_columns) + 1 + assert 34 <= occupied_width <= 52 + assert max(occupied_rows) - min(occupied_rows) + 1 >= 13 + assert not any("( )" in row for row in pose) + assert any("e" in row for row in pose) + assert any(">" in row for row in pose) + assert not any("jgs" in row.lower() for row in pose) + assert sum(row.count("\\__/") + row.count("\\___/") for row in pose[-3:]) >= 3 + + +def test_raised_and_primary_spray_pose_use_the_selected_jgs_elephant_exactly() -> None: + """The chosen playful elephant remains the canonical raised-trunk artwork without reinterpretation.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + selected_elephant = ( + " _", + " / )", + " ; |", + ' .-""-.-""""-. | ;', + " /' \\ \\ / /", + ' .-""""-/ ( \'--\' /', + " .' | ; e /", + " / \\ | __.'", + " / '._ ; .-'", + " //| \\ \\,-'", + " // | `;.___>", + " /`| / |`\\", + " |/ / _,.-----\\ | \\", + " / .; | | | \\", + " | / | \\ / |\\__/", + " \\__/ \\___/ \\___/", + ) + + assert tuple(row.rstrip() for row in module.ElephantSplashIterator.FULL_POSES["raise_3"]) == selected_elephant + assert tuple(row.rstrip() for row in module.ElephantSplashIterator.FULL_POSES["spray_1"]) == selected_elephant + + +def test_walking_uses_a_hanging_trunk_with_only_its_tip_curled() -> None: + """Walking keeps the trunk down while its complete two-sided tip curls upward.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + + for pose_name in ("walk_1", "walk_2", "walk_3", "walk_4"): + pose = module.ElephantSplashIterator.FULL_POSES[pose_name] + assert not any(row.strip() for row in pose[:3]) + assert pose[10][32] == "\\" + assert pose[10][36] == "|" + assert pose[11][33] == "\\" + assert pose[11][36] == "|" + assert pose[12][34] == "|" + assert pose[12][36] == "|" + assert pose[13][34] == "|" + assert pose[13][36] == "|" + assert pose[-2][34:37] == "\\_)" + assert not pose[-1][25:].strip() + + +def test_drinking_lowers_the_trunk_only_after_the_elephant_stops() -> None: + """The three interaction poses move the trunk tip from raised to puddle height.""" + iterator = _make_iterator(80, 24) + elephant = iterator.elephant + + tip_rows = [elephant.trunk_coord_for_pose(f"drink_{index}").row for index in range(1, 4)] + + assert tip_rows[0] >= tip_rows[1] >= tip_rows[2] + assert tip_rows[0] > tip_rows[2] + + +def test_drinking_uses_the_same_narrow_two_sided_trunk_without_embedded_bubbles() -> None: + """Interaction poses retain both trunk contours while particles provide the water symbols.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + + for pose_name in ("drink_1", "drink_2", "drink_3"): + pose = module.ElephantSplashIterator.FULL_POSES[pose_name] + assert pose[10][32] == "\\" + assert pose[10][36] == "|" + assert pose[11][33] == "\\" + assert pose[11][36] == "|" + assert pose[12][34] == "|" + assert pose[12][36] == "|" + assert not any(symbol in "o.~" for row in pose[10:] for symbol in row[29:]) + + +def test_selected_elephant_credit_lives_in_documentation_not_the_sprite() -> None: + """The artist remains credited without rendering a signature inside the animation.""" + documentation = Path("docs/effects/elephantsplash.md").read_text(encoding="utf-8") + + assert "jgs" in documentation + assert "https://asciiart.website/art/4937" in documentation + + +def test_every_full_pose_preserves_the_selected_elephants_back_and_face() -> None: + """Only the legs and trunk move; the chosen elephant does not morph into another drawing.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + + for pose in module.ElephantSplashIterator.FULL_POSES.values(): + assert any('.-""-.-""""-.' in row for row in pose) + assert any("; e" in row for row in pose) + + +def test_all_full_sprite_poses_share_one_padded_bounding_box() -> None: + """Every authored pose addresses the same persistent sprite-cell grid.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + dimensions = {(len(pose), len(row)) for pose in module.ElephantSplashIterator.FULL_POSES.values() for row in pose} + + assert len(dimensions) == 1 + + +def test_walking_changes_legs_without_moving_the_body_or_trunk() -> None: + """The walk cycle moves grounded feet forward and back without changing the body or trunk.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + poses = module.ElephantSplashIterator.FULL_POSES + neutral = poses["walk_1"] + + for pose_name in ("walk_2", "walk_3", "walk_4"): + pose = poses[pose_name] + assert pose[:12] == neutral[:12] + assert all(row[30:] == neutral_row[30:] for row, neutral_row in zip(pose[12:], neutral[12:])) + + ground_rows = [poses[pose_name][-1][:25] for pose_name in ("walk_1", "walk_2", "walk_3", "walk_4")] + assert all(ground.count("\\__/") + ground.count("\\___/") == 3 for ground in ground_rows) + assert len(set(ground_rows)) >= 3 + + +def test_elephant_poses_never_duplicate_the_ear_curve() -> None: + """No pose turns the elephant's single ear curve into a blinking pair of C shapes.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + + assert all("((" not in row for pose in module.ElephantSplashIterator.FULL_POSES.values() for row in pose) + + +@pytest.mark.parametrize("pose_name", ["raise_3", "spray_1", "spray_2", "wiggle_1", "wiggle_2"]) +def test_full_raised_trunk_stays_proportional_to_the_head(pose_name: str) -> None: + """The lifted trunk curves naturally above the face instead of stretching across the screen.""" + iterator = _make_iterator(80, 24) + elephant = iterator.elephant + pose = elephant.poses[pose_name] + eye_column = next(row.index("e") for row in pose if "e" in row) + tip = elephant.trunk_tip_offsets[pose_name] + + assert 2 <= tip.column - eye_column <= 12 + + +def test_full_elephant_head_does_not_draw_a_second_closed_muzzle() -> None: + """The side profile has one readable ear rather than two neighbouring circular features.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + + for pose in module.ElephantSplashIterator.FULL_POSES.values(): + assert not any("/ \\" in row for row in pose[3:9]) + + +@pytest.mark.parametrize(("canvas_width", "canvas_height"), [(80, 24), (12, 6)]) +def test_every_elephant_pose_declares_its_visible_trunk_tip(canvas_width: int, canvas_height: int) -> None: + """Water effects use a deliberate trunk endpoint instead of a rightmost-character guess.""" + iterator = _make_iterator(canvas_width, canvas_height) + elephant = iterator.elephant + + assert set(elephant.trunk_tip_offsets) == set(elephant.poses) + for pose_name, tip_offset in elephant.trunk_tip_offsets.items(): + row_index = elephant.height - tip_offset.row - 1 + assert elephant.poses[pose_name][row_index][tip_offset.column] != " " + assert elephant.trunk_coord_for_pose(pose_name) == elephant._coord_for_offset(tip_offset) + + +def test_lowest_drinking_and_spraying_tips_are_at_opposite_trunk_extremes() -> None: + """The lowered tip touches the floor while the spraying tip sits above the elephant's eye.""" + iterator = _make_iterator(80, 24) + elephant = iterator.elephant + drink_tip = elephant.trunk_coord_for_pose("drink_3", elephant.target_coord) + spray_tip = elephant.trunk_coord_for_pose("spray_1", elephant.target_coord) + + assert drink_tip.row == iterator.terminal.canvas.bottom + assert spray_tip.row >= elephant.target_coord.row + 8 + + +@pytest.mark.parametrize("pose_name", ["raise_3", "spray_1", "spray_2", "wiggle_1", "wiggle_2"]) +def test_full_raised_trunk_curves_above_the_elephants_eye(pose_name: str) -> None: + """A lifted trunk has an unmistakable upturned tip instead of a long rectangular snout.""" + iterator = _make_iterator(80, 24) + elephant = iterator.elephant + pose = elephant.poses[pose_name] + eye_row_index, eye_column = next((row_index, row.index("e")) for row_index, row in enumerate(pose) if "e" in row) + tip = elephant.trunk_tip_offsets[pose_name] + eye_row = elephant.height - eye_row_index - 1 + + assert tip.row > eye_row + assert tip.column > eye_column + 8 + + +@pytest.mark.parametrize( + ("canvas_width", "canvas_height", "expected_mode", "expected_phase"), + [ + (80, 24, "full", "WALK_IN"), + (41, 16, "full", "WALK_IN"), + (51, 15, "compact", "WALK_IN"), + (61, 12, "compact", "WALK_IN"), + (48, 12, "compact", "WALK_IN"), + (24, 10, "compact", "WALK_IN"), + (12, 6, "compact", "WALK_IN"), + (11, 5, "fallback", "SPLASH"), + ], +) +def test_elephant_splash_selects_a_responsive_sprite_mode( + canvas_width: int, + canvas_height: int, + expected_mode: str, + expected_phase: str, +) -> None: + """Canvas dimensions select the full, compact, or splash-only choreography.""" + iterator = _make_iterator(canvas_width, canvas_height) + + assert iterator.sprite_mode == expected_mode + assert iterator.phase.name == expected_phase + + +@pytest.mark.parametrize(("canvas_width", "canvas_height"), [(80, 24), (24, 10), (12, 6)]) +def test_elephant_sprite_is_bounded_and_starts_outside_canvas(canvas_width: int, canvas_height: int) -> None: + """Sprite helpers are bounded, portable, and begin fully to the left of the canvas.""" + iterator = _make_iterator(canvas_width, canvas_height) + + assert iterator.elephant is not None + assert ( + iterator.elephant.anchor.motion.current_coord.column == iterator.terminal.canvas.left - iterator.elephant.width + ) + assert len(iterator.elephant.characters) <= iterator.elephant.width * iterator.elephant.height + assert all(ord(symbol) < 128 for pose in iterator.elephant.poses.values() for row in pose for symbol in row) + assert all(character.layer == 2 for character in iterator.elephant.characters) + + +def test_elephant_uses_one_persistent_cell_for_every_bounding_box_coordinate() -> None: + """Sprite cells represent fixed local coordinates rather than whichever glyph occupies them.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + iterator = _make_iterator(80, 24) + elephant = iterator.elephant + + assert len(elephant.cells) == elephant.width * elephant.height + assert all(isinstance(cell, module.SpriteCell) for cell in elephant.cells) + assert {(cell.row, cell.column) for cell in elephant.cells} == { + (row, column) for row in range(elephant.height) for column in range(elephant.width) + } + assert len({cell.character for cell in elephant.cells}) == len(elephant.cells) + + +def test_applying_a_pose_updates_visibility_and_position_for_every_sprite_cell() -> None: + """One pose application atomically projects the complete grid from the shared integer origin.""" + iterator = _make_iterator(80, 24) + elephant = iterator.elephant + elephant.set_origin(elephant.target_coord.column, elephant.target_coord.row) + elephant.apply_pose("walk_2") + pose = elephant.poses["walk_2"] + + for cell in elephant.cells: + grid_row = elephant.height - cell.row - 1 + symbol = pose[grid_row][cell.column] + assert cell.character.motion.current_coord == Coord( + elephant.elephant_x + cell.column, + elephant.elephant_y + cell.row, + ) + assert (cell.character in iterator.terminal._visible_characters) is (symbol != " ") + assert cell.character.animation.current_character_visual.symbol == symbol + + +def test_horizontal_walk_moves_the_shared_origin_one_column_every_four_frames() -> None: + """Integer stepping replaces continuously eased sub-frame elephant motion.""" + iterator = _make_iterator(80, 24) + elephant = iterator.elephant + start_x = elephant.elephant_x + + for _ in range(3): + elephant.step_walk(direction=1) + assert elephant.elephant_x == start_x + + elephant.step_walk(direction=1) + + assert elephant.elephant_x == start_x + 1 + assert all( + cell.character.motion.current_coord.column == elephant.elephant_x + cell.column for cell in elephant.cells + ) + + +def test_walk_pose_is_held_for_eight_frames_without_individual_cell_paths() -> None: + """The central pose counter synchronises the complete sprite independently of TTE paths.""" + iterator = _make_iterator(80, 24) + elephant = iterator.elephant + + for _ in range(8): + elephant.step_walk(direction=1) + assert elephant.current_pose == 0 + + elephant.step_walk(direction=1) + + assert elephant.current_pose == 1 + assert all(cell.character.motion.active_path is None for cell in elephant.cells) + + +@pytest.mark.parametrize(("canvas_width", "canvas_height"), [(41, 16), (51, 15), (61, 12), (48, 12), (12, 6)]) +def test_every_stopped_elephant_pose_fits_inside_its_canvas(canvas_width: int, canvas_height: int) -> None: + """Responsive mode selection never allows the active sprite to clip at its stopping point.""" + iterator = _make_iterator(canvas_width, canvas_height) + elephant = iterator.elephant + + for pose in elephant.poses.values(): + occupied_columns = [column for row in pose for column, symbol in enumerate(row) if symbol != " "] + assert elephant.target_coord.column + min(occupied_columns) >= iterator.terminal.canvas.left + assert elephant.target_coord.column + max(occupied_columns) <= iterator.terminal.canvas.right + assert elephant.target_coord.row + elephant.height - 1 <= iterator.terminal.canvas.top + + +@pytest.mark.parametrize(("canvas_width", "canvas_height"), [(80, 24), (24, 10), (12, 6)]) +def test_elephant_walks_on_the_bottom_canvas_baseline(canvas_width: int, canvas_height: int) -> None: + """Every sprite size enters, stops, and exits along the bottom edge.""" + iterator = _make_iterator(canvas_width, canvas_height) + + assert iterator.elephant.start_coord.row == iterator.terminal.canvas.bottom + assert iterator.elephant.target_coord.row == iterator.terminal.canvas.bottom + + +def test_full_elephant_tusk_uses_the_highlight_colour() -> None: + """The small tusk remains readable against the purple silhouette.""" + iterator = _make_iterator(80, 24) + tusk = next( + character + for character in iterator.elephant.characters + if character.animation.current_character_visual.symbol == ">" + ) + + assert tusk.animation.current_character_visual.colors == ColorPair( + fg=iterator.config.elephant_highlight_color, + ) + + +def test_selected_elephant_eye_uses_the_highlight_colour() -> None: + """The canonical artwork's `e` eye remains expressive against the purple face.""" + iterator = _make_iterator(80, 24) + eye = next( + character + for character in iterator.elephant.characters + if character.animation.current_character_visual.symbol == "e" + ) + + assert eye.animation.current_character_visual.colors == ColorPair( + fg=iterator.config.elephant_highlight_color, + ) + + +def test_every_compact_pose_fits_the_minimum_compact_canvas_width() -> None: + """No expressive trunk pose is clipped on a twelve-column canvas.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + + assert all(len(row) <= 12 for pose in module.ElephantSplashIterator.COMPACT_POSES.values() for row in pose) + + +@pytest.mark.parametrize( + ("canvas_width", "canvas_height", "expected_width", "expected_height"), + [(80, 24, 15, 2), (24, 10, 3, 1), (12, 6, 3, 1)], +) +def test_a_visible_puddle_waits_on_the_bottom_in_front_of_the_elephant( + canvas_width: int, + canvas_height: int, + expected_width: int, + expected_height: int, +) -> None: + """The water source is a prominent, bounded helper placed beyond the trunk.""" + iterator = _make_iterator(canvas_width, canvas_height) + + assert iterator.puddle is not None + puddle_columns = {character.motion.current_coord.column for character in iterator.puddle.characters} + puddle_rows = {character.motion.current_coord.row for character in iterator.puddle.characters} + assert len(puddle_columns) == expected_width + assert len(puddle_rows) == expected_height + assert len(iterator.puddle.characters) == expected_width * expected_height + assert iterator.puddle.visible_count == expected_width * expected_height + assert min(puddle_rows) == iterator.terminal.canvas.bottom + assert min(character.motion.current_coord.column for character in iterator.puddle.characters) > ( + iterator.elephant.target_coord.column + ) + + +@pytest.mark.parametrize(("canvas_width", "canvas_height"), [(80, 24), (12, 6)]) +def test_puddle_begins_beside_the_lowered_trunk_tip(canvas_width: int, canvas_height: int) -> None: + """The water source is positioned from the drinking pose, keeping trunk and puddle connected.""" + iterator = _make_iterator(canvas_width, canvas_height) + drink_tip = iterator.elephant.trunk_coord_for_pose("drink_1", iterator.elephant.target_coord) + puddle_columns = {character.motion.current_coord.column for character in iterator.puddle.characters} + + assert min(abs(column - drink_tip.column) for column in puddle_columns) <= 1 + + +def test_full_puddle_ripples_while_the_elephant_approaches() -> None: + """The larger water source remains visibly active before drinking begins.""" + iterator = _make_iterator(80, 24) + initial_visuals = tuple( + (character.animation.current_character_visual.symbol, character.animation.current_character_visual.colors) + for character in iterator.puddle.characters + ) + + for _ in range(12): + next(iterator) + + current_visuals = tuple( + (character.animation.current_character_visual.symbol, character.animation.current_character_visual.colors) + for character in iterator.puddle.characters + ) + assert iterator.phase.name == "WALK_IN" + assert current_visuals != initial_visuals + assert iterator.puddle.visible_count == len(iterator.puddle.characters) + + +def test_tiny_canvas_does_not_create_an_elephant_or_particles() -> None: + """The splash-only fallback avoids impossible sprite and particle geometry.""" + iterator = _make_iterator(1, 1, "A") + + assert iterator.elephant is None + assert iterator.water_pool is None + + +def test_elephant_walks_in_with_multiple_poses_before_raising_its_trunk() -> None: + """The entrance moves the rigid sprite and advances its walking cycle.""" + iterator = _make_iterator(80, 24) + start_column = iterator.elephant.anchor.motion.current_coord.column + seen_poses: set[str] = set() + + for _ in range(500): + frame = next(iterator) + seen_poses.add(iterator.elephant.current_pose_name) + assert frame + if iterator.phase.name == "RAISE_TRUNK": + break + + assert iterator.phase.name == "RAISE_TRUNK" + assert iterator.elephant.anchor.motion.current_coord.column > start_column + assert len(seen_poses.intersection({"walk_1", "walk_2", "walk_3", "walk_4"})) >= 2 + + +def test_elephant_stops_to_drink_before_raising_its_trunk() -> None: + """Reaching the puddle starts a distinct drinking phase.""" + iterator = _make_iterator(80, 24) + + while iterator.phase.name == "WALK_IN": + next(iterator) + + assert iterator.phase.name == "DRINK" + + +def test_trunk_interaction_freezes_the_walk_cycle_and_logo_waits_for_lowest_pose() -> None: + """Leg poses stop at the puddle and branding cannot reveal during trunk lowering.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + iterator = _make_iterator(80, 24, "ORCA") + while iterator.state is not module.ElephantState.LOWERING_TRUNK: + next(iterator) + frozen_walk_frame = iterator.elephant.walk_frame + lowering_poses: set[str] = set() + + while iterator.state is module.ElephantState.LOWERING_TRUNK: + next(iterator) + lowering_poses.add(iterator.elephant.current_pose_name) + assert iterator.elephant.walk_frame == frozen_walk_frame + assert all(character not in iterator.terminal._visible_characters for character in iterator.input_characters) + + while iterator.state is not module.ElephantState.REVEALING_LOGO: + next(iterator) + + assert lowering_poses == {"drink_1", "drink_2", "drink_3"} + + +@pytest.mark.parametrize(("canvas_width", "canvas_height"), [(80, 24), (12, 6)]) +def test_drinking_poses_lower_the_trunk_while_the_puddle_shrinks( + canvas_width: int, + canvas_height: int, +) -> None: + """The elephant visibly consumes the complete puddle before lifting its trunk.""" + iterator = _make_iterator(canvas_width, canvas_height) + while iterator.phase.name == "WALK_IN": + next(iterator) + puddle_sizes = [iterator.puddle.visible_count] + seen_poses: set[str] = set() + drinking_frames = 0 + + while iterator.phase.name == "DRINK": + next(iterator) + drinking_frames += 1 + seen_poses.add(iterator.elephant.current_pose_name) + puddle_sizes.append(iterator.puddle.visible_count) + + assert iterator.phase.name == "RAISE_TRUNK" + assert drinking_frames == 72 + assert seen_poses == {"drink_1", "drink_2", "drink_3"} + assert puddle_sizes[-1] == 0 + assert puddle_sizes == sorted(puddle_sizes, reverse=True) + + +def test_drinking_pulls_three_visible_bubbles_from_the_puddle_into_the_trunk() -> None: + """A moving intake stream connects the shrinking water source to the elephant.""" + iterator = _make_iterator(80, 24) + while iterator.phase.name != "DRINK": + next(iterator) + + next(iterator) + initial_coords = tuple(character.motion.current_coord for character in iterator.intake_characters) + + assert len(iterator.intake_characters) == 3 + assert all(character in iterator.terminal._visible_characters for character in iterator.intake_characters) + + for _ in range(12): + next(iterator) + + assert tuple(character.motion.current_coord for character in iterator.intake_characters) != initial_coords + + while iterator.phase.name == "DRINK": + next(iterator) + + assert all(character not in iterator.terminal._visible_characters for character in iterator.intake_characters) + + +def test_drinking_bubbles_arrive_at_the_declared_trunk_tip() -> None: + """At least one intake bubble visibly completes its journey at the end of the trunk.""" + iterator = _make_iterator(80, 24) + while iterator.phase.name != "DRINK": + next(iterator) + + bubble_reached_tip = False + while iterator.phase.name == "DRINK": + next(iterator) + bubble_reached_tip |= any( + character.motion.current_coord == iterator.elephant.trunk_coord for character in iterator.intake_characters + ) + + assert bubble_reached_tip + + +def test_full_puddle_contracts_toward_its_centre_by_complete_columns() -> None: + """Two-row water shrinks as one coherent pool instead of splitting diagonally.""" + iterator = _make_iterator(80, 24) + while iterator.phase.name != "DRINK": + next(iterator) + while iterator.phase_frame < iterator.DRINK_FRAMES // 2: + next(iterator) + + visible_characters = [ + character for character in iterator.puddle.characters if character in iterator.terminal._visible_characters + ] + visible_columns = {character.motion.current_coord.column for character in visible_characters} + expected_columns = set(range(min(visible_columns), max(visible_columns) + 1)) + puddle_center = iterator.puddle.start_column + (iterator.puddle.width - 1) / 2 + + assert len(visible_columns) < iterator.puddle.width + assert visible_columns == expected_columns + assert abs((min(visible_columns) + max(visible_columns)) / 2 - puddle_center) <= 0.5 + assert len(visible_characters) == len(visible_columns) * iterator.puddle.height + + +def test_elephant_raises_its_trunk_in_three_timed_poses() -> None: + """Drinking is followed by the complete three-pose trunk sequence.""" + iterator = _make_iterator(80, 24) + while iterator.phase.name != "RAISE_TRUNK": + next(iterator) + seen_poses: set[str] = set() + + for _ in range(30): + next(iterator) + seen_poses.add(iterator.elephant.current_pose_name) + + assert seen_poses == {"raise_1", "raise_2", "raise_3"} + assert iterator.phase.name == "SPLASH" + + +def test_branding_is_hidden_and_partitioned_into_twelve_reveal_bands() -> None: + """Every input character is prepared once for the bounded radial reveal.""" + iterator = _make_iterator(80, 24, "PURPLE\nELEPHANT") + input_characters = iterator.terminal.get_characters() + grouped_characters = [character for group in iterator.reveal_groups for character in group] + + assert len(iterator.reveal_groups) == 12 + assert set(grouped_characters) == set(input_characters) + assert len(grouped_characters) == len(input_characters) + assert all(character not in iterator.terminal._visible_characters for character in input_characters) + assert all(character.animation.query_scene("reveal", None) is not None for character in input_characters) + assert all(character.layer == 1 for character in input_characters) + + +@pytest.mark.parametrize( + ("canvas_width", "canvas_height", "input_data", "expected_droplets"), + [(80, 24, "A", 24), (80, 24, "X" * 200, 40), (12, 6, "TTE", 16)], +) +def test_water_pool_is_preallocated_and_bounded( + canvas_width: int, + canvas_height: int, + input_data: str, + expected_droplets: int, +) -> None: + """Full and compact splashes allocate a fixed, capped particle pool.""" + iterator = _make_iterator(canvas_width, canvas_height, input_data) + + assert len(iterator.water_pool) == expected_droplets + assert iterator.water_pool.max_size == expected_droplets + assert len(iterator.water_pool.available) == expected_droplets + assert all(particle.layer == 3 for particle in iterator.water_pool.particles) + assert all(particle not in iterator.terminal._visible_characters for particle in iterator.water_pool.particles) + + +def test_splash_starts_one_active_droplet_at_the_raised_trunk_tip() -> None: + """The first splash frame begins a paced stream instead of a floating particle cluster.""" + iterator = _make_iterator(80, 24, "TTE", anchor_canvas="c", anchor_text="c") + while iterator.phase.name != "SPLASH": + next(iterator) + + next(iterator) + emitted_particles = [ + particle for particle in iterator.water_pool.particles if particle not in iterator.water_pool.available + ] + + assert len(emitted_particles) == 1 + assert set(emitted_particles).issubset(iterator.active_characters) + assert all(particle.motion.active_path is not None for particle in emitted_particles) + assert all(particle.animation.active_scene is not None for particle in emitted_particles) + assert all(particle in iterator.terminal._visible_characters for particle in emitted_particles) + branding_coords = {character.input_coord for character in iterator.input_characters} + for particle in emitted_particles: + path = particle.motion.active_path + origin = path.origin_segment.start.coord + destination = path.waypoints[-1] + assert origin == iterator.elephant.trunk_coord + assert destination.coord in branding_coords + assert destination.bezier_control[0].row > origin.row + assert destination.bezier_control[0].row > destination.coord.row + + +def test_splash_keeps_the_elephants_ear_and_body_in_one_stable_pose() -> None: + """Water particles animate independently while the complete elephant remains frozen.""" + iterator = _make_iterator(80, 24, "ORCA") + while iterator.phase.name != "SPLASH": + next(iterator) + + seen_poses: set[str] = set() + for _ in range(16): + next(iterator) + seen_poses.add(iterator.elephant.current_pose_name) + + assert seen_poses == {"spray_1"} + + +def test_splash_stream_emits_once_per_frame_and_fans_across_the_branding() -> None: + """Successive droplets form a continuous, distributed arc toward the reveal area.""" + iterator = _make_iterator(80, 24, "PURPLE\nELEPHANT", anchor_canvas="c", anchor_text="c") + while iterator.phase.name != "SPLASH": + next(iterator) + + for _ in range(8): + next(iterator) + + emitted_particles = [ + particle for particle in iterator.water_pool.particles if particle not in iterator.water_pool.available + ] + destinations = {particle.motion.active_path.waypoints[-1].coord for particle in emitted_particles} + + assert iterator.droplets_emitted == 8 + assert len(destinations) >= 6 + + +def test_splash_waits_for_every_droplet_to_be_reclaimed() -> None: + """The reveal cannot start while a water path remains active.""" + iterator = _make_iterator(80, 24, "PURPLE\nELEPHANT") + + for _ in range(800): + next(iterator) + if iterator.phase.name == "REVEAL": + break + + assert iterator.phase.name == "REVEAL" + assert iterator.droplets_emitted == len(iterator.water_pool) + assert len(iterator.water_pool.available) == len(iterator.water_pool) + assert not set(iterator.water_pool.particles).intersection(iterator.active_characters) + assert all(particle not in iterator.terminal._visible_characters for particle in iterator.water_pool.particles) + + +def test_reveal_releases_all_twelve_radial_bands_over_twenty_three_frames() -> None: + """The branding wave has bounded timing independent of the input size.""" + iterator = _make_iterator(80, 24, "PURPLE\nELEPHANT") + while iterator.phase.name != "REVEAL": + next(iterator) + + for _ in range(23): + next(iterator) + + assert iterator.next_reveal_group == 12 + assert all(character in iterator.terminal._visible_characters for character in iterator.input_characters) + assert all(character.animation.active_scene is not None for character in iterator.input_characters) + assert iterator.phase.name == "REVEAL" + assert iterator.elephant.current_pose_name == "spray_1" + + +def test_completed_reveal_enters_a_celebration_before_walk_out() -> None: + """The elephant remains beside the completed branding for a playful beat.""" + iterator = _make_iterator(80, 24, "PURPLE\nELEPHANT") + while iterator.phase.name != "REVEAL": + next(iterator) + + while iterator.phase.name == "REVEAL": + next(iterator) + + assert iterator.phase.name == "CELEBRATE" + assert iterator.elephant.anchor.motion.movement_is_complete() + + +def test_elephant_celebrates_for_forty_eight_frames_then_walks_out() -> None: + """The completed elephant holds still, reverses its trunk poses, then resumes at WALK_1.""" + module = import_module("terminaltexteffects.effects.effect_elephant_splash") + iterator = _make_iterator(80, 24, "PURPLE\nELEPHANT") + while iterator.phase.name != "CELEBRATE": + next(iterator) + stationary_coord = iterator.elephant.anchor.motion.current_coord + seen_poses: set[str] = set() + celebration_frames = 0 + + while iterator.phase.name == "CELEBRATE": + next(iterator) + celebration_frames += 1 + seen_poses.add(iterator.elephant.current_pose_name) + + assert celebration_frames == 48 + assert seen_poses == {"spray_1"} + assert iterator.state is module.ElephantState.RAISING_TRUNK + assert iterator.elephant.anchor.motion.current_coord == stationary_coord + + reverse_poses: list[str] = [] + while iterator.state is module.ElephantState.RAISING_TRUNK: + next(iterator) + reverse_poses.append(iterator.elephant.current_pose_name) + + assert {"raise_1", "raise_2", "raise_3"}.issubset(reverse_poses) + assert iterator.state is module.ElephantState.WALKING_OUT + assert iterator.elephant.current_pose_name == "walk_1" + + +def test_full_choreography_finishes_cleanly_within_frame_budget() -> None: + """The default full-canvas effect terminates with only the original branding visible.""" + iterator = _make_iterator(80, 24, "PURPLE\nELEPHANT") + rendered_frames = list(islice(iterator, 1001)) + + assert iterator.phase.name == "COMPLETE" + assert 1 <= len(rendered_frames) < 1000 + assert not iterator.active_characters + assert len(iterator.water_pool.available) == len(iterator.water_pool) + assert all(character not in iterator.terminal._visible_characters for character in iterator.elephant.characters) + assert all(character in iterator.terminal._visible_characters for character in iterator.input_characters) + assert all( + character.animation.current_character_visual.symbol == character.input_symbol + for character in iterator.input_characters + ) + + +def test_tiny_canvas_uses_a_finite_particle_free_splash_reveal() -> None: + """The fallback animates symbols directly and always emits a clean final frame.""" + iterator = _make_iterator(1, 1, "A", final_hold_frames=0) + rendered_frames = list(islice(iterator, 101)) + + assert iterator.phase.name == "COMPLETE" + assert iterator.water_pool is None + assert not iterator.active_characters + assert iterator.input_characters[0] in iterator.terminal._visible_characters + assert iterator.input_characters[0].animation.current_character_visual.symbol == "A" + assert rendered_frames + + +@pytest.mark.parametrize(("canvas_width", "canvas_height"), [(80, 24), (12, 6), (1, 1)]) +@pytest.mark.parametrize("final_hold_frames", [0, 1, 3]) +def test_final_hold_counts_the_transition_as_its_first_clean_frame( + canvas_width: int, + canvas_height: int, + final_hold_frames: int, +) -> None: + """The transition into HOLD is the first guaranteed clean final frame.""" + iterator = _make_iterator(canvas_width, canvas_height, "A", final_hold_frames=final_hold_frames) + + while iterator.phase.name != "HOLD": + next(iterator) + + remaining_frames = list(iterator) + + assert iterator.phase.name == "COMPLETE" + assert len(remaining_frames) == max(0, final_hold_frames - 1) + + +@pytest.mark.parametrize( + ("input_data", "existing_color_handling", "expected_colors"), + [ + ("A", "dynamic", ColorPair()), + ("\x1b[38;5;196mA\x1b[0m", "dynamic", ColorPair(fg=Color(196))), + ("\x1b[48;5;106mA\x1b[0m", "dynamic", ColorPair(bg=Color(106))), + ( + "\x1b[38;5;196m\x1b[48;5;106mA\x1b[0m", + "always", + ColorPair(fg=Color(196), bg=Color(106)), + ), + ], +) +def test_final_branding_preserves_existing_colors( + input_data: str, + existing_color_handling: str, + expected_colors: ColorPair, +) -> None: + """Dynamic and always modes restore the input color channels exactly.""" + iterator = _make_iterator( + 1, + 1, + input_data, + final_hold_frames=0, + existing_color_handling=existing_color_handling, + ) + + for _ in iterator: + pass + + assert iterator.input_characters[0].animation.current_character_visual.colors == expected_colors + + +def test_ignore_mode_finishes_with_the_effect_gradient() -> None: + """Ignore mode replaces parsed colors with the configured radial gradient.""" + iterator = _make_iterator( + 1, + 1, + "\x1b[38;5;196mA\x1b[0m", + final_hold_frames=0, + existing_color_handling="ignore", + ) + + for _ in iterator: + pass + + character = iterator.input_characters[0] + assert character.animation.current_character_visual.colors == ColorPair( + fg=iterator.character_final_color_map[character], + ) + + +def test_no_color_mode_keeps_the_choreography_without_color_codes() -> None: + """Disabling color retains the final symbol while omitting ANSI color codes.""" + iterator = _make_iterator(1, 1, "A", final_hold_frames=0, no_color=True) + + for _ in iterator: + pass + + visual = iterator.input_characters[0].animation.current_character_visual + + assert visual.symbol == "A" + assert visual._fg_color_code is None + assert visual._bg_color_code is None + + +def test_xterm_mode_converts_the_final_gradient() -> None: + """Xterm mode converts the effect's RGB gradient to an indexed color.""" + iterator = _make_iterator(1, 1, "A", final_hold_frames=0, xterm_colors=True) + + for _ in iterator: + pass + + assert isinstance(iterator.input_characters[0].animation.current_character_visual._fg_color_code, int) + + +def test_cli_parser_builds_custom_elephant_splash_config() -> None: + """The discovered command accepts every effect-specific public option.""" + parser, effect_resource_map = __main__.build_parser() + parsed_args = parser.parse_args( + [ + "elephantsplash", + "--elephant-color", + "800080", + "--elephant-highlight-color", + "dda0dd", + "--water-colors", + "00ffff", + "ffffff", + "--movement-speed", + "0.7", + "--walk-pose-frames", + "6", + "--horizontal-step-frames", + "3", + "--final-gradient-stops", + "800080", + "ffffff", + "--final-gradient-steps", + "6", + "--final-gradient-frames", + "2", + "--final-gradient-direction", + "horizontal", + "--final-hold-frames", + "0", + ], + ) + effect_class, config_class = effect_resource_map["elephantsplash"] + config = config_class._build_config(parsed_args) + + assert effect_class.__name__ == "ElephantSplash" + assert config.elephant_color == Color("#800080") + assert config.elephant_highlight_color == Color("#dda0dd") + assert config.water_colors == (Color("#00ffff"), Color("#ffffff")) + assert config.movement_speed == 0.7 + assert config.walk_pose_frames == 6 + assert config.horizontal_step_frames == 3 + assert config.final_gradient_direction is Gradient.Direction.HORIZONTAL + assert config.final_hold_frames == 0 diff --git a/tests/engine_tests/test_animation.py b/tests/engine_tests/test_animation.py index aee8b58a..9d7e2968 100644 --- a/tests/engine_tests/test_animation.py +++ b/tests/engine_tests/test_animation.py @@ -4,7 +4,7 @@ from terminaltexteffects.engine.animation import CharacterVisual, Frame, Scene from terminaltexteffects.engine.base_character import EffectCharacter -from terminaltexteffects.utils import easing +from terminaltexteffects.utils import easing, exceptions from terminaltexteffects.utils.exceptions import ( ActivateEmptySceneError, AnimationSceneError, @@ -180,6 +180,17 @@ def test_animation_new_scene_without_id(character: EffectCharacter) -> None: assert "0" in animation.scenes +def test_animation_new_scene_duplicate_id_preserves_original(character: EffectCharacter) -> None: + """Reject a duplicate scene ID without replacing the original scene.""" + animation = character.animation + original_scene = animation.new_scene(scene_id="test_scene") + + with pytest.raises(exceptions.DuplicateSceneIDError, match="test_scene"): + animation.new_scene(scene_id="test_scene") + + assert animation.query_scene("test_scene") is original_scene + + def test_animation_new_scene_id_generation_deleted_scene(character: EffectCharacter) -> None: """Test that a new scene ID is generated when the previous scene ID has been deleted.""" for _ in range(4): @@ -211,6 +222,32 @@ def test_animation_looping_active_scene_is_complete(character: EffectCharacter) assert animation.active_scene_is_complete() is True +def test_animation_looping_scene_emits_complete_once_per_cycle(character: EffectCharacter) -> None: + """Emit SCENE_COMPLETE only when sequential looping playback wraps.""" + scene = character.animation.new_scene(scene_id="test_scene", is_looping=True) + scene.add_frame(symbol="a", duration=2) + scene.add_frame(symbol="b", duration=2) + completed_cycles: list[str] = [] + character.event_handler.register_event( + character.event_handler.Event.SCENE_COMPLETE, + scene, + character.event_handler.Action.CALLBACK, + character.event_handler.Callback(lambda _character: completed_cycles.append(scene.scene_id)), + ) + character.animation.activate_scene(scene) + + for _ in range(3): + character.animation.step_animation() + assert completed_cycles == [] + + character.animation.step_animation() + assert completed_cycles == ["test_scene"] + + for _ in range(4): + character.animation.step_animation() + assert completed_cycles == ["test_scene", "test_scene"] + + def test_animation_non_looping_active_scene_is_complete(character: EffectCharacter) -> None: """Test that the non-looping active scene is complete after processing all frames.""" animation = character.animation @@ -422,6 +459,65 @@ def test_animation_step_animation_eased_scene_looping(character: EffectCharacter character.animation.step_animation() +def test_animation_eased_looping_scene_emits_complete_once_per_cycle(character: EffectCharacter) -> None: + """Emit SCENE_COMPLETE only when eased looping playback wraps.""" + scene = character.animation.new_scene(scene_id="test_scene", ease=easing.in_sine, is_looping=True) + scene.add_frame(symbol="a", duration=2) + scene.add_frame(symbol="b", duration=2) + completed_cycles: list[str] = [] + character.event_handler.register_event( + character.event_handler.Event.SCENE_COMPLETE, + scene, + character.event_handler.Action.CALLBACK, + character.event_handler.Callback(lambda _character: completed_cycles.append(scene.scene_id)), + ) + character.animation.activate_scene(scene) + + for _ in range(3): + character.animation.step_animation() + assert completed_cycles == [] + + character.animation.step_animation() + assert completed_cycles == ["test_scene"] + + +def test_animation_synced_looping_scene_does_not_emit_complete_per_tick(character: EffectCharacter) -> None: + """Use path events rather than per-tick completion events for synced loops.""" + path = character.motion.new_path() + path.new_waypoint(Coord(10, 10)) + character.motion.activate_path(path) + scene = character.animation.new_scene(scene_id="test_scene", sync=Scene.SyncMetric.STEP, is_looping=True) + scene.add_frame(symbol="a", duration=2) + scene.add_frame(symbol="b", duration=2) + completed_cycles: list[str] = [] + character.event_handler.register_event( + character.event_handler.Event.SCENE_COMPLETE, + scene, + character.event_handler.Action.CALLBACK, + character.event_handler.Callback(lambda _character: completed_cycles.append(scene.scene_id)), + ) + character.animation.activate_scene(scene) + + for _ in range(3): + character.animation.step_animation() + + assert completed_cycles == [] + + +def test_animation_synced_looping_scene_without_path_preserves_frames(character: EffectCharacter) -> None: + """Keep a synced looping scene reusable while no motion path is active.""" + scene = character.animation.new_scene(scene_id="test_scene", sync=Scene.SyncMetric.STEP, is_looping=True) + scene.add_frame(symbol="a", duration=2) + scene.add_frame(symbol="b", duration=2) + character.animation.activate_scene(scene) + + character.animation.step_animation() + + assert [frame.character_visual.symbol for frame in scene.frames] == ["a", "b"] + assert character.animation.active_scene is scene + assert character.animation.current_character_visual.symbol == "b" + + def test_animation_deactivate_scene(character: EffectCharacter) -> None: """Verify that deactivating a scene clears the active scene reference.""" scene = character.animation.new_scene(scene_id="test_scene") @@ -575,6 +671,32 @@ def test_scene_apply_gradient_to_symbols_invalid_symbols(character: EffectCharac new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=gradient) +@pytest.mark.parametrize("symbols", ["", []]) +def test_scene_apply_gradient_to_symbols_empty_sequence( + character: EffectCharacter, + symbols: str | list[str], +) -> None: + """Reject an empty symbol sequence without adding partial frames.""" + new_scene = character.animation.new_scene(scene_id="test_scene") + gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=2) + + with pytest.raises(AnimationSceneError, match="at least one symbol"): + new_scene.apply_gradient_to_symbols(symbols, duration=1, fg_gradient=gradient) + + assert not new_scene.frames + + +def test_scene_apply_gradient_to_symbols_empty_symbol(character: EffectCharacter) -> None: + """Reject an empty individual symbol without adding partial frames.""" + new_scene = character.animation.new_scene(scene_id="test_scene") + gradient = Gradient(Color("#000000"), Color("#ffffff"), steps=2) + + with pytest.raises(AnimationSceneError, match="length of 1"): + new_scene.apply_gradient_to_symbols(["a", ""], duration=1, fg_gradient=gradient) + + assert not new_scene.frames + + def test_scene_apply_gradient_to_symbols_single_single_step(character: EffectCharacter) -> None: """Verify a single-step gradient produces start and end frames.""" new_scene = character.animation.new_scene(scene_id="test_scene") @@ -680,8 +802,8 @@ def test_scene_reset_scene(character: EffectCharacter) -> None: def test_scene_id_equality(character: EffectCharacter) -> None: """Ensure scenes with matching IDs compare as equal.""" - new_scene = character.animation.new_scene(scene_id="test_scene") - new_scene2 = character.animation.new_scene(scene_id="test_scene") + new_scene = Scene(scene_id="test_scene") + new_scene2 = Scene(scene_id="test_scene") assert new_scene == new_scene2 diff --git a/tests/test_cli.py b/tests/test_cli.py index db092da1..2aef5a47 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,12 +17,20 @@ pytestmark = [pytest.mark.smoke] +def _write_plugin(tmp_path: Path, filename: str, source: str) -> Path: + """Write a user effect plugin and return its path.""" + plugin_dir = tmp_path / "terminaltexteffects" / "effects" + plugin_dir.mkdir(parents=True, exist_ok=True) + plugin_file = plugin_dir / filename + plugin_file.write_text(source.strip(), encoding="utf-8") + return plugin_file + + def _write_demo_plugin(tmp_path: Path) -> None: """Create a simple plugin effect in a temporary XDG config directory.""" - plugin_dir = tmp_path / "terminaltexteffects" / "effects" - plugin_dir.mkdir(parents=True) - plugin_file = plugin_dir / "plugin_demo.py" - plugin_file.write_text( + _write_plugin( + tmp_path, + "plugin_demo.py", """ from dataclasses import dataclass @@ -47,8 +55,7 @@ class PluginDemoConfig(BaseConfig): def get_effect_resources(): return "plugindemo", PluginDemoEffect, PluginDemoConfig -""".strip(), - encoding="utf-8", +""", ) @@ -174,6 +181,196 @@ def test_build_parser_includes_plugin_effect_in_completion( assert "--plugin-speed" in output +def test_build_parser_skips_plugin_import_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Keep valid effects available when one user plugin cannot be imported.""" + _write_demo_plugin(tmp_path) + broken_plugin = _write_plugin(tmp_path, "plugin_broken.py", "def broken(:") + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + _, effect_resource_map = __main__.build_parser() + + assert "matrix" in effect_resource_map + assert "plugindemo" in effect_resource_map + warning = capsys.readouterr().err + assert str(broken_plugin) in warning + assert "SyntaxError" in warning + assert all(getattr(module, "__file__", None) != str(broken_plugin) for module in sys.modules.values()) + + +def test_build_parser_skips_plugin_with_mismatched_command( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Reject a plugin whose resource command and parser command differ.""" + mismatched_plugin = _write_plugin( + tmp_path, + "plugin_mismatched.py", + """ +from dataclasses import dataclass + +from terminaltexteffects.engine.base_config import BaseConfig +from terminaltexteffects.utils import argutils + + +class MismatchedEffect: + pass + + +@dataclass +class MismatchedConfig(BaseConfig): + parser_spec = argutils.ParserSpec( + name="parser-command", + help="mismatch", + description="mismatch", + epilog="mismatch", + ) + + +def get_effect_resources(): + return "resource-command", MismatchedEffect, MismatchedConfig +""", + ) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + parser, effect_resource_map = __main__.build_parser() + + assert "resource-command" not in effect_resource_map + assert "parser-command" not in parser.format_help() + warning = capsys.readouterr().err + assert str(mismatched_plugin) in warning + assert "does not match" in warning + + +def test_build_parser_populates_user_plugin_parser_once( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Register a valid plugin without invoking its parser setup twice.""" + plugin_file = _write_plugin( + tmp_path, + "plugin_single_populate.py", + """ +from dataclasses import dataclass + +from terminaltexteffects.engine.base_config import BaseConfig +from terminaltexteffects.utils import argutils + + +class SinglePopulateEffect: + pass + + +@dataclass +class SinglePopulateConfig(BaseConfig): + parser_spec = argutils.ParserSpec( + name="single-populate", + help="single populate", + description="single populate", + epilog="single populate", + ) + populate_calls = 0 + + @classmethod + def _populate_parser(cls, parser): + cls.populate_calls += 1 + if cls.populate_calls > 1: + raise RuntimeError("parser setup called more than once") + super()._populate_parser(parser) + + +def get_effect_resources(): + return "single-populate", SinglePopulateEffect, SinglePopulateConfig +""", + ) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + parser, effect_resource_map = __main__.build_parser() + + assert "single-populate" in effect_resource_map + assert "single-populate" in parser.format_help() + assert str(plugin_file) not in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("filename", "source", "error_name"), + [ + ( + "plugin_invalid_resources.py", + """ +def get_effect_resources(): + return "invalid", object +""", + "ValueError", + ), + ( + "plugin_duplicate.py", + """ +def get_effect_resources(): + return "matrix", object, object +""", + "ValueError", + ), + ( + "plugin_invalid_parser.py", + """ +from dataclasses import dataclass + +from terminaltexteffects.engine.base_config import BaseConfig +from terminaltexteffects.utils import argutils + + +class InvalidParserEffect: + pass + + +@dataclass +class InvalidParserConfig(BaseConfig): + parser_spec = argutils.ParserSpec( + name="invalid-parser", + help="invalid", + description="invalid", + epilog="invalid", + ) + first: int = argutils.ArgSpec(name="--duplicate-option", default=1, type=int) + second: int = argutils.ArgSpec(name="--duplicate-option", default=2, type=int) + + +def get_effect_resources(): + return "invalid-parser", InvalidParserEffect, InvalidParserConfig +""", + "ArgumentError", + ), + ], +) +def test_build_parser_skips_plugin_registration_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + filename: str, + source: str, + error_name: str, +) -> None: + """Keep built-ins available when a user plugin cannot be registered.""" + broken_plugin = _write_plugin(tmp_path, filename, source) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + parser, effect_resource_map = __main__.build_parser() + + assert "matrix" in effect_resource_map + assert "invalid" not in effect_resource_map + assert "invalid-parser" not in effect_resource_map + assert "invalid-parser" not in parser.format_help() + warning = capsys.readouterr().err + assert str(broken_plugin) in warning + assert error_name in warning + + def test_bash_completion_registers_in_clean_shell() -> None: """The bash completion script should register both CLI entry points.""" result = _run_bash(