Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
dc0a112
Harden animation scene lifecycle
tusharmalpani20 Jul 18, 2026
2cd0212
Isolate invalid user effect plugins
tusharmalpani20 Jul 18, 2026
a2f6d7c
Document effect engine hardening
tusharmalpani20 Jul 18, 2026
648a2ef
Add Elephant Splash effect foundation
tusharmalpani20 Jul 18, 2026
189b46b
Animate the elephant walk and poses
tusharmalpani20 Jul 18, 2026
a645fab
Add the water splash and branding reveal
tusharmalpani20 Jul 18, 2026
edc87cd
Cover Elephant Splash edge cases
tusharmalpani20 Jul 18, 2026
5589f02
Document the Elephant Splash effect
tusharmalpani20 Jul 18, 2026
5229f3e
Fix Elephant Splash final hold timing
tusharmalpani20 Jul 18, 2026
f06436d
Move Elephant Splash choreography to the canvas floor
tusharmalpani20 Jul 18, 2026
a8e1a2f
Celebrate after the Elephant Splash reveal
tusharmalpani20 Jul 18, 2026
c71d596
Let the elephant actions breathe
tusharmalpani20 Jul 18, 2026
e331e14
Refresh the Elephant Splash demo and docs
tusharmalpani20 Jul 18, 2026
195ef8d
Redraw the elephant and deepen the puddle
tusharmalpani20 Jul 18, 2026
9f958e6
Refresh the elephant silhouette demo
tusharmalpani20 Jul 18, 2026
be09ccb
Fix elephant trunk and water choreography
tusharmalpani20 Jul 18, 2026
baddb66
Refresh the Elephant Splash demo
tusharmalpani20 Jul 18, 2026
385fec8
Redesign the full elephant animation
tusharmalpani20 Jul 18, 2026
c678244
Update the Elephant Splash demo
tusharmalpani20 Jul 18, 2026
8ee7299
Use the selected elephant artwork
tusharmalpani20 Jul 18, 2026
3422808
Credit and demo the selected elephant
tusharmalpani20 Jul 18, 2026
15b4aea
Coordinate the elephant sprite movement
tusharmalpani20 Jul 18, 2026
17bd732
Refresh the coordinated elephant demo
tusharmalpani20 Jul 18, 2026
3fcbcc3
Keep the elephant trunk raised while walking
tusharmalpani20 Jul 18, 2026
0a5db17
Update the raised-trunk elephant demo
tusharmalpani20 Jul 18, 2026
61dd029
Fix the elephant trunk and grounded gait
tusharmalpani20 Jul 19, 2026
359a1f2
Refresh the corrected elephant walk demo
tusharmalpani20 Jul 19, 2026
4e7b672
Refine the elephant trunk proportions
tusharmalpani20 Jul 19, 2026
8fb04e2
Update the proportional trunk demo
tusharmalpani20 Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/appguide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
27 changes: 27 additions & 0 deletions docs/effects/elephantsplash.md
Original file line number Diff line number Diff line change
@@ -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
Binary file added docs/img/effects_demos/elephantsplash_demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions docs/showroom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
173 changes: 123 additions & 50 deletions terminaltexteffects/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import argparse
import hashlib
import importlib
import importlib.util
import os
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions terminaltexteffects/effects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion terminaltexteffects/effects/effect_beams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Loading