diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b1a4a54..0d2947fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,12 +39,16 @@ identical rendered output. * Motion segment events now trigger `SEGMENT_ENTERED` before `SEGMENT_EXITED` for segments that are completely crossed in a single path step, matching the documented enter/exit event semantics. +* `Canvas._anchor_text()` now accepts an empty input-character list so whitespace-only Python API input can be handled + by effects as a quiet canvas instead of raising during terminal construction. #### Effects Changes (0.16.0) --- * Burn smoke now uses `ParticlePool` for pooled helper characters and event-based reclaim behavior. +* Added Fireflies, a calm nighttime effect with independently blinking input-character lights, bounded curved + wandering, spatial gathering, staggered landing illumination, pooled atmospheric helpers, and a final warm pulse. * LaserEtch sparks now use `ParticlePool` for pooled helper characters and event-based reclaim behavior. ### Bug Fixes (0.16.0) diff --git a/README.md b/README.md index e0930a27..9895b838 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ View the [Documentation](https://chrisbuilds.github.io/terminaltexteffects/) for Effect: Name of the effect to apply. Use -h for effect specific help. - {beams,binarypath,blackhole,bouncyballs,bubbles,burn,colorshift,crumble,decrypt,errorcorrect,expand,fireworks,highlight,laseretch,matrix,middleout,orbittingvolley,overflow,pour,print,rain,randomsequence,rings,scattered,slice,slide,smoke,spotlights,spray,swarm,sweep,synthgrid,thunderstorm,unstable,vhstape,waves,wipe} + {beams,binarypath,blackhole,bouncyballs,bubbles,burn,colorshift,crumble,decrypt,errorcorrect,expand,fireflies,fireworks,highlight,laseretch,matrix,middleout,orbittingvolley,overflow,pour,print,rain,randomsequence,rings,scattered,slice,slide,smoke,spotlights,spray,swarm,sweep,synthgrid,thunderstorm,unstable,vhstape,waves,wipe} Available Effects beams Create beams which travel over the canvas illuminating the characters behind them. binarypath Binary representations of each character move towards the home coordinate of the character. @@ -202,6 +202,7 @@ View the [Documentation](https://chrisbuilds.github.io/terminaltexteffects/) for decrypt Display a movie style decryption effect. errorcorrect Some characters start in the wrong position and are corrected in sequence. expand Expands the text from a single point. + fireflies Fireflies drift through the night and gradually illuminate the text. fireworks Characters launch and explode like fireworks and fall into place. highlight Run a specular highlight across the text. laseretch A laser etches characters onto the terminal. diff --git a/docs/effects/fireflies.md b/docs/effects/fireflies.md new file mode 100644 index 00000000..7fdab4ea --- /dev/null +++ b/docs/effects/fireflies.md @@ -0,0 +1,18 @@ +# Fireflies + +Fireflies drift in from the canvas edges, blink independently, gather around hidden character positions, and gradually illuminate the original text. + +## Quick Start + +``` py title="fireflies.py" +from terminaltexteffects.effects.effect_fireflies import Fireflies + +effect = Fireflies("YourTextHere") +with effect.terminal_output() as terminal: + for frame in effect: + terminal.print(frame) +``` + +The effect uses input characters as its primary fireflies, so their original symbols, coordinates, and final colors are restored exactly. On tiny canvases, orbiting and atmospheric helpers are reduced while blinking and illumination remain active. + +::: terminaltexteffects.effects.effect_fireflies diff --git a/docs/showroom.md b/docs/showroom.md index 9848f783..7d52b3be 100644 --- a/docs/showroom.md +++ b/docs/showroom.md @@ -466,6 +466,36 @@ Characters expand from the center. ``` --- +## Fireflies + +Fireflies drift in from the canvas edges, blink independently, gather around hidden text positions, and illuminate the original text in uneven clusters. + +[Reference](./effects/fireflies.md){ .md-button } [Config](./effects/fireflies.md#terminaltexteffects.effects.effect_fireflies.FirefliesConfig){ .md-button } + +??? example "Fireflies Command Line Arguments" + + ``` + --firefly-colors (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] + Colors used from dimmest to brightest while fireflies blink. (default: ('6b5c20', 'd4a72c', 'fff2a1')) + --firefly-symbols (ASCII/UTF-8 character) [(ASCII/UTF-8 character) ...] + One-cell symbols used in sequence while fireflies blink. (default: ('.', '*', 'o', '*')) + --movement-speed (float > 0) + Base movement speed for fireflies. Individual speeds vary around this value. (default: 0.18) + --wander-cycles (int > 0) + Maximum number of wandering waypoints before a firefly gathers near the text. (default: 3) + --auxiliary-count (int >= 0) + Maximum number of atmospheric fireflies. Tiny canvases automatically use fewer. (default: 6) + --final-gradient-stops (XTerm [0-255] OR RGB Hex [000000-ffffff]) [(XTerm [0-255] OR RGB Hex [000000-ffffff]) ...] + Space separated, unquoted, list of colors for the character gradient (applied across the canvas). If only one color is provided, the characters will be displayed in that color. (default: ('3f4f24', 'd6a928', 'ffe680')) + --final-gradient-steps (int > 0) [(int > 0) ...] + Space separated, unquoted, list of the number of gradient steps to use. More steps will create a smoother and longer gradient animation. (default: 12) + --final-gradient-direction (diagonal, horizontal, vertical, radial) + Direction of the final gradient across the text. (default: Direction.HORIZONTAL) + + Example: terminaltexteffects fireflies --firefly-colors 6b5c20 d4a72c fff2a1 --firefly-symbols . '*' o '*' --movement-speed 0.18 --wander-cycles 3 --auxiliary-count 6 --final-gradient-stops 3f4f24 d6a928 ffe680 --final-gradient-steps 12 --final-gradient-direction horizontal + ``` +--- + ## Fireworks Launches characters up the screen where they explode like fireworks and fall into place. diff --git a/mkdocs.yml b/mkdocs.yml index 48033045..53baf36e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -110,6 +110,7 @@ nav: - effects/decrypt.md - effects/errorcorrect.md - effects/expand.md + - effects/fireflies.md - effects/fireworks.md - effects/highlight.md - effects/laseretch.md diff --git a/terminaltexteffects/effects/__init__.py b/terminaltexteffects/effects/__init__.py index a30224a6..6746423c 100644 --- a/terminaltexteffects/effects/__init__.py +++ b/terminaltexteffects/effects/__init__.py @@ -11,6 +11,7 @@ from terminaltexteffects.effects.effect_decrypt import Decrypt from terminaltexteffects.effects.effect_errorcorrect import ErrorCorrect from terminaltexteffects.effects.effect_expand import Expand +from terminaltexteffects.effects.effect_fireflies import Fireflies from terminaltexteffects.effects.effect_fireworks import Fireworks from terminaltexteffects.effects.effect_highlight import Highlight from terminaltexteffects.effects.effect_laseretch import LaserEtch diff --git a/terminaltexteffects/effects/effect_fireflies.py b/terminaltexteffects/effects/effect_fireflies.py new file mode 100644 index 00000000..317c90fb --- /dev/null +++ b/terminaltexteffects/effects/effect_fireflies.py @@ -0,0 +1,737 @@ +"""Fireflies drift, blink, gather, and illuminate the input text.""" + +from __future__ import annotations + +import random +from dataclasses import dataclass +from enum import Enum, auto + +from terminaltexteffects import ( + Animation, + Color, + ColorPair, + Coord, + EffectCharacter, + EventHandler, + Gradient, + ParticlePool, + ParticleReset, + Scene, + easing, +) +from terminaltexteffects.engine.base_config import ( + BaseConfig, + FinalGradientDirectionArg, + FinalGradientStepsArg, + FinalGradientStopsArg, +) +from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator +from terminaltexteffects.utils import argutils + + +def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]: + """Return the Fireflies CLI command and its effect resources.""" + return "fireflies", Fireflies, FirefliesConfig + + +class FireflyState(Enum): + """Lifecycle states used by primary and auxiliary fireflies.""" + + DORMANT = auto() + ENTERING = auto() + WANDERING = auto() + GATHERING = auto() + ORBITING = auto() + APPROACHING = auto() + ILLUMINATING = auto() + SETTLED = auto() + DEPARTING = auto() + + +class FirefliesPhase(Enum): + """Top-level phases for reveal, final pulse, and completion.""" + + REVEAL = auto() + PULSE = auto() + COMPLETE = auto() + + +@dataclass +class Firefly: + """Precomputed state and choreography for one input character.""" + + character: EffectCharacter + target_coord: Coord + start_coord: Coord + cluster_id: int + activation_frame: int + release_frame: int + blink_signature: tuple[int, int, int, int] + state: FireflyState = FireflyState.DORMANT + attraction_strength: float = 0.0 + arrival_radius: int = 1 + group_leader: Firefly | None = None + settled_frame: int | None = None + + +@dataclass +class FirefliesConfig(BaseConfig): + """Configuration for the Fireflies effect.""" + + parser_spec: argutils.ParserSpec = argutils.ParserSpec( + name="fireflies", + help="Fireflies drift through the night and gradually illuminate the text.", + description="fireflies | Fireflies drift, gather, and gradually illuminate the text.", + epilog=( + "Example: terminaltexteffects fireflies --firefly-colors 6b5c20 d4a72c fff2a1 " + "--firefly-symbols . '*' o '*' --movement-speed 0.18 --wander-cycles 3 --auxiliary-count 6 " + "--final-gradient-stops 3f4f24 d6a928 ffe680 --final-gradient-steps 12 " + "--final-gradient-direction horizontal" + ), + ) + + firefly_colors: tuple[Color, ...] = argutils.ArgSpec( + name="--firefly-colors", + type=argutils.ColorArg.type_parser, + nargs="+", + action=argutils.TupleAction, + default=(Color("#6b5c20"), Color("#d4a72c"), Color("#fff2a1")), + metavar=argutils.ColorArg.METAVAR, + help="Colors used from dimmest to brightest while fireflies blink.", + ) # pyright: ignore[reportAssignmentType] + + firefly_symbols: tuple[str, ...] = argutils.ArgSpec( + name="--firefly-symbols", + type=argutils.Symbol.type_parser, + nargs="+", + action=argutils.TupleAction, + default=(".", "*", "o", "*"), + metavar=argutils.Symbol.METAVAR, + help="One-cell symbols used in sequence while fireflies blink.", + ) # pyright: ignore[reportAssignmentType] + + movement_speed: float = argutils.ArgSpec( + name="--movement-speed", + type=argutils.PositiveFloat.type_parser, + default=0.18, + metavar=argutils.PositiveFloat.METAVAR, + help="Base movement speed for fireflies. Individual speeds vary around this value.", + ) # pyright: ignore[reportAssignmentType] + + wander_cycles: int = argutils.ArgSpec( + name="--wander-cycles", + type=argutils.PositiveInt.type_parser, + default=3, + metavar=argutils.PositiveInt.METAVAR, + help="Maximum number of wandering waypoints before a firefly gathers near the text.", + ) # pyright: ignore[reportAssignmentType] + + auxiliary_count: int = argutils.ArgSpec( + name="--auxiliary-count", + type=argutils.NonNegativeInt.type_parser, + default=6, + metavar=argutils.NonNegativeInt.METAVAR, + help="Maximum number of atmospheric fireflies. Tiny canvases automatically use fewer.", + ) # pyright: ignore[reportAssignmentType] + + final_gradient_stops: tuple[Color, ...] = FinalGradientStopsArg( + default=(Color("#3f4f24"), Color("#d6a928"), Color("#ffe680")), + ) # pyright: ignore[reportAssignmentType] + + final_gradient_steps: tuple[int, ...] | int = FinalGradientStepsArg( + default=12, + ) # pyright: ignore[reportAssignmentType] + + final_gradient_direction: Gradient.Direction = FinalGradientDirectionArg( + default=Gradient.Direction.HORIZONTAL, + ) # pyright: ignore[reportAssignmentType] + + +class FirefliesIterator(BaseEffectIterator[FirefliesConfig]): + """Iterator for the Fireflies effect.""" + + CLUSTER_WIDTH = 4 + CLUSTER_HEIGHT = 2 + + def __init__(self, effect: Fireflies) -> None: + """Initialize and precompute the Fireflies choreography.""" + super().__init__(effect) + self.fireflies: list[Firefly] = [] + self.clusters: dict[int, list[Firefly]] = {} + self.character_final_color_map: dict[EffectCharacter, ColorPair] = {} + self.frame_count = 0 + self.phase = FirefliesPhase.REVEAL + self.final_frame_shown = False + self.pulse_queue: list[list[Firefly]] = [] + self.pulse_activation_frames: list[int] = [] + self._pulse_delay = 0 + self.auxiliary_states: dict[EffectCharacter, FireflyState] = {} + self.auxiliary_activation_frames: list[int] = [] + self.auxiliary_departure_frame: int | None = None + self.halo_emissions = 0 + self.build() + if not self.fireflies: + self.phase = FirefliesPhase.COMPLETE + + def _clamp_coord(self, column: int, row: int) -> Coord: + """Clamp a coordinate to the current canvas.""" + canvas = self.terminal.canvas + return Coord( + min(max(column, canvas.left), canvas.right), + min(max(row, canvas.bottom), canvas.top), + ) + + def _perimeter_coords(self) -> list[Coord]: + """Return every unique coordinate on the canvas perimeter.""" + canvas = self.terminal.canvas + coords = { + Coord(column, row) for column in range(canvas.left, canvas.right + 1) for row in (canvas.bottom, canvas.top) + } + coords.update( + Coord(column, row) for row in range(canvas.bottom, canvas.top + 1) for column in (canvas.left, canvas.right) + ) + return sorted(coords, key=lambda coord: (coord.row, coord.column)) + + def _curve_control(self, start: Coord, end: Coord) -> Coord: + """Create a gently offset, in-canvas control point between two coordinates.""" + midpoint_column = round((start.column + end.column) / 2) + midpoint_row = round((start.row + end.row) / 2) + horizontal_room = max(self.terminal.canvas.width // 6, 1) + vertical_room = max(self.terminal.canvas.height // 6, 1) + return self._clamp_coord( + midpoint_column + random.randint(-horizontal_room, horizontal_room), + midpoint_row + random.randint(-vertical_room, vertical_room), + ) + + def _blink_palette(self) -> tuple[Color, Color, Color]: + """Return dim, warm, and peak colors even when only one color is configured.""" + if len(self.config.firefly_colors) == 1: + base_color = self.config.firefly_colors[0] + return ( + Animation.adjust_color_brightness(base_color, 0.35), + Animation.adjust_color_brightness(base_color, 0.7), + base_color, + ) + spectrum = list(Gradient(*self.config.firefly_colors, steps=3)) + return spectrum[0], spectrum[len(spectrum) // 2], spectrum[-1] + + def _make_blink_scene(self, firefly: Firefly) -> None: + """Create an independently phased looping blink scene.""" + phase, period, peak_hold, symbol_offset = firefly.blink_signature + colors = list(self._blink_palette()) + levels = colors + colors[-2:0:-1] + phase %= len(levels) + levels = levels[phase:] + levels[:phase] + blink_scene = firefly.character.animation.new_scene(scene_id="blink", is_looping=True) + for index, color in enumerate(levels): + symbol = self.config.firefly_symbols[(index + symbol_offset) % len(self.config.firefly_symbols)] + duration = peak_hold if color == colors[-1] else period + blink_scene.add_frame(symbol, duration, colors=ColorPair(fg=color)) + + def _make_paths(self, firefly: Firefly, focus: Coord, cluster_index: int) -> None: + """Build bounded entry, wander, optional orbit, and approach paths.""" + character = firefly.character + canvas = self.terminal.canvas + speed = max(self.config.movement_speed * random.uniform(0.75, 1.25), 0.01) + + inward_column = firefly.start_coord.column + inward_row = firefly.start_coord.row + if firefly.start_coord.column == canvas.left: + inward_column += min(2, canvas.width - 1) + elif firefly.start_coord.column == canvas.right: + inward_column -= min(2, canvas.width - 1) + if firefly.start_coord.row == canvas.bottom: + inward_row += min(1, canvas.height - 1) + elif firefly.start_coord.row == canvas.top: + inward_row -= min(1, canvas.height - 1) + entry_coord = self._clamp_coord(inward_column, inward_row) + enter_path = character.motion.new_path(path_id="enter", speed=speed, ease=easing.in_out_sine, layer=2) + enter_path.new_waypoint( + entry_coord, + bezier_control=self._curve_control(firefly.start_coord, entry_coord), + ) + + wander_path = character.motion.new_path( + path_id="wander", + speed=max(speed * 0.75, 0.01), + ease=easing.in_out_sine, + layer=2, + ) + last_coord = entry_coord + horizontal_radius = max(min(canvas.width // 4, 3), 1) + vertical_radius = max(min(canvas.height // 4, 2), 1) + for _ in range(self.config.wander_cycles): + next_coord = self._clamp_coord( + focus.column + random.randint(-horizontal_radius, horizontal_radius), + focus.row + random.randint(-vertical_radius, vertical_radius), + ) + wander_path.new_waypoint(next_coord, bezier_control=self._curve_control(last_coord, next_coord)) + last_coord = next_coord + + if canvas.width >= 3 and canvas.height >= 2 and cluster_index % 3 == 1: + orbit_path = character.motion.new_path( + path_id="orbit", + speed=max(speed * 0.7, 0.01), + ease=easing.in_out_sine, + layer=2, + ) + first_orbit = self._clamp_coord(focus.column - 1, focus.row + 1) + second_orbit = self._clamp_coord(focus.column + 1, focus.row - 1) + orbit_path.new_waypoint(first_orbit, bezier_control=self._curve_control(last_coord, first_orbit)) + orbit_path.new_waypoint(second_orbit, bezier_control=self._curve_control(first_orbit, second_orbit)) + last_coord = second_orbit + + approach_path = character.motion.new_path( + path_id="approach", + speed=max(speed * 1.1, 0.01), + ease=easing.in_out_quad, + layer=2, + ) + column_direction = 1 if firefly.target_coord.column >= focus.column else -1 + row_direction = 1 if firefly.target_coord.row >= focus.row else -1 + overshoot = self._clamp_coord( + firefly.target_coord.column + column_direction, + firefly.target_coord.row + row_direction, + ) + approach_path.new_waypoint(overshoot, bezier_control=self._curve_control(last_coord, overshoot)) + approach_path.new_waypoint( + firefly.target_coord, + bezier_control=self._curve_control(overshoot, firefly.target_coord), + ) + + def _make_landing_scene(self, firefly: Firefly) -> None: + """Build the finite symbol-restoration scene for a landing firefly.""" + character = firefly.character + final_colors = self.character_final_color_map[character] + peak_color = self._blink_palette()[-1] + landing_scene = character.animation.new_scene(scene_id="landing") + landing_scene.add_frame( + self.config.firefly_symbols[-1], + 2, + colors=ColorPair(fg=peak_color, bg=final_colors.bg_color), + ) + landing_scene.add_frame( + character.input_symbol, + 2, + colors=ColorPair(fg=peak_color, bg=final_colors.bg_color), + ) + if final_colors.fg_color is not None: + for color in Gradient(peak_color, final_colors.fg_color, steps=4): + landing_scene.add_frame( + character.input_symbol, + 2, + colors=ColorPair(fg=color, bg=final_colors.bg_color), + ) + else: + landing_scene.add_frame( + character.input_symbol, + 2, + colors=ColorPair( + fg=Animation.adjust_color_brightness(peak_color, 0.55), + bg=final_colors.bg_color, + ), + ) + landing_scene.add_frame(character.input_symbol, 2, colors=final_colors) + + pulse_scene = character.animation.new_scene(scene_id="pulse") + pulse_scene.add_frame(character.input_symbol, 2, colors=final_colors) + pulse_scene.add_frame( + character.input_symbol, + 2, + colors=ColorPair(fg=peak_color, bg=final_colors.bg_color), + ) + pulse_scene.add_frame( + character.input_symbol, + 2, + colors=ColorPair(fg=self.config.firefly_colors[-1], bg=final_colors.bg_color), + ) + pulse_scene.add_frame(character.input_symbol, 2, colors=final_colors) + + character.event_handler.register_event( + EventHandler.Event.SCENE_COMPLETE, + landing_scene, + EventHandler.Action.CALLBACK, + EventHandler.Callback(self._settle_firefly, firefly), + ) + + def _register_path_events(self, firefly: Firefly) -> None: + """Connect bounded motion paths to explicit state transitions.""" + character = firefly.character + character.event_handler.register_event( + EventHandler.Event.PATH_COMPLETE, + "enter", + EventHandler.Action.CALLBACK, + EventHandler.Callback(self._finish_entry, firefly), + ) + character.event_handler.register_event( + EventHandler.Event.PATH_COMPLETE, + "wander", + EventHandler.Action.CALLBACK, + EventHandler.Callback(self._finish_wander, firefly), + ) + if "orbit" in character.motion.paths: + character.event_handler.register_event( + EventHandler.Event.PATH_COMPLETE, + "orbit", + EventHandler.Action.CALLBACK, + EventHandler.Callback(self._finish_orbit, firefly), + ) + character.event_handler.register_event( + EventHandler.Event.PATH_COMPLETE, + "approach", + EventHandler.Action.CALLBACK, + EventHandler.Callback(self._land_firefly, firefly), + ) + + def _finish_entry(self, character: EffectCharacter, firefly: Firefly) -> None: + """Advance an entering firefly into its curved wander path.""" + firefly.state = FireflyState.WANDERING + firefly.attraction_strength = 0.25 + character.motion.activate_path("wander") + + def _finish_wander(self, _: EffectCharacter, firefly: Firefly) -> None: + """Hold a wandering firefly near its cluster until its release frame.""" + firefly.state = FireflyState.GATHERING + firefly.attraction_strength = 0.6 + + def _finish_orbit(self, character: EffectCharacter, firefly: Firefly) -> None: + """Move an orbiting firefly into its final angled approach.""" + firefly.state = FireflyState.APPROACHING + firefly.attraction_strength = 0.9 + character.motion.activate_path("approach") + + def _land_firefly(self, character: EffectCharacter, firefly: Firefly) -> None: + """Restore the input coordinate and begin finite illumination.""" + character.motion.set_coordinate(firefly.target_coord) + firefly.state = FireflyState.ILLUMINATING + firefly.attraction_strength = 1.0 + character.animation.activate_scene("landing") + + def _settle_firefly(self, character: EffectCharacter, firefly: Firefly) -> None: + """Mark a completed landing and leave its exact final appearance stable.""" + character.motion.set_coordinate(character.input_coord) + character.animation.set_appearance(character.input_symbol, self.character_final_color_map[character]) + character.layer = 0 + firefly.state = FireflyState.SETTLED + firefly.settled_frame = self.frame_count + self._emit_halo(character.input_coord) + + def _activate_approach(self, firefly: Firefly) -> None: + """Release one gathered firefly through an optional orbit and approach.""" + character = firefly.character + if "orbit" in character.motion.paths: + firefly.state = FireflyState.ORBITING + firefly.attraction_strength = 0.75 + character.motion.activate_path("orbit") + else: + firefly.state = FireflyState.APPROACHING + firefly.attraction_strength = 0.9 + character.motion.activate_path("approach") + self.active_characters.add(character) + + def _prepare_pulse(self) -> None: + """Group settled fireflies into a diagonal warm-light wave.""" + pulse_groups: dict[int, list[Firefly]] = {} + for firefly in self.fireflies: + diagonal = firefly.target_coord.column + firefly.target_coord.row + pulse_groups.setdefault(diagonal, []).append(firefly) + self.pulse_queue = [pulse_groups[key] for key in sorted(pulse_groups)] + self._pulse_delay = 0 + self.phase = FirefliesPhase.PULSE + + def _initialize_auxiliary(self, particle: EffectCharacter) -> None: + """Create reusable blink and halo scenes for one pooled helper.""" + colors = self._blink_palette() + blink_scene = particle.animation.new_scene(scene_id="blink", is_looping=True) + phase = random.randrange(len(colors)) + ordered_colors = list(colors[phase:] + colors[:phase]) + for index, color in enumerate(ordered_colors): + blink_scene.add_frame( + self.config.firefly_symbols[index % len(self.config.firefly_symbols)], + random.randint(2, 5), + colors=ColorPair(fg=color), + ) + for index, color in enumerate(reversed(ordered_colors[:-1])): + blink_scene.add_frame( + self.config.firefly_symbols[(index + 1) % len(self.config.firefly_symbols)], + random.randint(2, 5), + colors=ColorPair(fg=color), + ) + + halo_scene = particle.animation.new_scene(scene_id="halo") + halo_scene.add_frame("*", 2, colors=ColorPair(fg=colors[-1])) + halo_scene.add_frame(".", 2, colors=ColorPair(fg=colors[1])) + halo_scene.add_frame(".", 1, colors=ColorPair(fg=colors[0])) + particle.layer = 1 + self.auxiliary_states[particle] = FireflyState.DORMANT + + def _build_auxiliary_pool(self) -> None: + """Create a canvas-scaled, strictly capped pool of atmospheric helpers.""" + canvas_area = self.terminal.canvas.width * self.terminal.canvas.height + effective_count = min(self.config.auxiliary_count, 12, canvas_area // 8) + self.auxiliary_pool = ParticlePool( + self.terminal, + self.active_characters, + self.config.firefly_symbols, + initial_count=effective_count, + max_size=effective_count, + initializer=self._initialize_auxiliary, + ) + self.auxiliary_activation_frames = sorted(random.randint(1, 6) for _ in range(effective_count)) + earliest_release = min((firefly.release_frame for firefly in self.fireflies), default=18) + self._auxiliary_departure_deadline = max(12, earliest_release - 6) + + def _setup_atmospheric_path(self, particle: EffectCharacter) -> None: + """Prepare one finite, gently curved atmospheric wander.""" + wander_path = particle.motion.new_path( + path_id="atmosphere", + speed=max(self.config.movement_speed * random.uniform(0.7, 1.1), 0.01), + ease=easing.in_out_sine, + layer=1, + ) + last_coord = particle.motion.current_coord + for _ in range(self.config.wander_cycles + 2): + next_coord = self.terminal.canvas.random_coord() + wander_path.new_waypoint(next_coord, bezier_control=self._curve_control(last_coord, next_coord)) + last_coord = next_coord + blink_scene = particle.animation.query_scene("blink") + blink_scene.reset_scene() + particle.animation.activate_scene(blink_scene) + particle.motion.activate_path(wander_path) + self.auxiliary_states[particle] = FireflyState.WANDERING + + def _emit_due_auxiliaries(self) -> None: + """Emit scheduled atmospheric particles from perimeter coordinates.""" + perimeter = self._perimeter_coords() + while self.auxiliary_activation_frames and self.frame_count >= self.auxiliary_activation_frames[0]: + self.auxiliary_activation_frames.pop(0) + self.auxiliary_pool.emit( + random.choice(perimeter), + self._setup_atmospheric_path, + reset=ParticleReset(clear_paths=True, clear_scenes=False, clear_events=True), + ) + + def _mark_auxiliary_reclaimed(self, character: EffectCharacter, *_: object) -> None: + """Record that a helper is hidden and available for reuse.""" + self.auxiliary_states[character] = FireflyState.DORMANT + + def _depart_auxiliaries(self) -> None: + """Send every emitted atmospheric helper beyond a canvas edge and reclaim it.""" + if self.auxiliary_departure_frame is not None: + return + self.auxiliary_departure_frame = self.frame_count + self.auxiliary_activation_frames.clear() + available_particles = set(self.auxiliary_pool.available) + for particle in self.auxiliary_pool.particles: + if particle in available_particles: + continue + depart_path = particle.motion.new_path( + path_id="depart", + speed=max(self.config.movement_speed * 1.4, 0.01), + ease=easing.in_sine, + layer=1, + ) + target = self.terminal.canvas.random_coord(outside_scope=True) + depart_path.new_waypoint(target, bezier_control=self._curve_control(particle.motion.current_coord, target)) + depart_scene = particle.animation.new_scene(scene_id="depart", sync=Scene.SyncMetric.DISTANCE) + for color in reversed(self._blink_palette()): + depart_scene.add_frame(particle.input_symbol, 2, colors=ColorPair(fg=color)) + self.auxiliary_pool.reclaim_on_event( + particle, + depart_path, + event=EventHandler.Event.PATH_COMPLETE, + ) + particle.event_handler.register_event( + EventHandler.Event.PATH_COMPLETE, + depart_path, + EventHandler.Action.CALLBACK, + EventHandler.Callback(self._mark_auxiliary_reclaimed), + ) + self.auxiliary_states[particle] = FireflyState.DEPARTING + particle.animation.activate_scene(depart_scene) + particle.motion.activate_path(depart_path) + self.active_characters.add(particle) + + def _emit_halo(self, origin: Coord) -> None: + """Reuse one available atmospheric helper as a prompt landing halo.""" + if not hasattr(self, "auxiliary_pool") or not self.auxiliary_pool.available: + return + + def setup_halo(particle: EffectCharacter) -> None: + halo_scene = particle.animation.query_scene("halo") + halo_scene.reset_scene() + self.auxiliary_pool.reclaim_on_event(particle, halo_scene) + particle.event_handler.register_event( + EventHandler.Event.SCENE_COMPLETE, + halo_scene, + EventHandler.Action.CALLBACK, + EventHandler.Callback(self._mark_auxiliary_reclaimed), + ) + self.auxiliary_states[particle] = FireflyState.ILLUMINATING + particle.animation.activate_scene(halo_scene) + + emitted = self.auxiliary_pool.emit( + origin, + setup_halo, + symbol="*", + reset=ParticleReset(clear_paths=True, clear_scenes=False, clear_events=True), + ) + if emitted is not None: + self.halo_emissions += 1 + + def _step_auxiliaries(self) -> None: + """Advance scheduled emissions and enforce bounded atmospheric departure.""" + if not self.auxiliary_pool.particles: + return + if self.auxiliary_departure_frame is None: + self._emit_due_auxiliaries() + settled_count = sum(firefly.state is FireflyState.SETTLED for firefly in self.fireflies) + settlement_ratio = settled_count / len(self.fireflies) if self.fireflies else 1 + if self.frame_count >= self._auxiliary_departure_deadline or settlement_ratio >= 0.75: + self._depart_auxiliaries() + available_particles = set(self.auxiliary_pool.available) + for particle in self.auxiliary_pool.particles: + if particle not in available_particles and particle.is_visible: + self.active_characters.add(particle) + + def _step_reveal(self) -> None: + """Activate, release, and tick primary fireflies for one reveal frame.""" + self._step_auxiliaries() + for firefly in self.fireflies: + character = firefly.character + if firefly.state is FireflyState.DORMANT and self.frame_count >= firefly.activation_frame: + firefly.state = FireflyState.ENTERING + firefly.attraction_strength = 0.1 + self.terminal.set_character_visibility(character, is_visible=True) + character.animation.activate_scene("blink") + character.motion.activate_path("enter") + if firefly.state is FireflyState.GATHERING and self.frame_count >= firefly.release_frame: + self._activate_approach(firefly) + if firefly.state is not FireflyState.DORMANT and firefly.state is not FireflyState.SETTLED: + self.active_characters.add(character) + self.update() + auxiliaries_reclaimed = len(self.auxiliary_pool.available) == len(self.auxiliary_pool) + if ( + self.fireflies + and all(firefly.state is FireflyState.SETTLED for firefly in self.fireflies) + and auxiliaries_reclaimed + ): + self._prepare_pulse() + + def _step_pulse(self) -> None: + """Activate and tick the next diagonal group in the final warm pulse.""" + if self.pulse_queue and self._pulse_delay == 0: + group = self.pulse_queue.pop(0) + self.pulse_activation_frames.append(self.frame_count) + for firefly in group: + pulse_scene = firefly.character.animation.query_scene("pulse") + pulse_scene.reset_scene() + firefly.character.animation.activate_scene(pulse_scene) + self.active_characters.add(firefly.character) + self._pulse_delay = 2 + elif self._pulse_delay: + self._pulse_delay -= 1 + self.update() + if not self.pulse_queue and not self.active_characters: + self.phase = FirefliesPhase.COMPLETE + + def build(self) -> None: + """Precompute clusters, blink schedules, paths, and final colors.""" + characters = self.terminal.get_characters() + final_gradient = Gradient(*self.config.final_gradient_stops, steps=self.config.final_gradient_steps) + final_mapping = final_gradient.build_coordinate_color_mapping( + self.terminal.canvas.text_bottom, + self.terminal.canvas.text_top, + self.terminal.canvas.text_left, + self.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + for character in characters: + if self.terminal.config.existing_color_handling in ("dynamic", "always"): + self.character_final_color_map[character] = ColorPair( + fg=character.animation.input_fg_color, + bg=character.animation.input_bg_color, + ) + else: + self.character_final_color_map[character] = ColorPair(fg=final_mapping[character.input_coord]) + + bucketed_characters: dict[tuple[int, int], list[EffectCharacter]] = {} + for character in characters: + bucket = ( + (character.input_coord.column - self.terminal.canvas.text_left) // self.CLUSTER_WIDTH, + (character.input_coord.row - self.terminal.canvas.text_bottom) // self.CLUSTER_HEIGHT, + ) + bucketed_characters.setdefault(bucket, []).append(character) + bucket_order = list(bucketed_characters) + random.shuffle(bucket_order) + + blink_signatures = [ + (phase, period, peak_hold, symbol_offset) + for phase in range(4) + for period in range(2, 6) + for peak_hold in range(1, 3) + for symbol_offset in range(4) + ] + random.shuffle(blink_signatures) + perimeter = self._perimeter_coords() + + for cluster_id, bucket in enumerate(bucket_order): + cluster_characters = bucketed_characters[bucket] + random.shuffle(cluster_characters) + focus = self._clamp_coord( + round(sum(character.input_coord.column for character in cluster_characters) / len(cluster_characters)), + round(sum(character.input_coord.row for character in cluster_characters) / len(cluster_characters)), + ) + release_frame = 28 + cluster_id * 9 + random.randint(0, 6) + cluster: list[Firefly] = [] + for character_index, character in enumerate(cluster_characters): + overall_index = len(self.fireflies) + start_coord = random.choice(perimeter) + firefly = Firefly( + character=character, + target_coord=character.input_coord, + start_coord=start_coord, + cluster_id=cluster_id, + activation_frame=cluster_id * 2 + random.randint(1, 8), + release_frame=release_frame + random.randint(0, 3), + blink_signature=blink_signatures[overall_index % len(blink_signatures)], + arrival_radius=max(1, min(self.terminal.canvas.width, self.terminal.canvas.height) // 8), + ) + character.motion.set_coordinate(start_coord) + self._make_blink_scene(firefly) + self._make_paths(firefly, focus, character_index) + self._make_landing_scene(firefly) + self._register_path_events(firefly) + cluster.append(firefly) + self.fireflies.append(firefly) + for firefly in cluster[1:]: + firefly.group_leader = cluster[0] + self.clusters[cluster_id] = cluster + self._build_auxiliary_pool() + + def __next__(self) -> str: + """Advance the reveal, final pulse, or stable completion frame.""" + if self.phase is FirefliesPhase.COMPLETE: + if not self.final_frame_shown: + self.final_frame_shown = True + return self.frame + raise StopIteration + + self.frame_count += 1 + if self.phase is FirefliesPhase.REVEAL: + self._step_reveal() + elif self.phase is FirefliesPhase.PULSE: + self._step_pulse() + return self.frame + + +class Fireflies(BaseEffect[FirefliesConfig]): + """Fireflies drift through the canvas and illuminate the input text.""" + + @property + def _config_cls(self) -> type[FirefliesConfig]: + return FirefliesConfig + + @property + def _iterator_cls(self) -> type[FirefliesIterator]: + return FirefliesIterator diff --git a/terminaltexteffects/engine/terminal.py b/terminaltexteffects/engine/terminal.py index 2bb57882..360cdf04 100644 --- a/terminaltexteffects/engine/terminal.py +++ b/terminaltexteffects/engine/terminal.py @@ -359,11 +359,11 @@ def _anchor_text( ) -> list[EffectCharacter]: """Anchors the text within the canvas based on the specified anchor point. - The `characters` argument must be non-empty; this method expects at least one - character when calculating anchored text bounds. + Empty character lists are represented by the full canvas bounds. This permits + effects to handle whitespace-only input as a quiet canvas. Args: - characters (list[EffectCharacter]): Non-empty list of characters to reposition within the canvas. + characters (list[EffectCharacter]): Characters to reposition within the canvas. anchor (Literal["n", "ne", "e", "se", "s", "sw", "w", "nw", "c"]): Anchor point for the text within the Canvas. @@ -372,6 +372,18 @@ def _anchor_text( coordinates within the canvas after anchoring. """ + if not characters: + self.text_left = self.left + self.text_right = self.right + self.text_top = self.top + self.text_bottom = self.bottom + self.text_width = self.width + self.text_height = self.height + self.text_center_row = self.center_row + self.text_center_column = self.center_column + self.text_center = self.center + return [] + # translate coordinate based on anchor within the canvas input_width = max([character._input_coord.column for character in characters]) input_height = max([character._input_coord.row for character in characters]) diff --git a/tests/conftest.py b/tests/conftest.py index 0e932f92..1f60a3b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ effect_decrypt, effect_errorcorrect, effect_expand, + effect_fireflies, effect_fireworks, effect_highlight, effect_laseretch, @@ -161,6 +162,7 @@ effect_decrypt.Decrypt, effect_errorcorrect.ErrorCorrect, effect_expand.Expand, + effect_fireflies.Fireflies, effect_fireworks.Fireworks, effect_highlight.Highlight, effect_laseretch.LaserEtch, diff --git a/tests/effects_tests/test_fireflies.py b/tests/effects_tests/test_fireflies.py new file mode 100644 index 00000000..2d05e471 --- /dev/null +++ b/tests/effects_tests/test_fireflies.py @@ -0,0 +1,420 @@ +"""Focused tests for the Fireflies effect.""" + +from __future__ import annotations + +import importlib +import importlib.util +import random +import subprocess +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Literal, cast + +import pytest + +from terminaltexteffects import __main__ +from terminaltexteffects.effects import effect_fireflies +from terminaltexteffects.engine.terminal import TerminalConfig +from terminaltexteffects.utils.graphics import Color, ColorPair, Gradient + +if TYPE_CHECKING: + from terminaltexteffects.engine.motion import Path as MotionPath + + +def _make_iterator( + input_data: str, + *, + seed: int = 7, + auxiliary_count: int = 0, + wander_cycles: int = 3, + movement_speed: float = 0.18, + existing_color_handling: Literal["always", "dynamic", "ignore"] = "ignore", +) -> effect_fireflies.FirefliesIterator: + """Build a deterministic Fireflies iterator without terminal frame limiting.""" + random.seed(seed) + effect = effect_fireflies.Fireflies(input_data) + effect.effect_config.auxiliary_count = auxiliary_count + effect.effect_config.wander_cycles = wander_cycles + effect.effect_config.movement_speed = movement_speed + terminal_config = TerminalConfig._build_config() + terminal_config.frame_rate = 0 + terminal_config.ignore_terminal_dimensions = True + terminal_config.existing_color_handling = existing_color_handling + effect.terminal_config = terminal_config + return cast("effect_fireflies.FirefliesIterator", iter(effect)) + + +def _run_to_completion( + iterator: effect_fireflies.FirefliesIterator, + *, + max_frames: int = 3000, +) -> tuple[list[str], set[effect_fireflies.FireflyState]]: + """Consume an iterator with a guard and record all primary states observed.""" + frames: list[str] = [] + observed_states = {firefly.state for firefly in iterator.fireflies} + for _ in range(max_frames): + try: + frames.append(next(iterator)) + except StopIteration: + return frames, observed_states + observed_states.update(firefly.state for firefly in iterator.fireflies) + pytest.fail(f"Fireflies did not terminate within {max_frames} frames") + + +def _path_signature(path: MotionPath) -> tuple[object, ...]: + """Serialize observable path planning data for deterministic comparisons.""" + return ( + path.path_id, + path.speed, + tuple((waypoint.coord, waypoint.bezier_control) for waypoint in path.waypoints), + ) + + +def test_fireflies_module_and_public_export() -> None: + """Fireflies should be available through its module and the public effects package.""" + module_spec = importlib.util.find_spec("terminaltexteffects.effects.effect_fireflies") + + assert module_spec is not None + + fireflies_module = importlib.import_module("terminaltexteffects.effects.effect_fireflies") + effects_package = importlib.import_module("terminaltexteffects.effects") + command, effect_class, config_class = fireflies_module.get_effect_resources() + + assert command == "fireflies" + assert effect_class is fireflies_module.Fireflies + assert config_class is fireflies_module.FirefliesConfig + assert effects_package.Fireflies is fireflies_module.Fireflies + + +def test_fireflies_cli_discovery_and_defaults(capsys: pytest.CaptureFixture[str]) -> None: + """CLI discovery should expose Fireflies and its expressive default configuration.""" + parser, effect_resource_map = __main__.build_parser() + + assert "fireflies" in effect_resource_map + assert "fireflies" in parser.format_help() + + parsed_args = parser.parse_args(["fireflies"]) + config_class = effect_resource_map["fireflies"][1] + config = config_class._build_config(parsed_args) + + assert config.firefly_colors == (Color("#6b5c20"), Color("#d4a72c"), Color("#fff2a1")) + assert config.firefly_symbols == (".", "*", "o", "*") + assert config.movement_speed == 0.18 + assert config.wander_cycles == 3 + assert config.auxiliary_count == 6 + assert config.final_gradient_stops == (Color("#3f4f24"), Color("#d6a928"), Color("#ffe680")) + assert config.final_gradient_steps == 12 + assert config.final_gradient_direction is Gradient.Direction.HORIZONTAL + + with pytest.raises(SystemExit, match="0"): + parser.parse_args(["fireflies", "--help"]) + fireflies_help = capsys.readouterr().out + assert "--firefly-colors" in fireflies_help + assert "--auxiliary-count" in fireflies_help + + +@pytest.mark.parametrize( + "arguments", + [ + ["--movement-speed", "0"], + ["--wander-cycles", "0"], + ["--auxiliary-count", "-1"], + ["--firefly-symbols", "too-wide"], + ["--firefly-colors", "not-a-color"], + ], +) +def test_fireflies_cli_rejects_invalid_configuration( + arguments: list[str], + capsys: pytest.CaptureFixture[str], +) -> None: + """Every Fireflies option should use the repository's standard validators.""" + parser, _ = __main__.build_parser() + + with pytest.raises(SystemExit, match="2"): + parser.parse_args(["fireflies", *arguments]) + + assert "unrecognized arguments" not in capsys.readouterr().err + + +def test_firefly_state_creation_and_bounded_paths() -> None: + """Every input character should receive a bounded, in-canvas firefly plan.""" + iterator = _make_iterator("ABCDE\nFGHIJ\nKLMNO", wander_cycles=3) + + assert len(iterator.fireflies) == len(iterator.terminal.get_characters()) + assert iterator.clusters + + for firefly in iterator.fireflies: + character = firefly.character + start = firefly.start_coord + canvas = iterator.terminal.canvas + + assert firefly.state is effect_fireflies.FireflyState.DORMANT + assert firefly.target_coord == character.input_coord + assert character.motion.current_coord == start + assert canvas.coord_is_in_canvas(start) + assert start.column in (canvas.left, canvas.right) or start.row in (canvas.bottom, canvas.top) + assert not character.is_visible + assert firefly.arrival_radius >= 1 + assert firefly.attraction_strength == 0 + + assert "enter" in character.motion.paths + assert "wander" in character.motion.paths + assert "approach" in character.motion.paths + assert len(character.motion.paths["wander"].waypoints) <= iterator.config.wander_cycles + assert character.motion.paths["approach"].waypoints[-1].coord == character.input_coord + assert all(not path.loop for path in character.motion.paths.values()) + + total_waypoints = 0 + for path in character.motion.paths.values(): + total_waypoints += len(path.waypoints) + for waypoint in path.waypoints: + assert canvas.coord_is_in_canvas(waypoint.coord) + assert all(canvas.coord_is_in_canvas(control) for control in waypoint.bezier_control or ()) + assert total_waypoints <= iterator.config.wander_cycles + 5 + + +def test_fireflies_receive_varied_blink_motion_and_cluster_schedules() -> None: + """Blink and motion schedules should vary while spatial groups remain bounded.""" + iterator = _make_iterator("ABCDEFGHIJKLMNOP\nQRSTUVWXYZabcdef") + + blink_signatures = [firefly.blink_signature for firefly in iterator.fireflies] + motion_signatures = [ + tuple(_path_signature(path) for path in firefly.character.motion.paths.values()) + for firefly in iterator.fireflies + ] + + assert len(set(blink_signatures[:8])) == 8 + assert len(set(motion_signatures)) > 1 + assert len({firefly.activation_frame for firefly in iterator.fireflies}) > 1 + assert len({firefly.release_frame for firefly in iterator.fireflies}) > 1 + + for firefly in iterator.fireflies: + blink_scene = firefly.character.animation.query_scene("blink") + assert blink_scene.is_looping + assert len({frame.character_visual.colors.fg_color for frame in blink_scene.frames}) >= 3 + assert all(frame.character_visual.symbol in iterator.config.firefly_symbols for frame in blink_scene.frames) + + for cluster in iterator.clusters.values(): + columns = [firefly.target_coord.column for firefly in cluster] + rows = [firefly.target_coord.row for firefly in cluster] + assert max(columns) - min(columns) <= 3 + assert max(rows) - min(rows) <= 1 + assert all(firefly.group_leader is cluster[0] for firefly in cluster[1:]) + + +def test_firefly_planning_is_deterministic_under_repository_seed_convention() -> None: + """Resetting module-level random should reproduce all planned schedules and paths.""" + + def serialize(iterator: effect_fireflies.FirefliesIterator) -> tuple[object, ...]: + return tuple( + ( + firefly.start_coord, + firefly.cluster_id, + firefly.activation_frame, + firefly.release_frame, + firefly.blink_signature, + tuple(_path_signature(path) for path in firefly.character.motion.paths.values()), + ) + for firefly in iterator.fireflies + ) + + first = serialize(_make_iterator("DETERMINISTIC\nFIREFLIES", seed=41)) + second = serialize(_make_iterator("DETERMINISTIC\nFIREFLIES", seed=41)) + + assert first == second + + +def test_fireflies_progress_through_living_states_and_restore_exact_text() -> None: + """Primary fireflies should wander, gather, land, pulse, and finish exactly.""" + iterator = _make_iterator("ABCDE\nFGHIJ", seed=17, wander_cycles=1, movement_speed=1.0) + + frames, observed_states = _run_to_completion(iterator) + + assert frames + assert { + effect_fireflies.FireflyState.DORMANT, + effect_fireflies.FireflyState.ENTERING, + effect_fireflies.FireflyState.WANDERING, + effect_fireflies.FireflyState.GATHERING, + effect_fireflies.FireflyState.APPROACHING, + effect_fireflies.FireflyState.ILLUMINATING, + effect_fireflies.FireflyState.SETTLED, + } <= observed_states + assert effect_fireflies.FireflyState.ORBITING in observed_states + assert iterator.phase is effect_fireflies.FirefliesPhase.COMPLETE + assert iterator.final_frame_shown + + for firefly in iterator.fireflies: + character = firefly.character + assert firefly.state is effect_fireflies.FireflyState.SETTLED + assert firefly.settled_frame is not None + assert character.motion.current_coord == character.input_coord + assert character.animation.current_character_visual.symbol == character.input_symbol + assert character.animation.current_character_visual.colors == iterator.character_final_color_map[character] + assert character.layer == 0 + assert character.is_visible + assert character.motion.active_path is None + assert character.animation.active_scene is None + + +def test_arrivals_and_final_pulse_are_staggered_in_groups() -> None: + """Settlements should be uneven and the final warm pulse should travel in groups.""" + iterator = _make_iterator("ABCDEFGHIJKLMNOP\nQRSTUVWXYZabcdef", seed=23, wander_cycles=1, movement_speed=1.0) + + _run_to_completion(iterator) + + settled_frames = [firefly.settled_frame for firefly in iterator.fireflies] + assert None not in settled_frames + assert len(set(settled_frames)) > 3 + assert len(iterator.pulse_activation_frames) > 1 + assert list(iterator.pulse_activation_frames) == sorted(iterator.pulse_activation_frames) + assert len(set(iterator.pulse_activation_frames)) > 1 + + +def test_auxiliary_fireflies_depart_reuse_as_halos_and_cleanup() -> None: + """Atmospheric helpers should depart, support bounded halo reuse, and leave no artifacts.""" + iterator = _make_iterator( + "ABCDEFGHIJKLMNOP\nQRSTUVWXYZabcdef\nghijklmnopqrstuv", + seed=31, + auxiliary_count=4, + wander_cycles=1, + movement_speed=1.0, + ) + + assert len(iterator.auxiliary_pool) == 4 + assert all(not particle.is_visible for particle in iterator.auxiliary_pool.particles) + assert len(iterator.auxiliary_pool.available) == len(iterator.auxiliary_pool) + + _run_to_completion(iterator) + + assert iterator.auxiliary_departure_frame is not None + assert iterator.halo_emissions > 0 + assert len(iterator.auxiliary_pool.available) == len(iterator.auxiliary_pool) + assert all(not particle.is_visible for particle in iterator.auxiliary_pool.particles) + assert all(particle not in iterator.active_characters for particle in iterator.auxiliary_pool.particles) + assert all(particle.animation.active_scene is None for particle in iterator.auxiliary_pool.particles) + assert all(particle.motion.active_path is None for particle in iterator.auxiliary_pool.particles) + + +@pytest.mark.parametrize( + "input_data", + [ + "", + "A", + "ABCDE", + "A\nB\nC\nD", + "A D\n\n G", + "ABCD\nEFGH\nIJKL", + ], +) +def test_fireflies_terminates_and_restores_representative_inputs(input_data: str) -> None: + """Empty, tiny, sparse, one-dimensional, and multiline inputs should finish exactly.""" + iterator = _make_iterator(input_data, seed=43, wander_cycles=1, movement_speed=1.0) + original = { + character.character_id: (character.input_symbol, character.input_coord) + for character in iterator.terminal.get_characters() + } + + _run_to_completion(iterator) + + assert iterator.phase is effect_fireflies.FirefliesPhase.COMPLETE + for character in iterator.terminal.get_characters(): + expected_symbol, expected_coord = original[character.character_id] + assert character.animation.current_character_visual.symbol == expected_symbol + assert character.motion.current_coord == expected_coord + + +def test_fireflies_handles_whitespace_only_python_input() -> None: + """Whitespace-only API input should render one quiet frame and terminate without particles.""" + iterator = _make_iterator(" \n ", seed=47, auxiliary_count=6, wander_cycles=1, movement_speed=1.0) + + frames, _ = _run_to_completion(iterator, max_frames=10) + + assert frames == [" "] + assert iterator.fireflies == [] + assert len(iterator.auxiliary_pool) == 0 + assert iterator.phase is effect_fireflies.FirefliesPhase.COMPLETE + + +@pytest.mark.parametrize( + ("input_data", "mode", "expected_colors"), + [ + ("\x1b[38;5;196mA\x1b[0m", "dynamic", ColorPair(fg=Color(196))), + ("\x1b[48;5;106m \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_fireflies_restores_ansi_input_colors( + input_data: str, + mode: Literal["always", "dynamic"], + expected_colors: ColorPair, +) -> None: + """Dynamic and always modes should restore parsed fg/bg channels, including colored spaces.""" + iterator = _make_iterator( + input_data, + seed=53, + wander_cycles=1, + movement_speed=1.0, + existing_color_handling=mode, + ) + + _run_to_completion(iterator) + + character = iterator.terminal.get_characters()[0] + assert character.animation.current_character_visual.symbol == character.input_symbol + assert character.animation.current_character_visual.colors == expected_colors + assert iterator.character_final_color_map[character] == expected_colors + + +def test_fireflies_ignore_mode_uses_effect_gradient_for_ansi_input() -> None: + """Ignore mode should finish with the configured gradient rather than parsed input colors.""" + iterator = _make_iterator( + "\x1b[38;5;196mA\x1b[0m", + seed=59, + wander_cycles=1, + movement_speed=1.0, + existing_color_handling="ignore", + ) + + _run_to_completion(iterator) + + character = iterator.terminal.get_characters()[0] + assert character.animation.current_character_visual.colors == iterator.character_final_color_map[character] + assert character.animation.current_character_visual.colors != ColorPair(fg=Color(196)) + + +def test_fireflies_representative_cli_execution() -> None: + """The installed module entry point should render Fireflies successfully with a fixed seed.""" + input_file = Path(__file__).parents[1] / "testinput" / "single_char.txt" + + result = subprocess.run( # noqa: S603 + [ + sys.executable, + "-m", + "terminaltexteffects", + "--input-file", + str(input_file), + "--frame-rate", + "0", + "--seed", + "7", + "fireflies", + "--movement-speed", + "1", + "--wander-cycles", + "1", + "--auxiliary-count", + "0", + ], + check=False, + capture_output=True, + text=True, + cwd=Path(__file__).parents[2], + ) + + assert result.returncode == 0, result.stderr + assert "a" in result.stdout